返回 DeepSeek-Reasonix
independent_session_boundaries_test.go
根目录 / desktop / independent_session_boundaries_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "errors"
6 "path/filepath"
7 "reflect"
8 "testing"
9
10 "reasonix/desktop/internal/workspacestate"
11 "reasonix/internal/provider"
12 "reasonix/internal/session"
13 )
14
15 func TestIndependentForkResumesPublicationWithSameOperationWithoutResettingChoices(t *testing.T) {
16 for _, restart := range []bool{false, true} {
17 t.Run(map[bool]string{false: "same-process-retry", true: "restart-recovery"}[restart], func(t *testing.T) { independentForkPublicationRecovery(t, restart) })
18 }
19 }
20
21 func independentForkPublicationRecovery(t *testing.T, restart bool) {
22 app, root, refs := canonicalOrganizationFixture(t, "parent", "sibling")
23 parentRef, siblingRef := refs["parent"], refs["sibling"]
24 parentKey := projectNodeSessionKey(ProjectNode{Session: &parentRef})
25 siblingKey := projectNodeSessionKey(ProjectNode{Session: &siblingRef})
26 if err := app.ReorderSessions("project", root, []string{parentKey, siblingKey}); err != nil {
27 t.Fatal(err)
28 }
29 if err := app.SaveSessionGroups("project", root, []desktopGroup{{ID: "parent-group", Title: "Parent group", SessionKeys: []string{parentKey}}}); err != nil {
30 t.Fatal(err)
31 }
32 service := app.desktopSessionService("")
33 parent, ok := service.Runtime(refs["parent"])
34 if !ok {
35 t.Fatal("parent runtime missing")
36 }
37 payload, _ := json.Marshal(map[string]any{"message": provider.Message{ID: "answer", Role: provider.RoleAssistant, Content: "retained fork history"}})
38 if _, err := parent.Session().Append(t.Context(), session.Batch{OperationID: "turn-1", TurnID: "turn-1", Events: []session.Event{
39 {Kind: "turn/start"}, {Kind: "message/complete", Payload: payload}, {Kind: "turn/end", Payload: json.RawMessage(`{"status":"completed"}`)},
40 }}); err != nil {
41 t.Fatal(err)
42 }
43 if _, err := parent.Session().Flush(t.Context()); err != nil {
44 t.Fatal(err)
45 }
46 plan, err := app.planCanonicalFork(refs["parent"], "turn-1")
47 if err != nil {
48 t.Fatal(err)
49 }
50 operationID, childID := "publication-retry", "publication-child"
51 // Force the real persistence -> registry publication race without timing or
52 // a production test hook: the captured plan is now one lifecycle behind.
53 if err := app.workspaceRegistry().ArchiveSession(t.Context(), refs["parent"].SessionID); err != nil {
54 t.Fatal(err)
55 }
56 if err := app.workspaceRegistry().RestoreSession(t.Context(), refs["parent"].SessionID); err != nil {
57 t.Fatal(err)
58 }
59 if _, err := app.executeCanonicalFork(refs["parent"], plan, operationID, childID); !errors.Is(err, workspacestate.ErrMutationConflict) {
60 t.Fatalf("stale publication error = %v, want mutation conflict", err)
61 }
62 state, err := app.workspaceRegistry().Load(t.Context())
63 if err != nil {
64 t.Fatal(err)
65 }
66 if pending, ok := state.PendingCreates[childID]; !ok || pending.OperationID != operationID {
67 t.Fatalf("pending publication lost: %#v", state.PendingCreates)
68 }
69 childRef := session.SessionRef{HostID: "local", SessionID: childID}
70 if _, err := service.Query().Snapshot(t.Context(), childRef); err != nil {
71 t.Fatalf("failed publication lost durable child: %v", err)
72 }
73 if _, attached := state.SessionStates[childID]; attached {
74 t.Fatal("failed publication attached child")
75 }
76 if restart {
77 serviceRoot, registryPath := app.desktopSessions.root, app.workspaceRegistry().Path()
78 app.closeSessionServices()
79 app = NewApp()
80 app.ctx = t.Context()
81 app.desktopSessions.root = serviceRoot
82 app.desktopSessions.workspaceState = workspacestate.NewStore(registryPath)
83 t.Cleanup(app.closeSessionServices)
84 if err := app.recoverDesktopPendingCreates(t.Context()); err != nil {
85 t.Fatal(err)
86 }
87 state, err = app.workspaceRegistry().Load(t.Context())
88 if err != nil {
89 t.Fatal(err)
90 }
91 if want := []string{"parent", childID, "sibling"}; !reflect.DeepEqual(state.Workspaces[plan.workspaceID].SessionIDs, want) {
92 t.Fatalf("recovery order=%v want=%v", state.Workspaces[plan.workspaceID].SessionIDs, want)
93 }
94 organization, err := app.GetSessionOrganization(SessionOrganizationWorkspace{Scope: "project", WorkspaceRoot: root})
95 if err != nil {
96 t.Fatal(err)
97 }
98 if len(organization.Groups) != 1 || !reflect.DeepEqual(organization.Groups[0].SessionKeys, []string{parentKey, projectNodeSessionKey(ProjectNode{Session: &childRef})}) {
99 t.Fatalf("recovery lost inherited group: %#v", organization.Groups)
100 }
101 }
102 plan, err = app.planCanonicalFork(refs["parent"], "turn-1")
103 if err != nil {
104 t.Fatal(err)
105 }
106 child, err := app.executeCanonicalFork(refs["parent"], plan, operationID, childID)
107 if err != nil {
108 t.Fatal(err)
109 }
110 if child != childRef {
111 t.Fatalf("retry changed child identity: %#v", child)
112 }
113 if _, err := app.RenameSessionTarget(SessionSelector{Ref: &childRef}, "User chosen child title"); err != nil {
114 t.Fatal(err)
115 }
116 if err := app.SaveSessionGroups("project", root, []desktopGroup{{ID: "chosen", Title: "Chosen", SessionKeys: []string{projectNodeSessionKey(ProjectNode{Session: &childRef})}}}); err != nil {
117 t.Fatal(err)
118 }
119 if err := app.SetSessionPinned(SessionSelector{Ref: &childRef}, true); err != nil {
120 t.Fatal(err)
121 }
122 before, err := app.workspaceRegistry().Load(t.Context())
123 if err != nil {
124 t.Fatal(err)
125 }
126 if _, err := app.executeCanonicalFork(refs["parent"], plan, operationID, childID); err != nil {
127 t.Fatal(err)
128 }
129 after, err := app.workspaceRegistry().Load(t.Context())
130 if err != nil {
131 t.Fatal(err)
132 }
133 if _, exists := after.PendingCreates[childID]; exists {
134 t.Fatal("retry left pending publication")
135 }
136 if !reflect.DeepEqual(before.Presentation[childID], after.Presentation[childID]) {
137 t.Fatalf("retry reset user presentation: %#v -> %#v", before.Presentation[childID], after.Presentation[childID])
138 }
139 if !reflect.DeepEqual(before.Workspaces[plan.workspaceID].Organization, after.Workspaces[plan.workspaceID].Organization) {
140 t.Fatal("retry reset user group/order")
141 }
142 count := 0
143 for _, id := range after.Workspaces[plan.workspaceID].SessionIDs {
144 if id == childID {
145 count++
146 }
147 }
148 if count != 1 {
149 t.Fatalf("child membership count=%d", count)
150 }
151 }
152
153 func TestIndependentSessionForkRespectsManualSidebarOrder(t *testing.T) {
154 isolateDesktopUserDirs(t)
155 root := t.TempDir()
156 app := NewApp()
157 t.Cleanup(app.closeSessionServices)
158 app.ctx = t.Context()
159 app.desktopSessions.root = filepath.Join(root, "desktop-sessions-v5", "by-id")
160 app.desktopSessions.workspaceState = workspacestate.NewStore(filepath.Join(root, "desktop", "workspace-state-v1.json"))
161 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
162 if err != nil {
163 t.Fatal(err)
164 }
165 if err := addProject(root, "Fork project"); err != nil {
166 t.Fatal(err)
167 }
168 parent, err := app.desktopSessionService("").Create(t.Context(), session.CreateOptions{
169 SessionID: "target-fork-parent", CWD: root, Origin: session.SessionOriginNew,
170 })
171 if err != nil {
172 t.Fatal(err)
173 }
174 payload, err := json.Marshal(map[string]any{"message": provider.Message{
175 ID: "answer", Role: provider.RoleAssistant, Content: "forked target history",
176 }})
177 if err != nil {
178 t.Fatal(err)
179 }
180 if _, err := parent.Session().Append(t.Context(), session.Batch{
181 OperationID: "turn-1", TurnID: "turn-1",
182 Events: []session.Event{
183 {Kind: "turn/start"},
184 {Kind: "message/complete", Payload: payload},
185 {Kind: "turn/end", Payload: json.RawMessage(`{"status":"completed"}`)},
186 },
187 }); err != nil {
188 t.Fatal(err)
189 }
190 if _, err := parent.Session().Flush(t.Context()); err != nil {
191 t.Fatal(err)
192 }
193 if err := app.desktopSessions.workspaceState.AttachSession(t.Context(), "", workspaceID, parent.Ref().SessionID, ""); err != nil {
194 t.Fatal(err)
195 }
196 if err := app.desktopSessionService("").SetTitle(t.Context(), parent.Ref(), "Parent work"); err != nil {
197 t.Fatal(err)
198 }
199 parentTitle, parentPinned := "Parent work", true
200 if err := app.workspaceRegistry().UpdatePresentation(t.Context(), []string{parent.Ref().SessionID}, &parentTitle, &parentPinned); err != nil {
201 t.Fatal(err)
202 }
203 parentRef := parent.Ref()
204 if err := app.SaveSessionGroups("project", root, []desktopGroup{{
205 ID: "feature", Title: "Feature", SessionKeys: []string{projectNodeSessionKey(ProjectNode{Session: &parentRef})},
206 }}); err != nil {
207 t.Fatal(err)
208 }
209 app.tabs = map[string]*WorkspaceTab{"active": {ID: "active", SessionID: "unrelated"}}
210 app.activeTabID = "active"
211
212 sibling, err := app.desktopSessionService("").Create(t.Context(), session.CreateOptions{SessionID: "sibling", CWD: root, Origin: session.SessionOriginNew})
213 if err != nil {
214 t.Fatal(err)
215 }
216 if err := app.workspaceRegistry().AttachSession(t.Context(), "", workspaceID, sibling.Ref().SessionID, ""); err != nil {
217 t.Fatal(err)
218 }
219 parentKey := projectNodeSessionKey(ProjectNode{Session: &parentRef})
220 siblingRef := sibling.Ref()
221 siblingKey := projectNodeSessionKey(ProjectNode{Session: &siblingRef})
222 if err := app.ReorderSessions("project", root, []string{parentKey, siblingKey}); err != nil {
223 t.Fatal(err)
224 }
225
226 selector := SessionSelector{Ref: &session.SessionRef{HostID: localDesktopHostID, SessionID: parent.Ref().SessionID}}
227 first, err := app.ForkSessionTarget(selector, "turn-1")
228 if err != nil {
229 t.Fatal(err)
230 }
231
232 page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 10})
233 if err != nil {
234 t.Fatal(err)
235 }
236 var got []string
237 for _, row := range page.Items {
238 if row.Session != nil {
239 got = append(got, row.Session.SessionID)
240 }
241 }
242 want := []string{parent.Ref().SessionID, first.SessionID, "sibling"}
243 if !reflect.DeepEqual(got, want) {
244 t.Fatalf("sidebar order = %v; want parent, child, sibling = %v", got, want)
245 }
246 }
247
248 func TestIndependentSessionOrderKeepsFrozenCursor(t *testing.T) {
249 isolateDesktopUserDirs(t)
250 root := t.TempDir()
251 app := NewApp()
252 t.Cleanup(app.closeSessionServices)
253 app.ctx = t.Context()
254 app.desktopSessions.root = filepath.Join(root, "by-id")
255 app.desktopSessions.workspaceState = workspacestate.NewStore(filepath.Join(root, "state.json"))
256 ws, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
257 if err != nil {
258 t.Fatal(err)
259 }
260 if err := addProject(root, "Review"); err != nil {
261 t.Fatal(err)
262 }
263 var keys []string
264 for _, id := range []string{"a", "b", "c"} {
265 runtime, err := app.desktopSessionService("").Create(t.Context(), session.CreateOptions{SessionID: id, CWD: root, Origin: session.SessionOriginNew})
266 if err != nil {
267 t.Fatal(err)
268 }
269 if err := app.workspaceRegistry().AttachSession(t.Context(), "", ws, id, ""); err != nil {
270 t.Fatal(err)
271 }
272 ref := runtime.Ref()
273 keys = append(keys, projectNodeSessionKey(ProjectNode{Session: &ref}))
274 }
275 if err := app.ReorderSessions("project", root, keys); err != nil {
276 t.Fatal(err)
277 }
278 first, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 1})
279 if err != nil {
280 t.Fatal(err)
281 }
282 if first.NextCursor == "" || len(first.Items) != 1 || first.Items[0].Session == nil || first.Items[0].Session.SessionID != "a" {
283 t.Fatalf("first page = %#v; want A and a next-page cursor", first)
284 }
285 if err := app.ReorderSessions("project", root, []string{keys[2], keys[0], keys[1]}); err != nil {
286 t.Fatal(err)
287 }
288 second, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 1, Cursor: first.NextCursor})
289 if err != nil || len(second.Items) != 1 || second.Items[0].Session.SessionID != "b" || second.SnapshotID != first.SnapshotID {
290 t.Fatalf("reorder disturbed frozen continuation: %+v %v", second, err)
291 }
292 refreshed, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "project", WorkspaceRoot: root, Limit: 10})
293 if err != nil {
294 t.Fatal(err)
295 }
296 var got []string
297 for _, row := range refreshed.Items {
298 if row.Session != nil {
299 got = append(got, row.Session.SessionID)
300 }
301 }
302 if want := []string{"c", "a", "b"}; !reflect.DeepEqual(got, want) {
303 t.Fatalf("refreshed order = %v, want %v", got, want)
304 }
305 }
306
306 lines GO