返回 DeepSeek-Reasonix
session_workspace_api_test.go
根目录 / desktop / session_workspace_api_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "path/filepath"
6 "testing"
7
8 "reasonix/desktop/internal/workspacestate"
9 "reasonix/internal/provider"
10 "reasonix/internal/session"
11 )
12
13 func TestCreateSessionBootstrapsGlobalWorkspace(t *testing.T) {
14 root := t.TempDir()
15 app := NewApp()
16 t.Cleanup(app.closeSessionServices)
17 app.ctx = t.Context()
18 app.desktopSessions.root = filepath.Join(root, "desktop-sessions-v5", "by-id")
19 app.desktopSessions.workspaceState = workspacestate.NewStore(filepath.Join(root, "desktop", "workspace-state-v1.json"))
20
21 ref, err := app.CreateSession(workspacestate.GlobalWorkspaceID)
22 if err != nil {
23 t.Fatalf("CreateSession(global): %v", err)
24 }
25 if err := validateLocalSessionRef(ref); err != nil {
26 t.Fatalf("CreateSession(global) ref: %v", err)
27 }
28 state, err := app.desktopSessions.workspaceState.Load(t.Context())
29 if err != nil {
30 t.Fatal(err)
31 }
32 workspace, ok := state.Workspaces[workspacestate.GlobalWorkspaceID]
33 if !ok {
34 t.Fatal("CreateSession(global) did not register the global workspace")
35 }
36 if len(workspace.SessionIDs) != 1 || workspace.SessionIDs[0] != ref.SessionID {
37 t.Fatalf("global sessions = %v, want [%s]", workspace.SessionIDs, ref.SessionID)
38 }
39 }
40
41 func TestWorkspaceSessionListSurvivesRuntimePruneAndAppRestart(t *testing.T) {
42 root := t.TempDir()
43 sessionRoot := filepath.Join(root, "desktop-sessions-v5", "by-id")
44 statePath := filepath.Join(root, "desktop", "workspace-state-v1.json")
45 projectRoot := filepath.Join(root, "project")
46
47 first := NewApp()
48 t.Cleanup(first.closeSessionServices)
49 first.desktopSessions.root = sessionRoot
50 first.desktopSessions.workspaceState = workspacestate.NewStore(statePath)
51 service := first.desktopSessionService(filepath.Join(projectRoot, "sessions"))
52 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "durable-session", CWD: projectRoot, Origin: session.SessionOriginNew})
53 if err != nil {
54 t.Fatalf("Create: %v", err)
55 }
56 payload, err := json.Marshal(map[string]any{"message": map[string]any{"id": "user-1", "role": "user", "content": "keep me"}})
57 if err != nil {
58 t.Fatalf("Marshal: %v", err)
59 }
60 if _, err := runtime.Session().AppendBatch(t.Context(), "user-turn", []session.Event{{Kind: "message/complete", Payload: payload}}); err != nil {
61 t.Fatalf("AppendBatch: %v", err)
62 }
63 if _, err := runtime.Session().Flush(t.Context()); err != nil {
64 t.Fatalf("Flush: %v", err)
65 }
66 workspaceID, err := first.ensureDesktopWorkspace(t.Context(), "project", projectRoot)
67 if err != nil {
68 t.Fatalf("ensureDesktopWorkspace: %v", err)
69 }
70 if err := first.desktopSessions.workspaceState.AttachSession(t.Context(), "", workspaceID, runtime.Ref().SessionID, ""); err != nil {
71 t.Fatalf("AttachSession: %v", err)
72 }
73 first.closeSessionServices()
74
75 second := NewApp()
76 t.Cleanup(second.closeSessionServices)
77 second.desktopSessions.root = sessionRoot
78 second.desktopSessions.workspaceState = workspacestate.NewStore(statePath)
79 page, err := second.ListWorkspaceSessions(workspaceID, "", "", 10, false)
80 if err != nil {
81 t.Fatalf("ListWorkspaceSessions after restart: %v", err)
82 }
83 if len(page.Sessions) != 1 || page.Sessions[0].Ref.SessionID != "durable-session" || page.Sessions[0].Preview != "keep me" {
84 t.Fatalf("sessions after restart = %#v", page.Sessions)
85 }
86 if err := second.ArchiveCanonicalSession(page.Sessions[0].Ref); err != nil {
87 t.Fatalf("ArchiveCanonicalSession: %v", err)
88 }
89 visible, err := second.ListWorkspaceSessions(workspaceID, "", "", 10, false)
90 if err != nil {
91 t.Fatalf("List visible: %v", err)
92 }
93 if len(visible.Sessions) != 0 {
94 t.Fatalf("visible archived sessions = %#v", visible.Sessions)
95 }
96 archived, err := second.ListWorkspaceSessions(workspaceID, "", "", 10, true)
97 if err != nil {
98 t.Fatalf("List archived: %v", err)
99 }
100 if len(archived.Sessions) != 1 || !archived.Sessions[0].Archived {
101 t.Fatalf("archived sessions = %#v", archived.Sessions)
102 }
103 if err := second.RestoreCanonicalSession(archived.Sessions[0].Ref); err != nil {
104 t.Fatalf("RestoreCanonicalSession: %v", err)
105 }
106 }
107
108 func TestSessionRefHistoryAndRenameDoNotNeedController(t *testing.T) {
109 root := t.TempDir()
110 app := NewApp()
111 t.Cleanup(app.closeSessionServices)
112 app.desktopSessions.root = filepath.Join(root, "desktop-sessions-v5", "by-id")
113 app.desktopSessions.workspaceState = workspacestate.NewStore(filepath.Join(root, "desktop", "workspace-state-v1.json"))
114 service := app.desktopSessionService("")
115 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "cold-session", CWD: root, Origin: session.SessionOriginNew})
116 if err != nil {
117 t.Fatal(err)
118 }
119 payload, _ := json.Marshal(map[string]any{"message": map[string]any{"id": "user-1", "role": "user", "content": "cold history"}})
120 if _, err := runtime.Session().AppendBatch(t.Context(), "turn", []session.Event{{Kind: "message/complete", Payload: payload}}); err != nil {
121 t.Fatal(err)
122 }
123 if _, err := runtime.Session().Flush(t.Context()); err != nil {
124 t.Fatal(err)
125 }
126 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "global", "")
127 if err != nil {
128 t.Fatal(err)
129 }
130 if err := app.desktopSessions.workspaceState.AttachSession(t.Context(), "", workspaceID, "cold-session", ""); err != nil {
131 t.Fatal(err)
132 }
133 if err := service.Close(t.Context(), runtime.Ref()); err != nil {
134 t.Fatal(err)
135 }
136 page, err := app.ReadSessionHistory(runtime.Ref(), "", 10)
137 if err != nil {
138 t.Fatal(err)
139 }
140 if len(page.Messages) != 1 || page.Messages[0].Content != "cold history" {
141 t.Fatalf("history = %#v", page.Messages)
142 }
143 if err := app.RenameCanonicalSession(runtime.Ref(), "Cold title"); err != nil {
144 t.Fatal(err)
145 }
146 listed, err := app.ListWorkspaceSessions(workspaceID, "", "", 10, false)
147 if err != nil {
148 t.Fatal(err)
149 }
150 if len(listed.Sessions) != 1 || listed.Sessions[0].Title != "Cold title" {
151 t.Fatalf("renamed list = %#v", listed.Sessions)
152 }
153 missing := session.SessionRef{HostID: "local", SessionID: "missing"}
154 if _, err := app.ReadSessionHistory(missing, "", 10); err == nil {
155 t.Fatal("missing SessionID produced readable replacement history")
156 }
157 if _, err := service.Query().Snapshot(t.Context(), missing); err == nil {
158 t.Fatal("missing SessionID was created as an empty replacement")
159 }
160 }
161
162 func TestForkSessionPublishesHeaderBackedChildAfterParent(t *testing.T) {
163 root := t.TempDir()
164 app := NewApp()
165 t.Cleanup(app.closeSessionServices)
166 app.ctx = t.Context()
167 app.desktopSessions.root = filepath.Join(root, "desktop-sessions-v5", "by-id")
168 app.desktopSessions.workspaceState = workspacestate.NewStore(filepath.Join(root, "desktop", "workspace-state-v1.json"))
169 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
170 if err != nil {
171 t.Fatal(err)
172 }
173 parent, err := app.desktopSessionService("").Create(t.Context(), session.CreateOptions{SessionID: "fork-parent", CWD: root, Origin: session.SessionOriginNew})
174 if err != nil {
175 t.Fatal(err)
176 }
177 payload, _ := json.Marshal(map[string]any{"message": provider.Message{ID: "answer", Role: provider.RoleAssistant, Content: "forked history"}})
178 if _, err := parent.Session().Append(t.Context(), session.Batch{OperationID: "turn-1", TurnID: "turn-1", Events: []session.Event{
179 {Kind: "turn/start"}, {Kind: "message/complete", Payload: payload}, {Kind: "turn/end", Payload: json.RawMessage(`{"status":"completed"}`)},
180 }}); err != nil {
181 t.Fatal(err)
182 }
183 if _, err := parent.Session().Flush(t.Context()); err != nil {
184 t.Fatal(err)
185 }
186 if err := app.desktopSessions.workspaceState.AttachSession(t.Context(), "", workspaceID, parent.Ref().SessionID, ""); err != nil {
187 t.Fatal(err)
188 }
189 child, err := app.ForkSession(parent.Ref(), "turn-1")
190 if err != nil {
191 t.Fatal(err)
192 }
193 state, err := app.desktopSessions.workspaceState.Load(t.Context())
194 if err != nil {
195 t.Fatal(err)
196 }
197 ids := state.Workspaces[workspaceID].SessionIDs
198 if len(ids) != 2 || ids[0] != parent.Ref().SessionID || ids[1] != child.SessionID {
199 t.Fatalf("fork order = %#v", ids)
200 }
201 infos, err := listAllCanonicalSessionInfo(t.Context(), app.desktopSessionService("").Query())
202 if err != nil {
203 t.Fatal(err)
204 }
205 if infos[child.SessionID].ParentSessionID != parent.Ref().SessionID || infos[child.SessionID].Origin != session.SessionOriginFork {
206 t.Fatalf("fork header = %+v", infos[child.SessionID])
207 }
208 }
209
210 func TestSidebarKeepsCanonicalSessionsWithSharedTopicIndependent(t *testing.T) {
211 isolateDesktopUserDirs(t)
212 root := t.TempDir()
213 app := NewApp()
214 t.Cleanup(app.closeSessionServices)
215 app.ctx = t.Context()
216 app.desktopSessions.root = filepath.Join(root, "desktop-sessions-v5", "by-id")
217 app.desktopSessions.workspaceState = workspacestate.NewStore(filepath.Join(root, "desktop", "workspace-state-v1.json"))
218 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
219 if err != nil {
220 t.Fatal(err)
221 }
222 for _, id := range []string{"branch-a", "branch-b"} {
223 if _, err := app.desktopSessionService("").Create(t.Context(), session.CreateOptions{SessionID: id, CWD: root, Origin: session.SessionOriginNew}); err != nil {
224 t.Fatal(err)
225 }
226 if err := app.workspaceRegistry().AttachSession(t.Context(), "", workspaceID, id, ""); err != nil {
227 t.Fatal(err)
228 }
229 if err := app.workspaceRegistry().EnsureSessionTopic(t.Context(), id, "shared-topic", "Shared topic"); err != nil {
230 t.Fatal(err)
231 }
232 }
233 page, err := app.unifiedProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 10})
234 if err != nil {
235 t.Fatal(err)
236 }
237 if len(page.Items) != 2 {
238 t.Fatalf("two durable sessions should occupy two rows: %#v", page.Items)
239 }
240 seen := map[string]bool{}
241 for _, row := range page.Items {
242 if row.Session == nil {
243 t.Fatalf("row has no session identity: %#v", row)
244 }
245 if row.TopicID != "shared-topic" || len(row.Children) != 0 {
246 t.Fatalf("row is not an independent session: %#v", row)
247 }
248 seen[row.Session.SessionID] = true
249 }
250 if !seen["branch-a"] || !seen["branch-b"] {
251 t.Fatalf("lost a durable branch: %#v", seen)
252 }
253 }
254
255 func TestForkSessionTargetRetryReusesDurableChildWithoutOpeningTab(t *testing.T) {
256 isolateDesktopUserDirs(t)
257 root := t.TempDir()
258 app := NewApp()
259 t.Cleanup(app.closeSessionServices)
260 app.ctx = t.Context()
261 app.desktopSessions.root = filepath.Join(root, "desktop-sessions-v5", "by-id")
262 app.desktopSessions.workspaceState = workspacestate.NewStore(filepath.Join(root, "desktop", "workspace-state-v1.json"))
263 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
264 if err != nil {
265 t.Fatal(err)
266 }
267 if err := addProject(root, "Fork project"); err != nil {
268 t.Fatal(err)
269 }
270 parent, err := app.desktopSessionService("").Create(t.Context(), session.CreateOptions{
271 SessionID: "target-fork-parent", CWD: root, Origin: session.SessionOriginNew,
272 })
273 if err != nil {
274 t.Fatal(err)
275 }
276 payload, _ := json.Marshal(map[string]any{"message": provider.Message{
277 ID: "answer", Role: provider.RoleAssistant, Content: "forked target history",
278 }})
279 if _, err := parent.Session().Append(t.Context(), session.Batch{
280 OperationID: "turn-1", TurnID: "turn-1",
281 Events: []session.Event{
282 {Kind: "turn/start"},
283 {Kind: "message/complete", Payload: payload},
284 {Kind: "turn/end", Payload: json.RawMessage(`{"status":"completed"}`)},
285 },
286 }); err != nil {
287 t.Fatal(err)
288 }
289 if _, err := parent.Session().Flush(t.Context()); err != nil {
290 t.Fatal(err)
291 }
292 if err := app.desktopSessions.workspaceState.AttachSession(t.Context(), "", workspaceID, parent.Ref().SessionID, ""); err != nil {
293 t.Fatal(err)
294 }
295 if err := app.desktopSessionService("").SetTitle(t.Context(), parent.Ref(), "Parent work"); err != nil {
296 t.Fatal(err)
297 }
298 parentTitle, parentPinned := "Parent work", true
299 if err := app.workspaceRegistry().UpdatePresentation(t.Context(), []string{parent.Ref().SessionID}, &parentTitle, &parentPinned); err != nil {
300 t.Fatal(err)
301 }
302 parentRef := parent.Ref()
303 if err := app.SaveSessionGroups("project", root, []desktopGroup{{
304 ID: "feature", Title: "Feature", SessionKeys: []string{projectNodeSessionKey(ProjectNode{Session: &parentRef})},
305 }}); err != nil {
306 t.Fatal(err)
307 }
308 app.tabs = map[string]*WorkspaceTab{"active": {ID: "active", SessionID: "unrelated"}}
309 app.activeTabID = "active"
310
311 selector := SessionSelector{Ref: &session.SessionRef{HostID: localDesktopHostID, SessionID: parent.Ref().SessionID}}
312 first, err := app.ForkSessionTarget(selector, "turn-1")
313 if err != nil {
314 t.Fatal(err)
315 }
316 second, err := app.ForkSessionTarget(selector, "turn-1")
317 if err != nil {
318 t.Fatal(err)
319 }
320 if first != second {
321 t.Fatalf("fork retry created another child: first=%+v second=%+v", first, second)
322 }
323 state, err := app.desktopSessions.workspaceState.Load(t.Context())
324 if err != nil {
325 t.Fatal(err)
326 }
327 ids := state.Workspaces[workspaceID].SessionIDs
328 if len(ids) != 2 || ids[0] != parent.Ref().SessionID || ids[1] != first.SessionID {
329 t.Fatalf("workspace children after retry = %#v", ids)
330 }
331 childPresentation := state.Presentation[first.SessionID]
332 if childPresentation.Pinned || childPresentation.Title != "Parent work · 分叉" {
333 t.Fatalf("fork presentation = %+v, want inherited title suffix without pin", childPresentation)
334 }
335 groups, err := app.ListProjectGroups("project", root)
336 if err != nil || len(groups) != 1 || !containsDesktopString(groups[0].SessionKeys, projectNodeSessionKey(ProjectNode{Session: &first})) {
337 t.Fatalf("fork groups = %#v, err=%v; child should inherit parent group", groups, err)
338 }
339 if app.activeTabID != "active" || len(app.tabs) != 1 {
340 t.Fatalf("target fork changed navigation: active=%q tabs=%d", app.activeTabID, len(app.tabs))
341 }
342 journal, err := loadForkOperations(forkOperationsPath())
343 if err != nil {
344 t.Fatal(err)
345 }
346 if len(journal.Operations) != 1 || journal.Operations[0].State != "completed" ||
347 journal.Operations[0].ChildSessionID != first.SessionID {
348 t.Fatalf("fork journal = %+v", journal.Operations)
349 }
350 }
351
352 func TestCopySessionTargetCopiesFullHistoryIdempotentlyWithoutOpeningTab(t *testing.T) {
353 isolateDesktopUserDirs(t)
354 root := t.TempDir()
355 app := NewApp()
356 t.Cleanup(app.closeSessionServices)
357 app.ctx = t.Context()
358 app.desktopSessions.root = filepath.Join(root, "desktop-sessions-v5", "by-id")
359 app.desktopSessions.workspaceState = workspacestate.NewStore(filepath.Join(root, "desktop", "workspace-state-v1.json"))
360 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
361 if err != nil {
362 t.Fatal(err)
363 }
364 source, err := app.desktopSessionService("").Create(t.Context(), session.CreateOptions{
365 SessionID: "copy-target-source", CWD: root, Origin: session.SessionOriginNew,
366 })
367 if err != nil {
368 t.Fatal(err)
369 }
370 payload, _ := json.Marshal(map[string]any{"message": provider.Message{
371 ID: "copy-message", Role: provider.RoleUser, Content: "copy the entire durable history",
372 }})
373 if _, err := source.Session().AppendBatch(t.Context(), "copy-message", []session.Event{{Kind: "message/complete", Payload: payload}}); err != nil {
374 t.Fatal(err)
375 }
376 if _, err := source.Session().Flush(t.Context()); err != nil {
377 t.Fatal(err)
378 }
379 if err := app.workspaceRegistry().AttachSession(t.Context(), "", workspaceID, source.Ref().SessionID, ""); err != nil {
380 t.Fatal(err)
381 }
382 app.tabs = map[string]*WorkspaceTab{"active": {ID: "active", SessionID: "unrelated"}}
383 app.activeTabID = "active"
384 selector := SessionSelector{Ref: &session.SessionRef{HostID: localDesktopHostID, SessionID: source.Ref().SessionID}}
385
386 first, err := app.CopySessionTarget(selector, "copy-request-1")
387 if err != nil {
388 t.Fatal(err)
389 }
390 second, err := app.CopySessionTarget(selector, "copy-request-1")
391 if err != nil {
392 t.Fatal(err)
393 }
394 if !first.Committed || first.Ref != second.Ref || first.OperationID != "copy-request-1" {
395 t.Fatalf("copy retries = first:%+v second:%+v", first, second)
396 }
397 history, err := app.desktopSessionService("").Query().History(t.Context(), first.Ref)
398 if err != nil {
399 t.Fatal(err)
400 }
401 if len(history) != 1 || history[0].Content != "copy the entire durable history" {
402 t.Fatalf("copy history = %+v", history)
403 }
404 state, err := app.workspaceRegistry().Load(t.Context())
405 if err != nil {
406 t.Fatal(err)
407 }
408 ids := state.Workspaces[workspaceID].SessionIDs
409 if len(ids) != 2 || ids[0] != source.Ref().SessionID || ids[1] != first.Ref.SessionID {
410 t.Fatalf("workspace copies = %#v", ids)
411 }
412 if app.activeTabID != "active" || len(app.tabs) != 1 {
413 t.Fatalf("target copy changed navigation: active=%q tabs=%d", app.activeTabID, len(app.tabs))
414 }
415 }
416
416 lines GO