返回 DeepSeek-Reasonix
saved_tab_reconcile_test.go
根目录 / desktop / saved_tab_reconcile_test.go
1 package main
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "log/slog"
9 "os"
10 "path/filepath"
11 "strings"
12 "testing"
13
14 "reasonix/desktop/internal/draftstate"
15 "reasonix/desktop/internal/legacycleanup"
16 "reasonix/desktop/internal/workspacestate"
17 "reasonix/internal/agent"
18 "reasonix/internal/config"
19 "reasonix/internal/control"
20 "reasonix/internal/event"
21 "reasonix/internal/session"
22 )
23
24 func newSavedTabReconcileTestApp(t *testing.T) *App {
25 t.Helper()
26 isolateDesktopUserDirs(t)
27 app := NewApp()
28 app.ctx = t.Context()
29 pinDesktopSessionRoot(t, app)
30 installNoopRuntimeEvents(app)
31 app.legacyCleanup = legacycleanup.New(filepath.Join(t.TempDir(), "legacy-empty-session-cleanup-v1.json"))
32 t.Cleanup(func() { _ = app.draftStore().Close() })
33 return app
34 }
35
36 func finishSavedTabMigration(app *App) {
37 close(app.desktopMigrationDone)
38 }
39
40 func savedProjectTab(id, sessionID, operationID, root, workspaceID string) desktopTabEntry {
41 return desktopTabEntry{
42 ID: id, Scope: "project", WorkspaceRoot: root, WorkspaceID: workspaceID,
43 TopicID: "topic-" + id, SessionID: sessionID, CreateOperationID: operationID,
44 }
45 }
46
47 func TestReconcileSavedTabsWaitsForMigrationCreatedCanonicalSession(t *testing.T) {
48 app := newSavedTabReconcileTestApp(t)
49 root := t.TempDir()
50 workspaceID := desktopWorkspaceID("project", root)
51 file := desktopTabsFile{Tabs: []desktopTabEntry{savedProjectTab("saved", "migrated-session", "", root, workspaceID)}, ActiveTab: "saved"}
52
53 type result struct {
54 file desktopTabsFile
55 changed bool
56 }
57 finished := make(chan result, 1)
58 reachedMigration := make(chan struct{})
59 app.beforeSavedTabMigrationWait = func() { close(reachedMigration) }
60 go func() {
61 got, changed := app.reconcileSavedTabs(t.Context(), file)
62 finished <- result{got, changed}
63 }()
64 <-reachedMigration
65 select {
66 case <-finished:
67 t.Fatal("saved tab reconciliation passed a missing canonical identity before migration completed")
68 default:
69 }
70
71 ref, gotWorkspaceID := createLegacyCleanupSession(t, app, root, "migrated-session", true)
72 if ref.SessionID != "migrated-session" || gotWorkspaceID != workspaceID {
73 t.Fatalf("migration fixture identity = %q/%q", ref.SessionID, gotWorkspaceID)
74 }
75 finishSavedTabMigration(app)
76 got := <-finished
77 if got.changed || len(got.file.Tabs) != 1 || got.file.Tabs[0].SessionID != ref.SessionID {
78 t.Fatalf("migration-created session was not restored: changed=%v file=%+v", got.changed, got.file)
79 }
80 }
81
82 func TestReconcileSavedTabsDropsMissingCanonicalWithoutOwner(t *testing.T) {
83 app := newSavedTabReconcileTestApp(t)
84 finishSavedTabMigration(app)
85 root := t.TempDir()
86 file := desktopTabsFile{
87 Tabs: []desktopTabEntry{savedProjectTab("stale", "missing-session", "", root, desktopWorkspaceID("project", root))},
88 ActiveTab: "stale", TabOrder: []string{"stale"},
89 }
90
91 got, changed := app.reconcileSavedTabs(t.Context(), file)
92 if !changed || len(got.Tabs) != 0 || got.ActiveTab != "" || len(got.TabOrder) != 0 {
93 t.Fatalf("stale tab reconciliation = changed:%v file:%+v", changed, got)
94 }
95 }
96
97 func TestReconcileSavedTabsSelectsValidFallbackAfterDroppingActive(t *testing.T) {
98 app := newSavedTabReconcileTestApp(t)
99 finishSavedTabMigration(app)
100 root := t.TempDir()
101 file := desktopTabsFile{
102 Tabs: []desktopTabEntry{savedProjectTab("stale", "missing-session", "", root, desktopWorkspaceID("project", root))},
103 RemoteTabs: []desktopRemoteTabEntry{{ID: "remote", HostID: "host", Workspace: "/workspace"}},
104 ActiveTab: "stale", RemoteTabOrder: []string{"remote"}, TabOrder: []string{"stale", "remote"},
105 }
106
107 got, changed := app.reconcileSavedTabs(t.Context(), file)
108 if !changed || len(got.Tabs) != 0 || len(got.RemoteTabs) != 1 || got.ActiveTab != "remote" {
109 t.Fatalf("fallback selection = changed:%v file:%+v", changed, got)
110 }
111 single := singleSurfaceTabsFile(got)
112 if len(single.RemoteTabs) != 1 || single.RemoteTabs[0].ID != "remote" {
113 t.Fatalf("single-surface fallback discarded valid remote tab: %+v", single)
114 }
115 }
116
117 func TestReconcileSavedTabsDropsMissingLegacyPathWithoutOwner(t *testing.T) {
118 app := newSavedTabReconcileTestApp(t)
119 finishSavedTabMigration(app)
120 missing := filepath.Join(t.TempDir(), "missing-session.jsonl")
121 file := desktopTabsFile{Tabs: []desktopTabEntry{{ID: "legacy", Scope: "global", TopicID: "topic", SessionPath: missing}}, ActiveTab: "legacy"}
122
123 got, changed := app.reconcileSavedTabs(t.Context(), file)
124 if !changed || len(got.Tabs) != 0 {
125 t.Fatalf("missing legacy tab reconciliation = changed:%v file:%+v", changed, got)
126 }
127 }
128
129 func TestReconcileSavedTabsDiagnosticDoesNotExposeIdentityOrPath(t *testing.T) {
130 app := newSavedTabReconcileTestApp(t)
131 finishSavedTabMigration(app)
132 secretPath := filepath.Join(t.TempDir(), "private-customer-path", "missing.jsonl")
133 const secretSessionID = "private-session-identity"
134 file := desktopTabsFile{Tabs: []desktopTabEntry{{
135 ID: "private-tab", Scope: "global", TopicID: "topic", SessionID: secretSessionID, SessionPath: secretPath,
136 }}, ActiveTab: "private-tab"}
137 var output bytes.Buffer
138 previous := slog.Default()
139 slog.SetDefault(slog.New(slog.NewTextHandler(&output, nil)))
140 t.Cleanup(func() { slog.SetDefault(previous) })
141
142 if _, changed := app.reconcileSavedTabs(t.Context(), file); !changed {
143 t.Fatal("missing saved tab was not reconciled")
144 }
145 logBody := output.String()
146 if strings.Contains(logBody, secretPath) || strings.Contains(logBody, secretSessionID) || strings.Contains(logBody, "private-tab") {
147 t.Fatalf("saved-tab diagnostic exposed private identity: %s", logBody)
148 }
149 for _, field := range []string{"outcome=drop_stale_presentation", "reason=canonical_identity_absent", "identity_kind=canonical", "waited_for_migration=true"} {
150 if !strings.Contains(logBody, field) {
151 t.Fatalf("saved-tab diagnostic missing %q: %s", field, logBody)
152 }
153 }
154 }
155
156 func TestReconcileSavedTabsPreservesRecoveryEntry(t *testing.T) {
157 app := newSavedTabReconcileTestApp(t)
158 root := t.TempDir()
159 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
160 if err != nil {
161 t.Fatal(err)
162 }
163 if err := app.workspaceRegistry().RecordRecovery(t.Context(), workspacestate.RecoveryEntry{
164 ID: "recovery", SourceKey: "recovery-source", SessionID: "missing-with-recovery", WorkspaceID: workspaceID,
165 Scope: "project", WorkspaceRoot: root, Format: "canonical", Reason: "interrupted", Status: "pending",
166 }); err != nil {
167 t.Fatal(err)
168 }
169 finishSavedTabMigration(app)
170 file := desktopTabsFile{Tabs: []desktopTabEntry{savedProjectTab("recover", "missing-with-recovery", "", root, workspaceID)}, ActiveTab: "recover"}
171
172 got, changed := app.reconcileSavedTabs(t.Context(), file)
173 if changed || len(got.Tabs) != 1 {
174 t.Fatalf("recovery-owned tab was discarded: changed=%v file=%+v", changed, got)
175 }
176 }
177
178 func TestReconcileSavedTabsRetainsMatchingPendingCreateWithoutWaiting(t *testing.T) {
179 app := newSavedTabReconcileTestApp(t)
180 root := t.TempDir()
181 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
182 if err != nil {
183 t.Fatal(err)
184 }
185 const sessionID = "pending-session"
186 const operationID = "pending-operation"
187 if err := app.workspaceRegistry().BeginCreate(t.Context(), workspacestate.PendingCreate{
188 OperationID: operationID, WorkspaceID: workspaceID, SessionID: sessionID,
189 }); err != nil {
190 t.Fatal(err)
191 }
192 file := desktopTabsFile{Tabs: []desktopTabEntry{savedProjectTab("pending", sessionID, operationID, root, workspaceID)}, ActiveTab: "pending"}
193
194 got, changed := app.reconcileSavedTabs(t.Context(), file)
195 if changed || len(got.Tabs) != 1 || got.Tabs[0].CreateOperationID != operationID {
196 t.Fatalf("pending create was not retained: changed=%v file=%+v", changed, got)
197 }
198 }
199
200 func TestReconcileSavedTabsRestoresSessionIdentityFromPendingCreate(t *testing.T) {
201 app := newSavedTabReconcileTestApp(t)
202 root := t.TempDir()
203 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
204 if err != nil {
205 t.Fatal(err)
206 }
207 const sessionID = "pending-session"
208 const operationID = "pending-operation"
209 if err := app.workspaceRegistry().BeginCreate(t.Context(), workspacestate.PendingCreate{
210 OperationID: operationID, WorkspaceID: workspaceID, SessionID: sessionID,
211 }); err != nil {
212 t.Fatal(err)
213 }
214 file := desktopTabsFile{Tabs: []desktopTabEntry{{
215 ID: "pending", Scope: "project", WorkspaceRoot: root, WorkspaceID: workspaceID, CreateOperationID: operationID,
216 }}, ActiveTab: "pending"}
217
218 got, changed := app.reconcileSavedTabs(t.Context(), file)
219 if !changed || len(got.Tabs) != 1 || got.Tabs[0].SessionID != sessionID {
220 t.Fatalf("pending identity was not restored: changed=%v file=%+v", changed, got)
221 }
222 }
223
224 func TestReconcileSavedTabsDropsArchivedPresentationButKeepsSession(t *testing.T) {
225 app := newSavedTabReconcileTestApp(t)
226 root := t.TempDir()
227 ref, workspaceID := createLegacyCleanupSession(t, app, root, "archived-session", true)
228 if err := app.archiveSessionRefsWithOperation([]session.SessionRef{ref}, "archive-test"); err != nil {
229 t.Fatal(err)
230 }
231 file := desktopTabsFile{Tabs: []desktopTabEntry{savedProjectTab("archived", ref.SessionID, "", root, workspaceID)}, ActiveTab: "archived"}
232
233 got, changed := app.reconcileSavedTabs(t.Context(), file)
234 if !changed || len(got.Tabs) != 0 {
235 t.Fatalf("archived presentation retained: changed=%v file=%+v", changed, got)
236 }
237 state, err := app.workspaceRegistry().Load(t.Context())
238 if err != nil {
239 t.Fatal(err)
240 }
241 if state.SessionStates[ref.SessionID].Lifecycle != workspacestate.Archived {
242 t.Fatalf("archived session lifecycle = %q", state.SessionStates[ref.SessionID].Lifecycle)
243 }
244 if _, err := app.desktopSessionService("").Query().Stat(t.Context(), ref); err != nil {
245 t.Fatalf("archived session content was removed: %v", err)
246 }
247 }
248
249 func TestReconcileSavedTabsPreservesUnregisteredCanonicalContent(t *testing.T) {
250 app := newSavedTabReconcileTestApp(t)
251 root := t.TempDir()
252 runtime, err := app.desktopSessionService("").Create(t.Context(), session.CreateOptions{
253 SessionID: "unregistered-content", CWD: root, Origin: session.SessionOriginNew,
254 })
255 if err != nil {
256 t.Fatal(err)
257 }
258 if _, err := runtime.Session().Flush(t.Context()); err != nil {
259 t.Fatal(err)
260 }
261 if err := app.desktopSessionService("").Close(t.Context(), runtime.Ref()); err != nil {
262 t.Fatal(err)
263 }
264 finishSavedTabMigration(app)
265 file := desktopTabsFile{Tabs: []desktopTabEntry{savedProjectTab("recover", runtime.Ref().SessionID, "", root, desktopWorkspaceID("project", root))}, ActiveTab: "recover"}
266
267 got, changed := app.reconcileSavedTabs(t.Context(), file)
268 if changed || len(got.Tabs) != 1 {
269 t.Fatalf("unregistered canonical content was discarded: changed=%v file=%+v", changed, got)
270 }
271 }
272
273 func TestReconcileSavedTabsPreservesEmptyWorkspaceConflictWithoutRetiredDraftReads(t *testing.T) {
274 app := newSavedTabReconcileTestApp(t)
275 canonicalRoot := t.TempDir()
276 ref, workspaceID := createLegacyCleanupSession(t, app, canonicalRoot, "empty-workspace-conflict", false)
277 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
278 t.Fatal(err)
279 }
280 state, err := app.workspaceRegistry().Load(t.Context())
281 if err != nil {
282 t.Fatal(err)
283 }
284 workspace := state.Workspaces[workspaceID]
285 workspace.Root = t.TempDir()
286 state.Workspaces[workspaceID] = workspace
287 body, err := json.Marshal(state)
288 if err != nil {
289 t.Fatal(err)
290 }
291 if err := os.WriteFile(app.workspaceRegistry().Path(), body, 0o600); err != nil {
292 t.Fatal(err)
293 }
294 finishSavedTabMigration(app)
295 file := desktopTabsFile{
296 Tabs: []desktopTabEntry{savedProjectTab("empty", ref.SessionID, "", canonicalRoot, workspaceID)},
297 RemoteTabs: []desktopRemoteTabEntry{{ID: "remote", HostID: "host", Workspace: "/workspace"}},
298 ActiveTab: "remote", TabOrder: []string{"remote", "empty"},
299 }
300 body = mustMarshalJSON(t, file)
301 if err := os.MkdirAll(desktopConfigDir(), 0o700); err != nil {
302 t.Fatal(err)
303 }
304 if err := os.WriteFile(filepath.Join(desktopConfigDir(), tabsFileName), body, 0o600); err != nil {
305 t.Fatal(err)
306 }
307
308 got, changed := app.reconcileSavedTabs(t.Context(), file)
309 if changed || len(got.Tabs) != 1 || len(got.RemoteTabs) != 1 || !got.Tabs[0].restoreBlocked {
310 t.Fatalf("formal workspace conflict was not retained for repair: changed=%v file=%+v", changed, got)
311 }
312 if persisted := loadTabsFile(); len(persisted.RemoteTabs) != 1 {
313 t.Fatalf("preflight archive overwrote unpublished tabs: %+v", persisted)
314 }
315 state, err = app.workspaceRegistry().Load(t.Context())
316 if err != nil {
317 t.Fatal(err)
318 }
319 if state.SessionStates[ref.SessionID].Lifecycle != workspacestate.Active {
320 t.Fatalf("empty formal session lifecycle = %q, want active", state.SessionStates[ref.SessionID].Lifecycle)
321 }
322 if _, err := os.Stat(app.draftStore().Path()); !os.IsNotExist(err) {
323 t.Fatalf("workspace conflict opened the retired draft database: %v", err)
324 }
325 if len(app.tabs) != 0 || len(app.runtimeByID) != 0 {
326 t.Fatal("empty-session reconciliation created a replacement runtime")
327 }
328 }
329
330 func TestReconcileSavedTabsPreservesNonEmptyWorkspaceConflict(t *testing.T) {
331 app := newSavedTabReconcileTestApp(t)
332 canonicalRoot := t.TempDir()
333 ref, workspaceID := createLegacyCleanupSession(t, app, canonicalRoot, "used-workspace-conflict", true)
334 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
335 t.Fatal(err)
336 }
337 state, err := app.workspaceRegistry().Load(t.Context())
338 if err != nil {
339 t.Fatal(err)
340 }
341 workspace := state.Workspaces[workspaceID]
342 workspace.Root = t.TempDir()
343 state.Workspaces[workspaceID] = workspace
344 body, err := json.Marshal(state)
345 if err != nil {
346 t.Fatal(err)
347 }
348 if err := os.WriteFile(app.workspaceRegistry().Path(), body, 0o600); err != nil {
349 t.Fatal(err)
350 }
351 finishSavedTabMigration(app)
352 file := desktopTabsFile{Tabs: []desktopTabEntry{savedProjectTab("used", ref.SessionID, "", canonicalRoot, workspaceID)}, ActiveTab: "used"}
353
354 got, changed := app.reconcileSavedTabs(t.Context(), file)
355 if changed || len(got.Tabs) != 1 {
356 t.Fatalf("non-empty stale-workspace session was discarded: changed=%v file=%+v", changed, got)
357 }
358 state, err = app.workspaceRegistry().Load(t.Context())
359 if err != nil {
360 t.Fatal(err)
361 }
362 if state.SessionStates[ref.SessionID].Lifecycle != workspacestate.Active {
363 t.Fatalf("non-empty session lifecycle = %q, want active", state.SessionStates[ref.SessionID].Lifecycle)
364 }
365 }
366
367 func TestRestoreDropsStaleSavedTabWithoutReplacementRuntime(t *testing.T) {
368 app := newSavedTabReconcileTestApp(t)
369 finishSavedTabMigration(app)
370 root := t.TempDir()
371 file := desktopTabsFile{
372 Tabs: []desktopTabEntry{savedProjectTab("stale", "gone", "", root, desktopWorkspaceID("project", root))},
373 ActiveTab: "stale", TabOrder: []string{"stale"},
374 }
375 body, err := json.Marshal(file)
376 if err != nil {
377 t.Fatal(err)
378 }
379 if err := os.MkdirAll(desktopConfigDir(), 0o755); err != nil {
380 t.Fatal(err)
381 }
382 if err := os.WriteFile(filepath.Join(desktopConfigDir(), tabsFileName), body, 0o600); err != nil {
383 t.Fatal(err)
384 }
385 app.tabsRestored = make(chan struct{})
386
387 app.restoreOrBuildTabs()
388 if len(app.tabs) != 0 || len(app.runtimeByID) != 0 || len(app.runtimeBySessionKey) != 0 {
389 t.Fatalf("stale restore created runtime state: tabs=%d runtimes=%d/%d", len(app.tabs), len(app.runtimeByID), len(app.runtimeBySessionKey))
390 }
391 persisted := loadTabsFile()
392 if len(persisted.Tabs) != 0 || persisted.ActiveTab != "" {
393 t.Fatalf("stale presentation remained persisted: %+v", persisted)
394 }
395 select {
396 case <-app.tabsRestored:
397 default:
398 t.Fatal("restore completion gate remained open")
399 }
400
401 second := NewApp()
402 second.ctx = t.Context()
403 pinDesktopSessionRoot(t, second)
404 t.Cleanup(func() { _ = second.draftStore().Close() })
405 finishSavedTabMigration(second)
406 second.tabsRestored = make(chan struct{})
407 second.restoreOrBuildTabs()
408 if len(second.tabs) != 0 || len(second.runtimeByID) != 0 || len(loadTabsFile().Tabs) != 0 {
409 t.Fatal("saved-tab repair was not idempotent across restart")
410 }
411 }
412
413 func TestDesktopTabsUnknownFieldsSurviveRoundTrip(t *testing.T) {
414 var file desktopTabsFile
415 raw := []byte(`{
416 "tabs": [{"id":"local","scope":"global","workspaceRoot":"","topicId":"topic","futureLocal":{"enabled":true}}],
417 "activeTab":"local",
418 "remoteTabs":[{"id":"remote","hostId":"host","workspace":"/work","futureRemote":7}],
419 "futureTop":{"version":4}
420 }`)
421 if err := json.Unmarshal(raw, &file); err != nil {
422 t.Fatal(err)
423 }
424 body, err := json.Marshal(file)
425 if err != nil {
426 t.Fatal(err)
427 }
428 var fields map[string]json.RawMessage
429 if err := json.Unmarshal(body, &fields); err != nil {
430 t.Fatal(err)
431 }
432 if string(fields["futureTop"]) != `{"version":4}` {
433 t.Fatalf("top-level unknown field lost: %s", body)
434 }
435 var localFields, remoteFields map[string]json.RawMessage
436 if err := json.Unmarshal(mustMarshalJSON(t, file.Tabs[0]), &localFields); err != nil {
437 t.Fatal(err)
438 }
439 if err := json.Unmarshal(mustMarshalJSON(t, file.RemoteTabs[0]), &remoteFields); err != nil {
440 t.Fatal(err)
441 }
442 if string(localFields["futureLocal"]) != `{"enabled":true}` || string(remoteFields["futureRemote"]) != "7" {
443 t.Fatalf("entry unknown fields lost: local=%s remote=%s", mustMarshalJSON(t, file.Tabs[0]), mustMarshalJSON(t, file.RemoteTabs[0]))
444 }
445 }
446
447 func mustMarshalJSON(t *testing.T, value any) []byte {
448 t.Helper()
449 body, err := json.Marshal(value)
450 if err != nil {
451 t.Fatal(err)
452 }
453 return body
454 }
455
456 func TestReconcileSavedTabsDropsRetiredDraftOperation(t *testing.T) {
457 app := newSavedTabReconcileTestApp(t)
458 finishSavedTabMigration(app)
459 root := t.TempDir()
460 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
461 if err != nil {
462 t.Fatal(err)
463 }
464 draft, _, err := app.draftStore().Open(t.Context(), workspaceID, "project", root, "draft-reconcile", `{}`)
465 if err != nil {
466 t.Fatal(err)
467 }
468 op, _, err := app.draftStore().BeginOperation(context.Background(), draftstate.Operation{
469 ID: "draft-op-retired", DraftID: draft.ID, WorkspaceID: workspaceID, DraftRevision: draft.Revision,
470 SessionID: "draft-session", SubmissionID: "submission", Fingerprint: "fingerprint", RequestJSON: `{}`,
471 })
472 if err != nil {
473 t.Fatal(err)
474 }
475 file := desktopTabsFile{Tabs: []desktopTabEntry{savedProjectTab("draft", op.SessionID, op.ID, root, workspaceID)}, ActiveTab: "draft"}
476 got, changed := app.reconcileSavedTabs(t.Context(), file)
477 if !changed || len(got.Tabs) != 0 {
478 t.Fatalf("retired draft operation was restored: changed=%v file=%+v", changed, got)
479 }
480 }
481
482 func TestReconcileSavedTabsPreservesResidualCanonicalArtifacts(t *testing.T) {
483 for _, fixture := range []struct {
484 name string
485 legacyPath bool
486 }{
487 {name: "partial canonical directory"},
488 {name: "legacy path on canonical tab", legacyPath: true},
489 } {
490 t.Run(fixture.name, func(t *testing.T) {
491 app := newSavedTabReconcileTestApp(t)
492 finishSavedTabMigration(app)
493 entry := desktopTabEntry{ID: "saved", Scope: "global", SessionID: "missing-manifest"}
494 artifact := filepath.Join(app.desktopSessions.root, entry.SessionID, "events.jsonl")
495 if fixture.legacyPath {
496 artifact = filepath.Join(t.TempDir(), "history.jsonl")
497 entry.SessionPath = artifact
498 }
499 if err := os.MkdirAll(filepath.Dir(artifact), 0o700); err != nil {
500 t.Fatal(err)
501 }
502 if err := os.WriteFile(artifact, []byte("{\"role\":\"user\",\"content\":\"history\"}\n"), 0o600); err != nil {
503 t.Fatal(err)
504 }
505
506 got, changed := app.reconcileSavedTabs(t.Context(), desktopTabsFile{Tabs: []desktopTabEntry{entry}})
507 if changed || len(got.Tabs) != 1 {
508 t.Fatalf("durable artifacts were hidden: changed=%v file=%+v", changed, got)
509 }
510 })
511 }
512 }
513
514 func TestPersistReconciledTabsRejectsStaleStartupSnapshot(t *testing.T) {
515 app := newSavedTabReconcileTestApp(t)
516 expectedVersion := app.tabsSnapshotVersion()
517 app.mu.Lock()
518 app.tabs["new"] = &WorkspaceTab{ID: "new", Scope: "global", SessionID: "new-session"}
519 app.tabOrder = []string{"new"}
520 app.activeTabID = "new"
521 dir, entries, activeID, version := app.saveTabsCollectLocked()
522 app.mu.Unlock()
523 app.saveTabsWrite(dir, entries, activeID, version)
524
525 stale := desktopTabsFile{Tabs: []desktopTabEntry{{ID: "old", Scope: "global", SessionID: "old-session"}}, ActiveTab: "old"}
526 if _, err := app.persistReconciledTabsFile(stale, expectedVersion); !errors.Is(err, errTabsSnapshotChanged) {
527 t.Fatalf("stale startup write error = %v, want %v", err, errTabsSnapshotChanged)
528 }
529 if got := loadTabsFile(); got.ActiveTab != "new" {
530 t.Fatalf("stale startup snapshot overwrote newer save: %+v", got)
531 }
532 }
533
534 func TestReconcileSavedTabsPreservesRecoveryOwnedEmptyConflict(t *testing.T) {
535 app := newSavedTabReconcileTestApp(t)
536 canonicalRoot := t.TempDir()
537 ref, workspaceID := createLegacyCleanupSession(t, app, canonicalRoot, "recovery-owned-conflict", false)
538 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
539 t.Fatal(err)
540 }
541 if err := app.workspaceRegistry().RecordRecovery(t.Context(), workspacestate.RecoveryEntry{
542 ID: "recovery", SourceKey: "source", SessionID: ref.SessionID, WorkspaceID: workspaceID,
543 Scope: "project", WorkspaceRoot: canonicalRoot, Format: "canonical", Reason: "interrupted", Status: "pending",
544 }); err != nil {
545 t.Fatal(err)
546 }
547 state, err := app.workspaceRegistry().Load(t.Context())
548 if err != nil {
549 t.Fatal(err)
550 }
551 workspace := state.Workspaces[workspaceID]
552 workspace.Root = t.TempDir()
553 state.Workspaces[workspaceID] = workspace
554 if err := os.WriteFile(app.workspaceRegistry().Path(), mustMarshalJSON(t, state), 0o600); err != nil {
555 t.Fatal(err)
556 }
557 finishSavedTabMigration(app)
558 file := desktopTabsFile{Tabs: []desktopTabEntry{savedProjectTab("recover", ref.SessionID, "", canonicalRoot, workspaceID)}}
559
560 got, changed := app.reconcileSavedTabs(t.Context(), file)
561 if changed || len(got.Tabs) != 1 {
562 t.Fatalf("recovery-owned conflict was removed: changed=%v file=%+v", changed, got)
563 }
564 state, err = app.workspaceRegistry().Load(t.Context())
565 if err != nil || state.SessionStates[ref.SessionID].Lifecycle != workspacestate.Active {
566 t.Fatalf("recovery-owned lifecycle = %q, err=%v", state.SessionStates[ref.SessionID].Lifecycle, err)
567 }
568 }
569
570 func TestReconcileSavedTabsPreservesChangedSourceWithInactiveMapping(t *testing.T) {
571 app := newSavedTabReconcileTestApp(t)
572 path := filepath.Join(t.TempDir(), "history.jsonl")
573 if err := os.WriteFile(path, []byte("{\"role\":\"user\",\"content\":\"new history\"}\n"), 0o600); err != nil {
574 t.Fatal(err)
575 }
576 evidence := savedTabReconcileEvidence{registry: workspacestate.State{
577 SourceMappings: map[string]workspacestate.SourceMapping{"old": {Path: path, SessionID: "archived", Fingerprint: "old-fingerprint"}},
578 SessionStates: map[string]workspacestate.SessionState{"archived": {Lifecycle: workspacestate.Archived}},
579 }}
580 decision := app.classifyLegacySavedTab(desktopTabEntry{ID: "saved", Scope: "global", SessionPath: path}, evidence)
581 if decision.outcome != preserveRecovery {
582 t.Fatalf("changed legacy source decision = %+v, want preserve recovery", decision)
583 }
584 }
585
586 func TestBindLegacyRecoveryOwnerDoesNotCreateReplacement(t *testing.T) {
587 app := newSavedTabReconcileTestApp(t)
588 path := filepath.Join(t.TempDir(), "missing.jsonl")
589 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "global", "")
590 if err != nil {
591 t.Fatal(err)
592 }
593 if err := app.workspaceRegistry().RecordRecovery(t.Context(), workspacestate.RecoveryEntry{
594 ID: "recovery", SourceKey: "source", Path: path, WorkspaceID: workspaceID,
595 Scope: "global", Format: "legacy", Reason: "interrupted", Status: "pending",
596 }); err != nil {
597 t.Fatal(err)
598 }
599 ctrl := control.New(control.Options{
600 Executor: agent.New(nil, nil, agent.NewSession("test"), agent.Options{}, event.Discard),
601 SessionService: app.desktopSessionService(""), ExclusiveSession: true,
602 })
603 t.Cleanup(ctrl.Close)
604 ref, _, err := app.bindTabCanonicalSession(t.Context(), ctrl, &config.Config{}, "global", "", "", path, "", false)
605 if !errors.Is(err, errLegacySourceRecoveryPending) || ref.SessionID != "" {
606 t.Fatalf("legacy recovery fallback = ref:%+v err:%v", ref, err)
607 }
608 }
609
610 func TestReconcileSavedTabsPreservesWhenMigrationFailed(t *testing.T) {
611 app := newSavedTabReconcileTestApp(t)
612 app.desktopMigrationFailed.Store(true)
613 finishSavedTabMigration(app)
614 file := desktopTabsFile{Tabs: []desktopTabEntry{{ID: "legacy", Scope: "global", SessionPath: filepath.Join(t.TempDir(), "missing.jsonl")}}}
615 got, changed := app.reconcileSavedTabs(t.Context(), file)
616 if changed || len(got.Tabs) != 1 {
617 t.Fatalf("failed migration discarded saved state: changed=%v file=%+v", changed, got)
618 }
619 }
620
621 // A 1.38 desktop saves the chat it opened at launch as the active tab; when
622 // that chat was never sent it has sidecars but no conversation, and the
623 // upgrade restores a fresh chat instead of a startup error.
624 func TestReconcileSavedTabsDropsNeverWrittenLegacySession(t *testing.T) {
625 for _, tc := range []struct {
626 name string
627 extra string
628 want savedTabReconcileOutcome
629 }{
630 {name: "never sent", want: dropStalePresentation},
631 {name: "transcript", extra: ".jsonl", want: preserveRecovery},
632 {name: "event log", extra: ".events.jsonl", want: preserveRecovery},
633 {name: "turn log", extra: ".turns.jsonl", want: preserveRecovery},
634 } {
635 t.Run(tc.name, func(t *testing.T) {
636 app := newSavedTabReconcileTestApp(t)
637 stem := filepath.Join(t.TempDir(), "20260926-091504.474467700-fake-model")
638 path := stem + ".jsonl"
639 if err := os.MkdirAll(stem+".inbox", 0o700); err != nil {
640 t.Fatal(err)
641 }
642 for name, body := range map[string]string{
643 path + ".meta": `{"id":"20260926-091504.474467700-fake-model","scope":"global"}`,
644 stem + ".goal-state.json": `{"status":"stopped","researchMode":2,"deliveryCheckpoint":{}}`,
645 filepath.Join(stem+".inbox", "transaction.lock"): "",
646 } {
647 if err := os.WriteFile(name, []byte(body), 0o600); err != nil {
648 t.Fatal(err)
649 }
650 }
651 if tc.extra != "" {
652 if err := os.WriteFile(stem+tc.extra, []byte("{}\n"), 0o600); err != nil {
653 t.Fatal(err)
654 }
655 }
656 entry := desktopTabEntry{ID: "saved", Scope: "global", SessionPath: path}
657 if got := app.classifyLegacySavedTab(entry, savedTabReconcileEvidence{}); got.outcome != tc.want {
658 t.Fatalf("decision = %+v, want %s", got, tc.want)
659 }
660 // Migration may already have mapped the source to a session it archived.
661 archived := savedTabReconcileEvidence{registry: workspacestate.State{
662 SourceMappings: map[string]workspacestate.SourceMapping{"src": {Path: path, SessionID: "archived"}},
663 SessionStates: map[string]workspacestate.SessionState{"archived": {Lifecycle: workspacestate.Archived}},
664 }}
665 if got := app.classifyLegacySavedTab(entry, archived); got.outcome != tc.want {
666 t.Fatalf("archived mapping decision = %+v, want %s", got, tc.want)
667 }
668 if _, err := os.Stat(path + ".meta"); err != nil {
669 t.Fatalf("classification touched the session files: %v", err)
670 }
671 })
672 }
673 }
674
674 lines GO