返回 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 TestReconcileSavedTabsArchivesProvablyEmptyWorkspaceConflict(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) != 0 || len(got.RemoteTabs) != 1 {
310 t.Fatalf("provably empty stale tab was retained: 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.Archived {
320 t.Fatalf("empty session lifecycle = %q, want archived", state.SessionStates[ref.SessionID].Lifecycle)
321 }
322 if len(app.tabs) != 0 || len(app.runtimeByID) != 0 {
323 t.Fatal("empty-session reconciliation created a replacement runtime")
324 }
325 }
326
327 func TestReconcileSavedTabsPreservesNonEmptyWorkspaceConflict(t *testing.T) {
328 app := newSavedTabReconcileTestApp(t)
329 canonicalRoot := t.TempDir()
330 ref, workspaceID := createLegacyCleanupSession(t, app, canonicalRoot, "used-workspace-conflict", true)
331 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
332 t.Fatal(err)
333 }
334 state, err := app.workspaceRegistry().Load(t.Context())
335 if err != nil {
336 t.Fatal(err)
337 }
338 workspace := state.Workspaces[workspaceID]
339 workspace.Root = t.TempDir()
340 state.Workspaces[workspaceID] = workspace
341 body, err := json.Marshal(state)
342 if err != nil {
343 t.Fatal(err)
344 }
345 if err := os.WriteFile(app.workspaceRegistry().Path(), body, 0o600); err != nil {
346 t.Fatal(err)
347 }
348 finishSavedTabMigration(app)
349 file := desktopTabsFile{Tabs: []desktopTabEntry{savedProjectTab("used", ref.SessionID, "", canonicalRoot, workspaceID)}, ActiveTab: "used"}
350
351 got, changed := app.reconcileSavedTabs(t.Context(), file)
352 if changed || len(got.Tabs) != 1 {
353 t.Fatalf("non-empty stale-workspace session was discarded: changed=%v file=%+v", changed, got)
354 }
355 state, err = app.workspaceRegistry().Load(t.Context())
356 if err != nil {
357 t.Fatal(err)
358 }
359 if state.SessionStates[ref.SessionID].Lifecycle != workspacestate.Active {
360 t.Fatalf("non-empty session lifecycle = %q, want active", state.SessionStates[ref.SessionID].Lifecycle)
361 }
362 }
363
364 func TestRestoreDropsStaleSavedTabWithoutReplacementRuntime(t *testing.T) {
365 app := newSavedTabReconcileTestApp(t)
366 finishSavedTabMigration(app)
367 root := t.TempDir()
368 file := desktopTabsFile{
369 Tabs: []desktopTabEntry{savedProjectTab("stale", "gone", "", root, desktopWorkspaceID("project", root))},
370 ActiveTab: "stale", TabOrder: []string{"stale"},
371 }
372 body, err := json.Marshal(file)
373 if err != nil {
374 t.Fatal(err)
375 }
376 if err := os.MkdirAll(desktopConfigDir(), 0o755); err != nil {
377 t.Fatal(err)
378 }
379 if err := os.WriteFile(filepath.Join(desktopConfigDir(), tabsFileName), body, 0o600); err != nil {
380 t.Fatal(err)
381 }
382 app.tabsRestored = make(chan struct{})
383
384 app.restoreOrBuildTabs()
385 if len(app.tabs) != 0 || len(app.runtimeByID) != 0 || len(app.runtimeBySessionKey) != 0 {
386 t.Fatalf("stale restore created runtime state: tabs=%d runtimes=%d/%d", len(app.tabs), len(app.runtimeByID), len(app.runtimeBySessionKey))
387 }
388 persisted := loadTabsFile()
389 if len(persisted.Tabs) != 0 || persisted.ActiveTab != "" {
390 t.Fatalf("stale presentation remained persisted: %+v", persisted)
391 }
392 select {
393 case <-app.tabsRestored:
394 default:
395 t.Fatal("restore completion gate remained open")
396 }
397
398 second := NewApp()
399 second.ctx = t.Context()
400 pinDesktopSessionRoot(t, second)
401 t.Cleanup(func() { _ = second.draftStore().Close() })
402 finishSavedTabMigration(second)
403 second.tabsRestored = make(chan struct{})
404 second.restoreOrBuildTabs()
405 if len(second.tabs) != 0 || len(second.runtimeByID) != 0 || len(loadTabsFile().Tabs) != 0 {
406 t.Fatal("saved-tab repair was not idempotent across restart")
407 }
408 }
409
410 func TestDesktopTabsUnknownFieldsSurviveRoundTrip(t *testing.T) {
411 var file desktopTabsFile
412 raw := []byte(`{
413 "tabs": [{"id":"local","scope":"global","workspaceRoot":"","topicId":"topic","futureLocal":{"enabled":true}}],
414 "activeTab":"local",
415 "remoteTabs":[{"id":"remote","hostId":"host","workspace":"/work","futureRemote":7}],
416 "futureTop":{"version":4}
417 }`)
418 if err := json.Unmarshal(raw, &file); err != nil {
419 t.Fatal(err)
420 }
421 body, err := json.Marshal(file)
422 if err != nil {
423 t.Fatal(err)
424 }
425 var fields map[string]json.RawMessage
426 if err := json.Unmarshal(body, &fields); err != nil {
427 t.Fatal(err)
428 }
429 if string(fields["futureTop"]) != `{"version":4}` {
430 t.Fatalf("top-level unknown field lost: %s", body)
431 }
432 var localFields, remoteFields map[string]json.RawMessage
433 if err := json.Unmarshal(mustMarshalJSON(t, file.Tabs[0]), &localFields); err != nil {
434 t.Fatal(err)
435 }
436 if err := json.Unmarshal(mustMarshalJSON(t, file.RemoteTabs[0]), &remoteFields); err != nil {
437 t.Fatal(err)
438 }
439 if string(localFields["futureLocal"]) != `{"enabled":true}` || string(remoteFields["futureRemote"]) != "7" {
440 t.Fatalf("entry unknown fields lost: local=%s remote=%s", mustMarshalJSON(t, file.Tabs[0]), mustMarshalJSON(t, file.RemoteTabs[0]))
441 }
442 }
443
444 func mustMarshalJSON(t *testing.T, value any) []byte {
445 t.Helper()
446 body, err := json.Marshal(value)
447 if err != nil {
448 t.Fatal(err)
449 }
450 return body
451 }
452
453 func TestReconcileSavedTabsRetainsDurableDraftOperation(t *testing.T) {
454 app := newSavedTabReconcileTestApp(t)
455 root := t.TempDir()
456 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root)
457 if err != nil {
458 t.Fatal(err)
459 }
460 draft, _, err := app.draftStore().Open(t.Context(), workspaceID, "project", root, "draft-reconcile", `{}`)
461 if err != nil {
462 t.Fatal(err)
463 }
464 op, _, err := app.draftStore().BeginOperation(context.Background(), draftstate.Operation{
465 ID: "draft-op", DraftID: draft.ID, WorkspaceID: workspaceID, DraftRevision: draft.Revision,
466 SessionID: "draft-session", SubmissionID: "submission", Fingerprint: "fingerprint", RequestJSON: `{}`,
467 })
468 if err != nil {
469 t.Fatal(err)
470 }
471 file := desktopTabsFile{Tabs: []desktopTabEntry{savedProjectTab("draft", op.SessionID, op.ID, root, workspaceID)}, ActiveTab: "draft"}
472 got, changed := app.reconcileSavedTabs(t.Context(), file)
473 if changed || len(got.Tabs) != 1 || got.Tabs[0].SessionID != op.SessionID {
474 t.Fatalf("durable draft operation was not retained: changed=%v file=%+v", changed, got)
475 }
476 }
477
478 func TestReconcileSavedTabsPreservesResidualCanonicalArtifacts(t *testing.T) {
479 for _, fixture := range []struct {
480 name string
481 legacyPath bool
482 }{
483 {name: "partial canonical directory"},
484 {name: "legacy path on canonical tab", legacyPath: true},
485 } {
486 t.Run(fixture.name, func(t *testing.T) {
487 app := newSavedTabReconcileTestApp(t)
488 finishSavedTabMigration(app)
489 entry := desktopTabEntry{ID: "saved", Scope: "global", SessionID: "missing-manifest"}
490 artifact := filepath.Join(app.desktopSessions.root, entry.SessionID, "events.jsonl")
491 if fixture.legacyPath {
492 artifact = filepath.Join(t.TempDir(), "history.jsonl")
493 entry.SessionPath = artifact
494 }
495 if err := os.MkdirAll(filepath.Dir(artifact), 0o700); err != nil {
496 t.Fatal(err)
497 }
498 if err := os.WriteFile(artifact, []byte("{\"role\":\"user\",\"content\":\"history\"}\n"), 0o600); err != nil {
499 t.Fatal(err)
500 }
501
502 got, changed := app.reconcileSavedTabs(t.Context(), desktopTabsFile{Tabs: []desktopTabEntry{entry}})
503 if changed || len(got.Tabs) != 1 {
504 t.Fatalf("durable artifacts were hidden: changed=%v file=%+v", changed, got)
505 }
506 })
507 }
508 }
509
510 func TestPersistReconciledTabsRejectsStaleStartupSnapshot(t *testing.T) {
511 app := newSavedTabReconcileTestApp(t)
512 expectedVersion := app.tabsSnapshotVersion()
513 app.mu.Lock()
514 app.tabs["new"] = &WorkspaceTab{ID: "new", Scope: "global", SessionID: "new-session"}
515 app.tabOrder = []string{"new"}
516 app.activeTabID = "new"
517 dir, entries, activeID, version := app.saveTabsCollectLocked()
518 app.mu.Unlock()
519 app.saveTabsWrite(dir, entries, activeID, version)
520
521 stale := desktopTabsFile{Tabs: []desktopTabEntry{{ID: "old", Scope: "global", SessionID: "old-session"}}, ActiveTab: "old"}
522 if _, err := app.persistReconciledTabsFile(stale, expectedVersion); !errors.Is(err, errTabsSnapshotChanged) {
523 t.Fatalf("stale startup write error = %v, want %v", err, errTabsSnapshotChanged)
524 }
525 if got := loadTabsFile(); got.ActiveTab != "new" {
526 t.Fatalf("stale startup snapshot overwrote newer save: %+v", got)
527 }
528 }
529
530 func TestReconcileSavedTabsPreservesRecoveryOwnedEmptyConflict(t *testing.T) {
531 app := newSavedTabReconcileTestApp(t)
532 canonicalRoot := t.TempDir()
533 ref, workspaceID := createLegacyCleanupSession(t, app, canonicalRoot, "recovery-owned-conflict", false)
534 if err := app.initializeLegacyEmptySessionCleanupBatch(); err != nil {
535 t.Fatal(err)
536 }
537 if err := app.workspaceRegistry().RecordRecovery(t.Context(), workspacestate.RecoveryEntry{
538 ID: "recovery", SourceKey: "source", SessionID: ref.SessionID, WorkspaceID: workspaceID,
539 Scope: "project", WorkspaceRoot: canonicalRoot, Format: "canonical", Reason: "interrupted", Status: "pending",
540 }); err != nil {
541 t.Fatal(err)
542 }
543 state, err := app.workspaceRegistry().Load(t.Context())
544 if err != nil {
545 t.Fatal(err)
546 }
547 workspace := state.Workspaces[workspaceID]
548 workspace.Root = t.TempDir()
549 state.Workspaces[workspaceID] = workspace
550 if err := os.WriteFile(app.workspaceRegistry().Path(), mustMarshalJSON(t, state), 0o600); err != nil {
551 t.Fatal(err)
552 }
553 finishSavedTabMigration(app)
554 file := desktopTabsFile{Tabs: []desktopTabEntry{savedProjectTab("recover", ref.SessionID, "", canonicalRoot, workspaceID)}}
555
556 got, changed := app.reconcileSavedTabs(t.Context(), file)
557 if changed || len(got.Tabs) != 1 {
558 t.Fatalf("recovery-owned conflict was removed: changed=%v file=%+v", changed, got)
559 }
560 state, err = app.workspaceRegistry().Load(t.Context())
561 if err != nil || state.SessionStates[ref.SessionID].Lifecycle != workspacestate.Active {
562 t.Fatalf("recovery-owned lifecycle = %q, err=%v", state.SessionStates[ref.SessionID].Lifecycle, err)
563 }
564 }
565
566 func TestReconcileSavedTabsPreservesChangedSourceWithInactiveMapping(t *testing.T) {
567 app := newSavedTabReconcileTestApp(t)
568 path := filepath.Join(t.TempDir(), "history.jsonl")
569 if err := os.WriteFile(path, []byte("{\"role\":\"user\",\"content\":\"new history\"}\n"), 0o600); err != nil {
570 t.Fatal(err)
571 }
572 evidence := savedTabReconcileEvidence{registry: workspacestate.State{
573 SourceMappings: map[string]workspacestate.SourceMapping{"old": {Path: path, SessionID: "archived", Fingerprint: "old-fingerprint"}},
574 SessionStates: map[string]workspacestate.SessionState{"archived": {Lifecycle: workspacestate.Archived}},
575 }}
576 decision := app.classifyLegacySavedTab(desktopTabEntry{ID: "saved", Scope: "global", SessionPath: path}, evidence)
577 if decision.outcome != preserveRecovery {
578 t.Fatalf("changed legacy source decision = %+v, want preserve recovery", decision)
579 }
580 }
581
582 func TestBindLegacyRecoveryOwnerDoesNotCreateReplacement(t *testing.T) {
583 app := newSavedTabReconcileTestApp(t)
584 path := filepath.Join(t.TempDir(), "missing.jsonl")
585 workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "global", "")
586 if err != nil {
587 t.Fatal(err)
588 }
589 if err := app.workspaceRegistry().RecordRecovery(t.Context(), workspacestate.RecoveryEntry{
590 ID: "recovery", SourceKey: "source", Path: path, WorkspaceID: workspaceID,
591 Scope: "global", Format: "legacy", Reason: "interrupted", Status: "pending",
592 }); err != nil {
593 t.Fatal(err)
594 }
595 ctrl := control.New(control.Options{
596 Executor: agent.New(nil, nil, agent.NewSession("test"), agent.Options{}, event.Discard),
597 SessionService: app.desktopSessionService(""), ExclusiveSession: true,
598 })
599 t.Cleanup(ctrl.Close)
600 ref, _, err := app.bindTabCanonicalSession(t.Context(), ctrl, &config.Config{}, "global", "", "", path, "", false)
601 if !errors.Is(err, errLegacySourceRecoveryPending) || ref.SessionID != "" {
602 t.Fatalf("legacy recovery fallback = ref:%+v err:%v", ref, err)
603 }
604 }
605
606 func TestReconcileSavedTabsPreservesWhenMigrationFailed(t *testing.T) {
607 app := newSavedTabReconcileTestApp(t)
608 app.desktopMigrationFailed.Store(true)
609 finishSavedTabMigration(app)
610 file := desktopTabsFile{Tabs: []desktopTabEntry{{ID: "legacy", Scope: "global", SessionPath: filepath.Join(t.TempDir(), "missing.jsonl")}}}
611 got, changed := app.reconcileSavedTabs(t.Context(), file)
612 if changed || len(got.Tabs) != 1 {
613 t.Fatalf("failed migration discarded saved state: changed=%v file=%+v", changed, got)
614 }
615 }
616
616 lines GO