返回 DeepSeek-Reasonix
session_cutover_test.go
根目录 / desktop / session_cutover_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/base64"
6 "encoding/json"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "reasonix/internal/agent"
14 "reasonix/internal/boot"
15 "reasonix/internal/control"
16 "reasonix/internal/event"
17 "reasonix/internal/provider"
18 "reasonix/internal/session"
19 )
20
21 func TestTabMetaDoesNotCompareLegacyFingerprintWithCanonicalSession(t *testing.T) {
22 isolateDesktopUserDirs(t)
23 app := NewApp()
24 t.Cleanup(app.closeSessionServices)
25 sessionPath := filepath.Join(t.TempDir(), "legacy.jsonl")
26 if err := os.WriteFile(sessionPath, nil, 0o600); err != nil {
27 t.Fatal(err)
28 }
29 if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{
30 ID: "legacy", Revision: 41, ContentDigest: "legacy-digest",
31 }); err != nil {
32 t.Fatal(err)
33 }
34 tab := &WorkspaceTab{
35 ID: "canonical-without-head", SessionID: "missing-canonical-session",
36 SessionPath: sessionPath, disabledMCP: map[string]ServerView{},
37 }
38 meta := app.tabMeta(tab, true)
39 if meta.SessionRevision != 0 || meta.SessionDigest != "" {
40 t.Fatalf("canonical tab fell back to legacy fingerprint (%d, %q)", meta.SessionRevision, meta.SessionDigest)
41 }
42 }
43
44 func TestDesktopHistorySliceUsesCanonicalDurableIndex(t *testing.T) {
45 isolateDesktopUserDirs(t)
46 model, _ := configureSwitchableDefaultModels(t)
47 app := NewApp()
48 t.Cleanup(app.closeSessionServices)
49 app.ctx = context.Background()
50 root := t.TempDir()
51 dir := desktopSessionDir(root)
52 service := app.desktopSessionService(dir)
53 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "canonical-history"})
54 if err != nil {
55 t.Fatal(err)
56 }
57 appendSessionTestMessage(t, runtime, "history-user", provider.Message{ID: "history-user", Role: provider.RoleUser, Origin: provider.MessageOriginUser, Content: "durable user turn"})
58
59 ctrl, err := app.buildTabControllerBoot(app.ctx, boot.Options{Model: model, WorkspaceRoot: root, SessionDir: dir, Sink: event.Discard})
60 if err != nil {
61 t.Fatal(err)
62 }
63 if _, err := ctrl.(control.IdentityLifecycle).OpenSession(t.Context(), runtime.Ref()); err != nil {
64 t.Fatal(err)
65 }
66 large := "durable assistant " + strings.Repeat("x", historyInlineRefThreshold+1024)
67 // Append after the controller's agent projection was created. The legacy
68 // live-history path cannot see this message; the canonical query can.
69 appendSessionTestMessage(t, runtime, "history-assistant", provider.Message{ID: "history-assistant", Role: provider.RoleAssistant, Content: large})
70 legacyPath := filepath.Join(dir, "legacy-projection.jsonl")
71 if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {
72 t.Fatal(err)
73 }
74 if err := os.WriteFile(legacyPath, nil, 0o600); err != nil {
75 t.Fatal(err)
76 }
77 tab := &WorkspaceTab{ID: "canonical-history-tab", Scope: "project", WorkspaceRoot: root, SessionID: runtime.Ref().SessionID, SessionPath: legacyPath, Ready: true, Ctrl: ctrl, sink: &tabEventSink{tabID: "canonical-history-tab", app: app}, disabledMCP: map[string]ServerView{}}
78 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
79 app.tabOrder = []string{tab.ID}
80 app.activeTabID = tab.ID
81 t.Cleanup(func() { ctrl.Close() })
82
83 openView, err := app.SessionOpenForTab(tab.ID)
84 if err != nil {
85 t.Fatal(err)
86 }
87 if err := agent.SaveBranchMetaPreserveUpdated(legacyPath, agent.BranchMeta{
88 ID: "legacy-projection",
89 Revision: int64(openView.SnapshotSequence) + 100,
90 ContentDigest: "legacy-projection-digest",
91 }); err != nil {
92 t.Fatal(err)
93 }
94 meta := app.tabMeta(tab, true)
95 if meta.SessionRevision != int64(openView.SnapshotSequence) || meta.SessionDigest != openView.StorageGeneration {
96 t.Fatalf("tab canonical fingerprint = (%d, %q), want (%d, %q)",
97 meta.SessionRevision, meta.SessionDigest, openView.SnapshotSequence, openView.StorageGeneration)
98 }
99
100 page := app.HistorySliceForTab(tab.ID, HistorySliceRequest{Turns: 12, Entries: 120, Bytes: 512 << 10})
101 if page.Error != "" || page.Source != "canonical-index" {
102 t.Fatalf("canonical history page = source %q error %q", page.Source, page.Error)
103 }
104 if page.TotalTurns != 1 || len(page.Entries) != 2 {
105 t.Fatalf("canonical history shape = turns %d entries %d, want 1/2", page.TotalTurns, len(page.Entries))
106 }
107 assistant := page.Entries[1]
108 if len(assistant.Refs) != 1 || assistant.Refs[0].Field != "content" {
109 t.Fatalf("large canonical message refs = %+v", assistant.Refs)
110 }
111 chunk := app.HistoryContentForTab(tab.ID, assistant.Refs[0], 0)
112 if chunk.Stale || !chunk.Done || chunk.Data != large {
113 t.Fatalf("canonical expanded content = stale:%v done:%v bytes:%d, want %d", chunk.Stale, chunk.Done, len(chunk.Data), len(large))
114 }
115
116 appendSessionTestMessage(t, runtime, "history-next", provider.Message{ID: "history-next", Role: provider.RoleUser, Origin: provider.MessageOriginUser, Content: "new turn"})
117 if stale := app.HistoryContentForTab(tab.ID, assistant.Refs[0], 0); !stale.Stale {
118 t.Fatal("content ref from older durable snapshot must become stale after append")
119 }
120 // Stop/recovery keeps message identities while rewriting their transcript.
121 // All desktop readers must accept the runtime's reason metadata and index
122 // new versions without colliding with the previously displayed messages.
123 payload, err := json.Marshal(map[string]any{
124 "reason": "cancel-or-recovery-rewrite",
125 "messages": []provider.Message{
126 {ID: "history-user", Role: provider.RoleUser, Origin: provider.MessageOriginUser, Content: "durable user turn"},
127 {ID: "history-assistant", Role: provider.RoleAssistant, Content: large},
128 },
129 })
130 if err != nil {
131 t.Fatal(err)
132 }
133 if _, err := runtime.Session().Append(t.Context(), session.Batch{OperationID: "cancel-rewrite", Events: []session.Event{{Kind: "history/replace", Payload: payload}}}); err != nil {
134 t.Fatal(err)
135 }
136 if _, err := runtime.Session().Flush(t.Context()); err != nil {
137 t.Fatal(err)
138 }
139 if page := app.HistorySliceForTab(tab.ID, HistorySliceRequest{Turns: 12}); page.Error != "" || len(page.Entries) != 2 {
140 t.Fatalf("compatibility reader after cancel rewrite: %+v", page)
141 }
142 if page, err := app.SessionHistoryWindowForTab(tab.ID, session.HistoryWindowRequest{Anchor: "newest"}); err != nil || page.Status != "ready" || len(page.Messages) != 2 {
143 t.Fatalf("window reader after cancel rewrite: %+v, %v", page, err)
144 }
145 if page, err := app.SessionHistoryPageForTab(tab.ID, "", 10); err != nil || page.Status != "ready" || len(page.Messages) != 2 {
146 t.Fatalf("page reader after cancel rewrite: %+v, %v", page, err)
147 }
148 }
149
150 func TestDesktopCanonicalHistoryRemainsReadableBeforeControllerReady(t *testing.T) {
151 isolateDesktopUserDirs(t)
152 app := NewApp()
153 t.Cleanup(app.closeSessionServices)
154 root := t.TempDir()
155 dir := desktopSessionDir(root)
156 service := app.desktopSessionService(dir)
157 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "canonical-cold-history"})
158 if err != nil {
159 t.Fatal(err)
160 }
161 appendSessionTestMessage(t, runtime, "cold-history-user", provider.Message{
162 ID: "cold-history-user",
163 Role: provider.RoleUser,
164 Origin: provider.MessageOriginUser,
165 Content: "history survives an unavailable configured model",
166 })
167 large := "large history survives too " + strings.Repeat("x", historyInlineRefThreshold+1024)
168 appendSessionTestMessage(t, runtime, "cold-history-assistant", provider.Message{
169 ID: "cold-history-assistant", Role: provider.RoleAssistant, Content: large,
170 })
171 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
172 if err != nil {
173 t.Fatal(err)
174 }
175 if err := app.workspaceRegistry().AttachSession(t.Context(), "", workspaceID, runtime.Ref().SessionID, ""); err != nil {
176 t.Fatal(err)
177 }
178
179 tab := &WorkspaceTab{
180 ID: "canonical-cold-history-tab",
181 Scope: "project",
182 WorkspaceRoot: root,
183 SessionID: runtime.Ref().SessionID,
184 Ready: false,
185 Ctrl: nil,
186 }
187 app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
188 app.tabOrder = []string{tab.ID}
189 app.activeTabID = tab.ID
190
191 page := app.HistorySliceForTab(tab.ID, HistorySliceRequest{Turns: 12})
192 if page.Error != "" || page.Source != "canonical-index" {
193 t.Fatalf("cold canonical history page = source %q error %q", page.Source, page.Error)
194 }
195 if len(page.Entries) != 2 || page.Entries[0].Message.Content != "history survives an unavailable configured model" {
196 t.Fatalf("cold canonical history entries = %+v", page.Entries)
197 }
198 if len(page.Entries[1].Refs) != 1 {
199 t.Fatalf("cold canonical large history refs = %+v", page.Entries[1].Refs)
200 }
201 content := app.HistoryContentForTab(tab.ID, page.Entries[1].Refs[0], 0)
202 if content.Stale || !content.Done || content.Data != large {
203 t.Fatalf("cold canonical expanded history = stale:%v done:%v bytes:%d, want %d", content.Stale, content.Done, len(content.Data), len(large))
204 }
205
206 opened, err := app.SessionOpenForTab(tab.ID)
207 if err != nil {
208 t.Fatal(err)
209 }
210 if opened.Ref != runtime.Ref() || len(opened.Recent.Entries) != 2 || opened.Recent.Entries[0].Preview != "history survives an unavailable configured model" {
211 t.Fatalf("cold canonical session open = %+v", opened)
212 }
213
214 canonical, err := app.SessionHistoryPageForTab(tab.ID, "", 12)
215 if err != nil {
216 t.Fatal(err)
217 }
218 if len(canonical.Messages) != 2 || canonical.Messages[0].Preview != "history survives an unavailable configured model" {
219 t.Fatalf("cold canonical history records = %+v", canonical.Messages)
220 }
221 var search session.SearchHistoryPage
222 for deadline := time.Now().Add(5 * time.Second); search.Status != "ready"; time.Sleep(time.Millisecond) {
223 search, err = app.SearchSessionHistoryForTab(tab.ID, "unavailable configured model", "", 12)
224 if err != nil || (search.Status != "preparing" && search.Status != "ready") || time.Now().After(deadline) {
225 t.Fatalf("cold canonical search = %+v, %v", search, err)
226 }
227 }
228 if len(search.Hits) != 1 || search.Hits[0].MessageID != "cold-history-user" {
229 t.Fatalf("cold canonical search hits = %+v", search.Hits)
230 }
231 location, err := app.LocateSessionMessageForTab(tab.ID, "cold-history-assistant", canonical.SnapshotSequence)
232 if err != nil || location.Status != "ready" || location.MessageID != "cold-history-assistant" {
233 t.Fatalf("cold canonical location = %+v, %v", location, err)
234 }
235 ref := canonical.Messages[1].ContentRef
236 if ref == nil {
237 t.Fatal("cold canonical large message has no content ref")
238 }
239 chunk, err := app.SessionHistoryContentForTab(tab.ID, *ref, 0)
240 decoded, decodeErr := base64.StdEncoding.DecodeString(chunk.Data)
241 if err != nil || decodeErr != nil || !chunk.Done || !strings.Contains(string(decoded), large) {
242 t.Fatalf("cold canonical content = done:%v bytes:%d, errors:%v/%v", chunk.Done, len(decoded), err, decodeErr)
243 }
244
245 // Explicit target readers remain bound to the durable session even after
246 // the tab disappears.
247 app.tabs = map[string]*WorkspaceTab{}
248 app.tabOrder = nil
249 app.activeTabID = ""
250 selector := SessionSelector{Ref: &session.SessionRef{HostID: localDesktopHostID, SessionID: runtime.Ref().SessionID}}
251 compatTarget, err := app.HistorySliceForTarget(selector, HistorySliceRequest{Turns: 12})
252 if err != nil || len(compatTarget.Entries) != 2 || len(compatTarget.Entries[1].Refs) != 1 {
253 t.Fatalf("target compatibility history = %+v, %v", compatTarget, err)
254 }
255 compatChunk, err := app.HistoryContentForTarget(selector, compatTarget.Entries[1].Refs[0], 0)
256 if err != nil || compatChunk.Stale || !compatChunk.Done || compatChunk.Data != large {
257 t.Fatalf("target compatibility content = %+v, %v", compatChunk, err)
258 }
259 targetPage, err := app.SessionHistoryPageForTarget(selector, "", 12)
260 if err != nil || len(targetPage.Messages) != 2 {
261 t.Fatalf("target history page = %+v, %v", targetPage, err)
262 }
263 targetLocation, err := app.LocateSessionMessageForTarget(selector, "cold-history-assistant", targetPage.SnapshotSequence)
264 if err != nil || targetLocation.Status != "ready" || targetLocation.MessageID != "cold-history-assistant" {
265 t.Fatalf("target location = %+v, %v", targetLocation, err)
266 }
267 targetRef := targetPage.Messages[1].ContentRef
268 if targetRef == nil {
269 t.Fatal("target large message has no content ref")
270 }
271 targetChunk, err := app.SessionHistoryContentForTarget(selector, *targetRef, 0)
272 targetDecoded, decodeErr := base64.StdEncoding.DecodeString(targetChunk.Data)
273 if err != nil || decodeErr != nil || !targetChunk.Done || !strings.Contains(string(targetDecoded), large) {
274 t.Fatalf("target content = done:%v bytes:%d, errors:%v/%v", targetChunk.Done, len(targetDecoded), err, decodeErr)
275 }
276 }
277
278 func appendSessionTestMessage(t *testing.T, runtime *session.Runtime, operationID string, message provider.Message) {
279 t.Helper()
280 payload, err := json.Marshal(map[string]any{"message": message})
281 if err != nil {
282 t.Fatal(err)
283 }
284 if _, err := runtime.Session().AppendBatch(t.Context(), operationID, []session.Event{{Kind: "message/complete", Payload: payload}}); err != nil {
285 t.Fatal(err)
286 }
287 if _, err := runtime.Session().Flush(t.Context()); err != nil {
288 t.Fatal(err)
289 }
290 }
291
292 func appendSessionTestModel(t *testing.T, runtime *session.Runtime, operationID, modelRef string) {
293 t.Helper()
294 payload, err := json.Marshal(map[string]string{"modelRef": modelRef})
295 if err != nil {
296 t.Fatal(err)
297 }
298 if _, err := runtime.Session().AppendBatch(t.Context(), operationID, []session.Event{{Kind: "session/config", Payload: payload}}); err != nil {
299 t.Fatal(err)
300 }
301 }
302
303 func TestDesktopV3CatalogResumeRenameAndDeleteUseSessionIdentity(t *testing.T) {
304 isolateDesktopUserDirs(t)
305 model, targetModel := configureSwitchableDefaultModels(t)
306 app := NewApp()
307 t.Cleanup(app.closeSessionServices)
308 app.ctx = context.Background()
309 root := t.TempDir()
310 dir := desktopSessionDir(root)
311 service := app.desktopSessionService(dir)
312
313 first, err := service.Create(t.Context(), session.CreateOptions{
314 SessionID: "first-v3", CWD: root, Origin: session.SessionOriginNew,
315 })
316 if err != nil {
317 t.Fatal(err)
318 }
319 appendSessionTestModel(t, first, "first-model", model)
320 appendSessionTestMessage(t, first, "first-message", provider.Message{ID: "user-first", Role: provider.RoleUser, Content: "first conversation"})
321 second, err := service.Create(t.Context(), session.CreateOptions{
322 SessionID: "second-v3", CWD: root, Origin: session.SessionOriginNew,
323 })
324 if err != nil {
325 t.Fatal(err)
326 }
327 appendSessionTestModel(t, second, "second-model", targetModel)
328 appendSessionTestMessage(t, second, "second-message", provider.Message{ID: "user-second", Role: provider.RoleUser, Content: "second conversation"})
329 for _, ref := range []session.SessionRef{first.Ref(), second.Ref()} {
330 if _, err := app.attachDesktopSession(t.Context(), "project", root, ref); err != nil {
331 t.Fatal(err)
332 }
333 }
334
335 ctrl, err := app.buildTabControllerBoot(app.ctx, boot.Options{Model: model, WorkspaceRoot: root, SessionDir: dir, Sink: event.Discard})
336 if err != nil {
337 t.Fatal(err)
338 }
339 identity := ctrl.(control.IdentityLifecycle)
340 if _, err := identity.OpenSession(t.Context(), first.Ref()); err != nil {
341 t.Fatal(err)
342 }
343 tab := &WorkspaceTab{ID: "v3-tab", Scope: "project", WorkspaceRoot: root, SessionID: first.Ref().SessionID, Ready: true, Ctrl: ctrl, sink: &tabEventSink{tabID: "v3-tab", app: app}, disabledMCP: map[string]ServerView{}}
344 app.tabs[tab.ID] = tab
345 app.tabOrder = []string{tab.ID}
346 app.activeTabID = tab.ID
347 app.newSessionRuntimeLocked(tab, sessionRuntimeKey(tab.currentSessionIdentity()))
348 t.Cleanup(func() {
349 if live := app.controllerForTab(tab); live != nil {
350 live.Close()
351 }
352 })
353
354 rows := app.ListSessions()
355 if len(rows) != 2 || rows[0].SessionID == "" || rows[1].SessionID == "" {
356 t.Fatalf("v3 catalog rows = %+v", rows)
357 }
358 if _, err := app.ResumeSessionForTab(tab.ID, sessionRoute(second.Ref().SessionID)); err != nil {
359 t.Fatal(err)
360 }
361 if tab.SessionID != second.Ref().SessionID || tab.SessionPath != "" {
362 t.Fatalf("resumed identity = id %q path %q", tab.SessionID, tab.SessionPath)
363 }
364 if tab.Ctrl == ctrl || tab.Ctrl.ModelRef() != targetModel {
365 t.Fatalf("target model runtime = ctrl changed %v model %q, want true/%q", tab.Ctrl != ctrl, tab.Ctrl.ModelRef(), targetModel)
366 }
367 if got := tab.Ctrl.History(); len(got) != 1 || got[0].Content != "second conversation" {
368 t.Fatalf("resumed history = %+v", got)
369 }
370 if err := app.RenameSession(sessionRoute(second.Ref().SessionID), "renamed v3"); err != nil {
371 t.Fatal(err)
372 }
373 if snap, err := service.Query().Snapshot(t.Context(), second.Ref()); err != nil || snap.Projection.Title != "renamed v3" {
374 t.Fatalf("renamed snapshot = %+v, %v", snap, err)
375 }
376 if err := app.DeleteSession(sessionRoute(second.Ref().SessionID)); err != nil {
377 t.Fatal(err)
378 }
379 if !tab.removed {
380 t.Fatal("archived runtime remains attached")
381 }
382 state, err := app.workspaceRegistry().Load(t.Context())
383 if err != nil || state.SessionStates[second.Ref().SessionID].Lifecycle != "archived" {
384 t.Fatalf("archive state=%+v err=%v", state, err)
385 }
386 if _, err := service.Query().Snapshot(t.Context(), second.Ref()); err != nil {
387 t.Fatalf("archive lost canonical content: %v", err)
388 }
389 }
390
391 func TestDesktopSessionRefOpenFallsBackAndPublishesHydrationReady(t *testing.T) {
392 isolateDesktopUserDirs(t)
393 model, fallbackModel := configureSwitchableDefaultModels(t)
394 app := NewApp()
395 t.Cleanup(app.closeSessionServices)
396 app.ctx = context.Background()
397 root := t.TempDir()
398 dir := desktopSessionDir(root)
399 service := app.desktopSessionService(dir)
400
401 source, err := service.Create(t.Context(), session.CreateOptions{SessionID: "resume-source"})
402 if err != nil {
403 t.Fatal(err)
404 }
405 appendSessionTestModel(t, source, "source-model", model)
406 appendSessionTestMessage(t, source, "source-message", provider.Message{ID: "source-user", Role: provider.RoleUser, Content: "source remains"})
407 target, err := service.Create(t.Context(), session.CreateOptions{SessionID: "resume-broken-target", CWD: root, Origin: session.SessionOriginNew})
408 if err != nil {
409 t.Fatal(err)
410 }
411 appendSessionTestModel(t, target, "target-model", "missing/model")
412 appendSessionTestMessage(t, target, "target-message", provider.Message{ID: "target-user", Role: provider.RoleUser, Content: "target restored"})
413 if _, err := app.attachDesktopSession(t.Context(), "project", root, target.Ref()); err != nil {
414 t.Fatal(err)
415 }
416
417 ctrl, err := app.buildTabControllerBoot(app.ctx, boot.Options{Model: model, WorkspaceRoot: root, SessionDir: dir, Sink: event.Discard})
418 if err != nil {
419 t.Fatal(err)
420 }
421 identity := ctrl.(control.IdentityLifecycle)
422 if _, err := identity.OpenSession(t.Context(), source.Ref()); err != nil {
423 t.Fatal(err)
424 }
425 tab := &WorkspaceTab{ID: "source-tab", Scope: "project", WorkspaceRoot: root, SessionID: source.Ref().SessionID, Ready: true, Ctrl: ctrl, model: model, sink: &tabEventSink{tabID: "source-tab", app: app}, disabledMCP: map[string]ServerView{}}
426 app.tabs[tab.ID] = tab
427 app.tabOrder = []string{tab.ID}
428 app.activeTabID = tab.ID
429 app.newSessionRuntimeLocked(tab, sessionRuntimeKey(tab.currentSessionIdentity()))
430 t.Cleanup(func() {
431 if live := app.controllerForTab(tab); live != nil {
432 live.Close()
433 }
434 })
435
436 readySignals := 0
437 app.readyHook = func() { readySignals++ }
438 if _, err := app.OpenSession(target.Ref()); err != nil {
439 t.Fatal(err)
440 }
441 if readySignals != 1 {
442 t.Fatalf("SessionRef open ready signals = %d, want 1", readySignals)
443 }
444 if tab.Ctrl == ctrl || tab.SessionID != target.Ref().SessionID || tab.SessionPath != "" || tab.Ctrl.ModelRef() != fallbackModel {
445 t.Fatalf("fallback binding: replaced=%v session=%q path=%q model=%q", tab.Ctrl != ctrl, tab.SessionID, tab.SessionPath, tab.Ctrl.ModelRef())
446 }
447 if got := tab.Ctrl.History(); len(got) != 1 || got[0].Content != "target restored" {
448 t.Fatalf("fallback history = %+v", got)
449 }
450 if snapshot, err := service.Query().Snapshot(t.Context(), target.Ref()); err != nil || snapshot.Projection.ModelRef != fallbackModel {
451 t.Fatalf("fallback config was not persisted on target: %+v, %v", snapshot.Projection, err)
452 }
453 }
454
454 lines GO