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