返回 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 installNoopRuntimeEvents(app)
217 app.desktopSessions.root = filepath.Join(root, "desktop-sessions-v5", "by-id")
218 app.desktopSessions.workspaceState = workspacestate.NewStore(filepath.Join(root, "desktop", "workspace-state-v1.json"))
219 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
220 if err != nil {
221 t.Fatal(err)
222 }
223 for _, id := range []string{"branch-a", "branch-b"} {
224 if _, err := app.desktopSessionService("").Create(t.Context(), session.CreateOptions{SessionID: id, CWD: root, Origin: session.SessionOriginNew}); err != nil {
225 t.Fatal(err)
226 }
227 if err := app.workspaceRegistry().AttachSession(t.Context(), "", workspaceID, id, ""); err != nil {
228 t.Fatal(err)
229 }
230 if err := app.workspaceRegistry().EnsureSessionTopic(t.Context(), id, "shared-topic", "Shared topic"); err != nil {
231 t.Fatal(err)
232 }
233 }
234 page, err := app.unifiedProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 10})
235 if err != nil {
236 t.Fatal(err)
237 }
238 if len(page.Items) != 2 {
239 t.Fatalf("two durable sessions should occupy two rows: %#v", page.Items)
240 }
241 seen := map[string]bool{}
242 for _, row := range page.Items {
243 if row.Session == nil {
244 t.Fatalf("row has no session identity: %#v", row)
245 }
246 if row.TopicID != "shared-topic" || len(row.Children) != 0 {
247 t.Fatalf("row is not an independent session: %#v", row)
248 }
249 seen[row.Session.SessionID] = true
250 }
251 if !seen["branch-a"] || !seen["branch-b"] {
252 t.Fatalf("lost a durable branch: %#v", seen)
253 }
254 // A topic-level adoption marker must never hide another canonical member
255 // when just one of the sessions sharing that topic is permanently deleted.
256 victim := session.SessionRef{HostID: localDesktopHostID, SessionID: "branch-a"}
257 if err := app.ArchiveCanonicalSession(victim); err != nil {
258 t.Fatal(err)
259 }
260 if err := app.PurgeCanonicalSession(victim); err != nil {
261 t.Fatal(err)
262 }
263 page, err = app.unifiedProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 1})
264 if err != nil || len(page.Items) != 1 || page.Items[0].Session == nil || page.Items[0].Session.SessionID != "branch-b" || page.NextCursor != "" {
265 t.Fatalf("purging one sibling hid or duplicated the live member: %#v, %v", page, err)
266 }
267 }
268
269 func TestForkSessionTargetRetryReusesDurableChildWithoutOpeningTab(t *testing.T) {
270 isolateDesktopUserDirs(t)
271 root := t.TempDir()
272 app := NewApp()
273 t.Cleanup(app.closeSessionServices)
274 app.ctx = t.Context()
275 app.desktopSessions.root = filepath.Join(root, "desktop-sessions-v5", "by-id")
276 app.desktopSessions.workspaceState = workspacestate.NewStore(filepath.Join(root, "desktop", "workspace-state-v1.json"))
277 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
278 if err != nil {
279 t.Fatal(err)
280 }
281 if err := addProject(root, "Fork project"); err != nil {
282 t.Fatal(err)
283 }
284 parent, err := app.desktopSessionService("").Create(t.Context(), session.CreateOptions{
285 SessionID: "target-fork-parent", CWD: root, Origin: session.SessionOriginNew,
286 })
287 if err != nil {
288 t.Fatal(err)
289 }
290 payload, _ := json.Marshal(map[string]any{"message": provider.Message{
291 ID: "answer", Role: provider.RoleAssistant, Content: "forked target history",
292 }})
293 if _, err := parent.Session().Append(t.Context(), session.Batch{
294 OperationID: "turn-1", TurnID: "turn-1",
295 Events: []session.Event{
296 {Kind: "turn/start"},
297 {Kind: "message/complete", Payload: payload},
298 {Kind: "turn/end", Payload: json.RawMessage(`{"status":"completed"}`)},
299 },
300 }); err != nil {
301 t.Fatal(err)
302 }
303 if _, err := parent.Session().Flush(t.Context()); err != nil {
304 t.Fatal(err)
305 }
306 if err := app.desktopSessions.workspaceState.AttachSession(t.Context(), "", workspaceID, parent.Ref().SessionID, ""); err != nil {
307 t.Fatal(err)
308 }
309 if err := app.desktopSessionService("").SetTitle(t.Context(), parent.Ref(), "Parent work"); err != nil {
310 t.Fatal(err)
311 }
312 parentTitle, parentPinned := "Parent work", true
313 if err := app.workspaceRegistry().UpdatePresentation(t.Context(), []string{parent.Ref().SessionID}, &parentTitle, &parentPinned); err != nil {
314 t.Fatal(err)
315 }
316 parentRef := parent.Ref()
317 if err := app.SaveSessionGroups("project", root, []desktopGroup{{
318 ID: "feature", Title: "Feature", SessionKeys: []string{projectNodeSessionKey(ProjectNode{Session: &parentRef})},
319 }}); err != nil {
320 t.Fatal(err)
321 }
322 app.tabs = map[string]*WorkspaceTab{"active": {ID: "active", SessionID: "unrelated"}}
323 app.activeTabID = "active"
324
325 selector := SessionSelector{Ref: &session.SessionRef{HostID: localDesktopHostID, SessionID: parent.Ref().SessionID}}
326 first, err := app.ForkSessionTarget(selector, "turn-1")
327 if err != nil {
328 t.Fatal(err)
329 }
330 second, err := app.ForkSessionTarget(selector, "turn-1")
331 if err != nil {
332 t.Fatal(err)
333 }
334 if first != second {
335 t.Fatalf("fork retry created another child: first=%+v second=%+v", first, second)
336 }
337 state, err := app.desktopSessions.workspaceState.Load(t.Context())
338 if err != nil {
339 t.Fatal(err)
340 }
341 ids := state.Workspaces[workspaceID].SessionIDs
342 if len(ids) != 2 || ids[0] != parent.Ref().SessionID || ids[1] != first.SessionID {
343 t.Fatalf("workspace children after retry = %#v", ids)
344 }
345 childPresentation := state.Presentation[first.SessionID]
346 if childPresentation.Pinned || childPresentation.Title != "Parent work · 分叉" {
347 t.Fatalf("fork presentation = %+v, want inherited title suffix without pin", childPresentation)
348 }
349 groups, err := app.ListProjectGroups("project", root)
350 if err != nil || len(groups) != 1 || !containsDesktopString(groups[0].SessionKeys, projectNodeSessionKey(ProjectNode{Session: &first})) {
351 t.Fatalf("fork groups = %#v, err=%v; child should inherit parent group", groups, err)
352 }
353 if app.activeTabID != "active" || len(app.tabs) != 1 {
354 t.Fatalf("target fork changed navigation: active=%q tabs=%d", app.activeTabID, len(app.tabs))
355 }
356 journal, err := loadForkOperations(forkOperationsPath())
357 if err != nil {
358 t.Fatal(err)
359 }
360 if len(journal.Operations) != 1 || journal.Operations[0].State != "completed" ||
361 journal.Operations[0].ChildSessionID != first.SessionID {
362 t.Fatalf("fork journal = %+v", journal.Operations)
363 }
364 }
365
366 func TestCopySessionTargetCopiesFullHistoryIdempotentlyWithoutOpeningTab(t *testing.T) {
367 isolateDesktopUserDirs(t)
368 root := t.TempDir()
369 app := NewApp()
370 t.Cleanup(app.closeSessionServices)
371 app.ctx = t.Context()
372 app.desktopSessions.root = filepath.Join(root, "desktop-sessions-v5", "by-id")
373 app.desktopSessions.workspaceState = workspacestate.NewStore(filepath.Join(root, "desktop", "workspace-state-v1.json"))
374 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
375 if err != nil {
376 t.Fatal(err)
377 }
378 source, err := app.desktopSessionService("").Create(t.Context(), session.CreateOptions{
379 SessionID: "copy-target-source", CWD: root, Origin: session.SessionOriginNew,
380 })
381 if err != nil {
382 t.Fatal(err)
383 }
384 payload, _ := json.Marshal(map[string]any{"message": provider.Message{
385 ID: "copy-message", Role: provider.RoleUser, Content: "copy the entire durable history",
386 }})
387 if _, err := source.Session().AppendBatch(t.Context(), "copy-message", []session.Event{{Kind: "message/complete", Payload: payload}}); err != nil {
388 t.Fatal(err)
389 }
390 if _, err := source.Session().Flush(t.Context()); err != nil {
391 t.Fatal(err)
392 }
393 if err := app.workspaceRegistry().AttachSession(t.Context(), "", workspaceID, source.Ref().SessionID, ""); err != nil {
394 t.Fatal(err)
395 }
396 app.tabs = map[string]*WorkspaceTab{"active": {ID: "active", SessionID: "unrelated"}}
397 app.activeTabID = "active"
398 selector := SessionSelector{Ref: &session.SessionRef{HostID: localDesktopHostID, SessionID: source.Ref().SessionID}}
399
400 first, err := app.CopySessionTarget(selector, "copy-request-1")
401 if err != nil {
402 t.Fatal(err)
403 }
404 second, err := app.CopySessionTarget(selector, "copy-request-1")
405 if err != nil {
406 t.Fatal(err)
407 }
408 if !first.Committed || first.Ref != second.Ref || first.OperationID != "copy-request-1" {
409 t.Fatalf("copy retries = first:%+v second:%+v", first, second)
410 }
411 history, err := app.desktopSessionService("").Query().History(t.Context(), first.Ref)
412 if err != nil {
413 t.Fatal(err)
414 }
415 if len(history) != 1 || history[0].Content != "copy the entire durable history" {
416 t.Fatalf("copy history = %+v", history)
417 }
418 state, err := app.workspaceRegistry().Load(t.Context())
419 if err != nil {
420 t.Fatal(err)
421 }
422 ids := state.Workspaces[workspaceID].SessionIDs
423 if len(ids) != 2 || ids[0] != source.Ref().SessionID || ids[1] != first.Ref.SessionID {
424 t.Fatalf("workspace copies = %#v", ids)
425 }
426 if app.activeTabID != "active" || len(app.tabs) != 1 {
427 t.Fatalf("target copy changed navigation: active=%q tabs=%d", app.activeTabID, len(app.tabs))
428 }
429 }
430
430 lines GO