返回 DeepSeek-Reasonix
app_autosave_test.go
根目录 / desktop / app_autosave_test.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "strings"
9 "sync"
10 "testing"
11 "time"
12
13 "reasonix/internal/agent"
14 "reasonix/internal/control"
15 "reasonix/internal/event"
16 "reasonix/internal/provider"
17 "reasonix/internal/tool"
18 )
19
20 type stubProvider struct{}
21
22 func (stubProvider) Name() string { return "stub" }
23
24 func (stubProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
25 ch := make(chan provider.Chunk, 1)
26 close(ch)
27 return ch, nil
28 }
29
30 func controllerWithContent(t *testing.T, path string) *control.Controller {
31 t.Helper()
32 sess := agent.NewSession("system")
33 sess.Add(provider.Message{Role: provider.RoleUser, Content: "remember this turn"})
34 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "acknowledged"})
35 ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
36 ctrl := control.New(control.Options{Executor: ag, SessionDir: filepath.Dir(path), SessionPath: path, Sink: event.Discard})
37 t.Cleanup(ctrl.Close)
38 return ctrl
39 }
40
41 func waitForFile(t *testing.T, path, want string) {
42 t.Helper()
43 deadline := time.Now().Add(10 * time.Second)
44 for time.Now().Before(deadline) {
45 if b, err := os.ReadFile(path); err == nil && strings.Contains(string(b), want) {
46 return
47 }
48 time.Sleep(5 * time.Millisecond)
49 }
50 t.Fatalf("file %q never contained %q", path, want)
51 }
52
53 // autosaveDeadlineMargin leaves room to report a stuck loop and run cleanups
54 // before the test binary's own timeout panics.
55 const autosaveDeadlineMargin = 5 * time.Second
56
57 // waitForAutosaveIdle waits for the loop to finish, which the bounded save
58 // waits and retries guarantee. It sets no bound of its own: those waits alone
59 // can outlast any fixed one on a slow disk, so only the binary deadline caps it.
60 func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
61 t.Helper()
62 deadline, bounded := t.Deadline()
63 for {
64 tab.saveMu.Lock()
65 idle := !tab.saving && !tab.saveAgain
66 tab.saveMu.Unlock()
67 if idle {
68 return
69 }
70 if bounded && time.Until(deadline) < autosaveDeadlineMargin {
71 t.Fatal("autosave loop still running at the test binary deadline")
72 }
73 time.Sleep(5 * time.Millisecond)
74 }
75 }
76
77 // requireAutosaved waits for the loop the caller started, then checks what it
78 // left on disk; polling the file instead would put a clock on the disk.
79 func requireAutosaved(t *testing.T, tab *WorkspaceTab, path, want string) {
80 t.Helper()
81 waitForAutosaveIdle(t, tab)
82 b, err := os.ReadFile(path)
83 if err != nil {
84 t.Fatalf("read session file after autosave: %v", err)
85 }
86 if !strings.Contains(string(b), want) {
87 t.Fatalf("session file %q does not contain %q after autosave went idle", path, want)
88 }
89 }
90
91 func appWithTab(t *testing.T, path string) (*App, *WorkspaceTab) {
92 t.Helper()
93 ctrl := controllerWithContent(t, path)
94 tab := &WorkspaceTab{
95 ID: "test_tab",
96 Ctrl: ctrl,
97 Scope: "global",
98 WorkspaceRoot: "",
99 Ready: true,
100 disabledMCP: map[string]ServerView{},
101 }
102 tab.sink = &tabEventSink{tabID: tab.ID, app: nil}
103 a := &App{
104 tabs: map[string]*WorkspaceTab{"test_tab": tab},
105 activeTabID: "test_tab",
106 }
107 tab.sink.app = a
108 t.Cleanup(func() { waitForAutosaveIdle(t, tab) })
109 return a, tab
110 }
111
112 // TestTurnDonePersistsSession proves a completed turn is written to disk without
113 // any explicit Snapshot call — the desktop autosave the data-loss fix adds. A
114 // nil sink ctx (no webview) must not disable persistence.
115 func TestTurnDonePersistsSession(t *testing.T) {
116 path := filepath.Join(t.TempDir(), "session.jsonl")
117 _, tab := appWithTab(t, path)
118
119 tab.sink.Emit(event.Event{Kind: event.TurnDone})
120
121 requireAutosaved(t, tab, path, "remember this turn")
122 }
123
124 // TestNonTurnDoneDoesNotPersist confirms only TurnDone triggers a save, so the
125 // per-token event storm doesn't thrash the disk.
126 func TestNonTurnDoneDoesNotPersist(t *testing.T) {
127 path := filepath.Join(t.TempDir(), "session.jsonl")
128 a, tab := appWithTab(t, path)
129 _ = a
130
131 tab.sink.Emit(event.Event{Kind: event.Text, Text: "tok"})
132
133 time.Sleep(50 * time.Millisecond)
134 if _, err := os.Stat(path); !os.IsNotExist(err) {
135 t.Fatalf("a non-TurnDone event wrote the session file (err=%v)", err)
136 }
137 }
138
139 // TestScheduleSnapshotCoalesces hammers the scheduler concurrently to prove the
140 // single-flight loop neither panics nor drops the final write.
141 func TestScheduleSnapshotCoalesces(t *testing.T) {
142 path := filepath.Join(t.TempDir(), "session.jsonl")
143 a, tab := appWithTab(t, path)
144 _ = a
145
146 var wg sync.WaitGroup
147 for range 64 {
148 wg.Go(func() {
149 tab.sink.Emit(event.Event{Kind: event.TurnDone})
150 })
151 }
152 wg.Wait()
153
154 requireAutosaved(t, tab, path, "acknowledged")
155 }
156
157 func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
158 path := filepath.Join(t.TempDir(), "blocked.jsonl")
159 if err := os.Mkdir(path, 0o755); err != nil {
160 t.Fatalf("mkdir blocked path: %v", err)
161 }
162 a, tab := appWithTab(t, path)
163 ctrl := tab.Ctrl
164 // Retry ownership does not depend on how quickly the filesystem rejects
165 // a write. Inject that failure, then use the real controller for recovery.
166 tab.Ctrl = &snapshotErrorSessionController{SessionAPI: ctrl, err: os.ErrPermission}
167
168 tab.sink.Emit(event.Event{Kind: event.TurnDone})
169 waitForAutosaveIdle(t, tab)
170
171 tab.saveMu.Lock()
172 failures := tab.saveFailures
173 tab.saveMu.Unlock()
174 if failures != maxTabSnapshotFailureRetries+1 {
175 t.Fatalf("autosave failures = %d, want %d attempts", failures, maxTabSnapshotFailureRetries+1)
176 }
177 if info, err := os.Stat(path); err != nil || !info.IsDir() {
178 t.Fatalf("blocked session path should still be the directory, info=%v err=%v", info, err)
179 }
180
181 if err := os.Remove(path); err != nil {
182 t.Fatalf("remove blocked dir: %v", err)
183 }
184 a.mu.Lock()
185 tab.Ctrl = ctrl
186 a.mu.Unlock()
187 tab.sink.Emit(event.Event{Kind: event.TurnDone})
188 requireAutosaved(t, tab, path, "remember this turn")
189
190 tab.saveMu.Lock()
191 failures = tab.saveFailures
192 tab.saveMu.Unlock()
193 if failures != 0 {
194 t.Fatalf("autosave failures after recovery = %d, want 0", failures)
195 }
196 }
197
198 func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
199 t.Setenv(agent.SessionLogSchemaEnv, "v1")
200 isolateDesktopUserDirs(t)
201
202 root := globalTabWorkspaceRoot()
203 dir := desktopSessionDir(root)
204 if err := os.MkdirAll(dir, 0o755); err != nil {
205 t.Fatalf("mkdir sessions: %v", err)
206 }
207 originalPath := filepath.Join(dir, "session.jsonl")
208 originalTopic := "topic_original"
209 if err := setTopicTitle("", originalTopic, "Original"); err != nil {
210 t.Fatalf("set original topic title: %v", err)
211 }
212 current := agent.NewSession("sys")
213 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
214 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
215 current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
216 if err := current.Save(originalPath); err != nil {
217 t.Fatalf("Save current: %v", err)
218 }
219 if err := agent.SaveBranchMeta(originalPath, agent.BranchMeta{
220 Scope: "global",
221 TopicID: originalTopic,
222 TopicTitle: "Original",
223 Preview: "first",
224 Turns: 2,
225 SchemaVersion: agent.BranchMetaCountsVersion,
226 }); err != nil {
227 t.Fatalf("SaveBranchMeta original: %v", err)
228 }
229
230 staleSess := agent.NewSession("sys")
231 staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
232 staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
233 staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
234 staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
235 app := &App{
236 tabs: map[string]*WorkspaceTab{},
237 detachedSessions: map[string]*WorkspaceTab{},
238 activeTabID: "recovery_tab",
239 }
240 tab := &WorkspaceTab{
241 ID: "recovery_tab",
242 Scope: "global",
243 WorkspaceRoot: root,
244 TopicID: originalTopic,
245 TopicTitle: "Original",
246 SessionPath: originalPath,
247 Ready: true,
248 model: "test-model",
249 disabledMCP: map[string]ServerView{},
250 }
251 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
252 tab.Ctrl = control.New(control.Options{
253 Executor: staleExec,
254 SessionDir: dir,
255 SessionPath: originalPath,
256 Label: "test",
257 Sink: tab.sink,
258 SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
259 OnSessionRecovered: app.handleTabSessionRecovered(tab),
260 })
261 app.tabs[tab.ID] = tab
262
263 if err := tab.Ctrl.Snapshot(); err != nil {
264 t.Fatalf("Snapshot: %v", err)
265 }
266 recoveryPath := tab.Ctrl.SessionPath()
267 if recoveryPath == "" || recoveryPath == originalPath {
268 t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
269 }
270 if tab.SessionPath != recoveryPath {
271 t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
272 }
273 saved := loadTabsFile()
274 if len(saved.Tabs) != 1 || saved.Tabs[0].ID != tab.ID {
275 t.Fatalf("saved tabs = %+v, want recovered tab %q", saved.Tabs, tab.ID)
276 }
277 if got := saved.Tabs[0].SessionPath; got != recoveryPath {
278 t.Fatalf("saved tab session path = %q, want recovery path %q", got, recoveryPath)
279 }
280 if tab.TopicID != originalTopic {
281 t.Fatalf("tab topic ID = %q, want original topic %q", tab.TopicID, originalTopic)
282 }
283 meta, ok, err := agent.LoadBranchMeta(recoveryPath)
284 if err != nil || !ok {
285 t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
286 }
287 if !meta.Recovered || meta.TopicID != tab.TopicID || meta.TopicTitle != tab.TopicTitle {
288 t.Fatalf("recovery meta = %+v, tab topic=%q/%q", meta, tab.TopicID, tab.TopicTitle)
289 }
290 tabMeta := app.tabMeta(tab, true)
291 if !tabMeta.Recovered || tabMeta.RecoveryDigest != meta.RecoveryDigest || tabMeta.RecoveryParentID != string(meta.ParentID) {
292 t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
293 }
294 nodes := mustListProjectTree(t, app)
295 foundOriginal := false
296 var walk func([]ProjectNode)
297 walk = func(list []ProjectNode) {
298 for _, node := range list {
299 if node.Recovered {
300 t.Fatalf("project tree should hide recovery metadata, got node %+v", node)
301 }
302 if node.TopicID == originalTopic {
303 foundOriginal = true
304 }
305 walk(node.Children)
306 }
307 }
308 walk(nodes)
309 if !foundOriginal {
310 t.Fatalf("project tree did not include original topic %q: %#v", originalTopic, nodes)
311 }
312 }
313
314 func TestDesktopSnapshotConflictRecoveryRequiresRecoveryLease(t *testing.T) {
315 t.Setenv(agent.SessionLogSchemaEnv, "v1")
316 isolateDesktopUserDirs(t)
317
318 root := globalTabWorkspaceRoot()
319 dir := desktopSessionDir(root)
320 if err := os.MkdirAll(dir, 0o755); err != nil {
321 t.Fatalf("mkdir sessions: %v", err)
322 }
323 originalPath := filepath.Join(dir, "session.jsonl")
324 current := agent.NewSession("sys")
325 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
326 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
327 current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
328 if err := current.Save(originalPath); err != nil {
329 t.Fatalf("Save current: %v", err)
330 }
331
332 staleSess := agent.NewSession("sys")
333 staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
334 staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
335 staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
336 recovery, err := staleSess.SaveRecoveryBranch(agent.RecoveryBranchOptions{
337 OriginalPath: originalPath,
338 BranchMeta: agent.BranchMeta{
339 Name: agent.RecoveryBranchDefaultName,
340 Scope: "global",
341 TopicID: "topic_recovery",
342 TopicTitle: "Recovery",
343 },
344 })
345 if err != nil {
346 t.Fatalf("SaveRecoveryBranch: %v", err)
347 }
348 lease, err := agent.TryAcquireSessionLease(recovery.Path)
349 if err != nil {
350 t.Fatalf("TryAcquireSessionLease recovery: %v", err)
351 }
352 defer lease.Release()
353
354 staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
355 runtimeEvents := make(chan runtimeEventEnvelope, 4)
356 app := &App{
357 ctx: context.Background(),
358 tabs: map[string]*WorkspaceTab{},
359 detachedSessions: map[string]*WorkspaceTab{},
360 activeTabID: "recovery_tab",
361 }
362 app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...any) {
363 runtimeEvents <- runtimeEventEnvelope{
364 ctx: ctx,
365 name: name,
366 payload: append([]any(nil), payload...),
367 }
368 }
369 tab := &WorkspaceTab{
370 ID: "recovery_tab",
371 Scope: "global",
372 WorkspaceRoot: root,
373 TopicID: "topic_original",
374 TopicTitle: "Original",
375 SessionPath: originalPath,
376 Ready: true,
377 model: "test-model",
378 disabledMCP: map[string]ServerView{},
379 }
380 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
381 tab.Ctrl = control.New(control.Options{
382 Executor: staleExec,
383 SessionDir: dir,
384 SessionPath: originalPath,
385 Label: "test",
386 Sink: tab.sink,
387 SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
388 OnSessionRecovered: app.handleTabSessionRecovered(tab),
389 })
390 app.tabs[tab.ID] = tab
391 app.mu.Lock()
392 app.saveTabsLocked()
393 app.mu.Unlock()
394
395 err = tab.Ctrl.Snapshot()
396 if !errors.Is(err, agent.ErrSessionLeaseHeld) {
397 t.Fatalf("Snapshot err = %v, want ErrSessionLeaseHeld", err)
398 }
399 if got := tab.Ctrl.SessionPath(); got != originalPath {
400 t.Fatalf("controller session path = %q, want original %q", got, originalPath)
401 }
402 if tab.SessionPath != originalPath {
403 t.Fatalf("tab session path = %q, want original %q", tab.SessionPath, originalPath)
404 }
405 if tab.TopicID != "topic_original" {
406 t.Fatalf("tab topic ID = %q, want original topic", tab.TopicID)
407 }
408 saved := loadTabsFile()
409 if len(saved.Tabs) != 1 || saved.Tabs[0].SessionPath != originalPath {
410 t.Fatalf("saved tabs after failed recovery = %+v, want original path %q", saved.Tabs, originalPath)
411 }
412
413 deadline := time.After(time.Second)
414 for {
415 select {
416 case emitted := <-runtimeEvents:
417 if emitted.name != "session:recovery-failed" {
418 continue
419 }
420 if len(emitted.payload) != 1 {
421 t.Fatalf("session:recovery-failed payload count = %d, want 1", len(emitted.payload))
422 }
423 failed, ok := emitted.payload[0].(sessionRecoveryFailedEvent)
424 if !ok {
425 t.Fatalf("session:recovery-failed payload type = %T, want sessionRecoveryFailedEvent", emitted.payload[0])
426 }
427 if failed.Reason != "lease_held" {
428 t.Fatalf("session:recovery-failed reason = %q, want lease_held", failed.Reason)
429 }
430 return
431 case <-deadline:
432 t.Fatal("session:recovery-failed event was not emitted")
433 }
434 }
435 }
436
437 func TestSetActiveTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
438 path := filepath.Join(t.TempDir(), "blocked.jsonl")
439 if err := os.Mkdir(path, 0o755); err != nil {
440 t.Fatalf("mkdir blocked path: %v", err)
441 }
442 a, _ := appWithTab(t, path)
443 a.tabs["target_tab"] = &WorkspaceTab{
444 ID: "target_tab",
445 Scope: "global",
446 Ready: true,
447 disabledMCP: map[string]ServerView{},
448 }
449 a.tabOrder = []string{"test_tab", "target_tab"}
450
451 err := a.SetActiveTab("target_tab")
452 if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
453 t.Fatalf("SetActiveTab error = %v, want persistence failure", err)
454 }
455 if a.activeTabID != "test_tab" {
456 t.Fatalf("active tab = %q, want original tab after failed save", a.activeTabID)
457 }
458 }
459
460 func TestRebindSessionBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
461 path := filepath.Join(t.TempDir(), "blocked.jsonl")
462 if err := os.Mkdir(path, 0o755); err != nil {
463 t.Fatalf("mkdir blocked path: %v", err)
464 }
465 a, tab := appWithTab(t, path)
466 target := filepath.Join(t.TempDir(), "target.jsonl")
467 sess := agent.NewSession("system")
468 sess.Add(provider.Message{Role: provider.RoleUser, Content: "target prompt"})
469 if err := sess.Save(target); err != nil {
470 t.Fatalf("save target: %v", err)
471 }
472 loaded, err := agent.LoadSession(target)
473 if err != nil {
474 t.Fatalf("load target: %v", err)
475 }
476
477 err = a.rebindTabToLoadedSessionPath(tab, target, loaded)
478 if err == nil || !strings.Contains(err.Error(), "save current session before switching sessions") {
479 t.Fatalf("rebind error = %v, want persistence failure", err)
480 }
481 if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
482 t.Fatalf("tab controller/path changed after failed save: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
483 }
484 }
485
486 // TestCloseTabNoResurrectionFromAutosave is the regression test for #4384.
487 // It proves that after CloseTab returns, the per-turn autosave goroutine can no
488 // longer write the session file — even when it is in flight at the moment the
489 // tab is closed. Pre-fix, the loop held a raw *WorkspaceTab pointer and a
490 // captured session path, so its Snapshot() call landed after DeleteSession
491 // trashed the file, "resurrecting" it.
492 func TestCloseTabNoResurrectionFromAutosave(t *testing.T) {
493 path := filepath.Join(t.TempDir(), "session.jsonl")
494
495 doomed, doomedTab := appWithTab(t, path)
496 // CloseTab needs >1 tab and mutates activeTabID, so add a survivor tab.
497 survivor := &WorkspaceTab{
498 ID: "survivor_tab",
499 Scope: "global",
500 Ready: true,
501 disabledMCP: map[string]ServerView{},
502 }
503 survivor.sink = &tabEventSink{tabID: survivor.ID, app: doomed}
504 doomed.tabs["survivor_tab"] = survivor
505 doomed.activeTabID = "test_tab"
506
507 // Write the session file once via the autosave loop, then wait for idle so
508 // the next TurnDone reliably kicks off a fresh loop.
509 doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
510 requireAutosaved(t, doomedTab, path, "acknowledged")
511
512 // Kick the autosave loop and close the tab in close succession. The loop
513 // will be in flight when CloseTab runs — exactly the #4384 window.
514 doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
515 if err := doomed.CloseTab("test_tab"); err != nil {
516 t.Fatalf("CloseTab: %v", err)
517 }
518
519 // CloseTab must have returned only after the autosave loop finished. Remove
520 // the file the way DeleteSession would (move to trash is just a remove here
521 // since we only care that nothing rewrites the original path).
522 if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
523 t.Fatalf("remove session file: %v", err)
524 }
525
526 // Give any would-be resurrection a chance to strike. If the autosave loop
527 // were still alive (the bug), the file reappears here.
528 time.Sleep(100 * time.Millisecond)
529 if _, err := os.Stat(path); !os.IsNotExist(err) {
530 t.Fatalf("session file resurrected after CloseTab + delete (stat err=%v) — autosave loop not drained", err)
531 }
532
533 // And the controller's session path must be cleared so no future Snapshot
534 // can write either.
535 if got := doomedTab.Ctrl.SessionPath(); got != "" {
536 t.Fatalf("controller session path = %q after CloseTab, want empty so snapshots no-op", got)
537 }
538 }
539
540 func TestCloseTabBlocksWhenSessionCannotPersist(t *testing.T) {
541 path := filepath.Join(t.TempDir(), "blocked.jsonl")
542 if err := os.Mkdir(path, 0o755); err != nil {
543 t.Fatalf("mkdir blocked path: %v", err)
544 }
545 a, tab := appWithTab(t, path)
546 survivor := &WorkspaceTab{
547 ID: "survivor_tab",
548 Scope: "global",
549 Ready: true,
550 disabledMCP: map[string]ServerView{},
551 }
552 survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
553 a.tabs[survivor.ID] = survivor
554 a.tabOrder = []string{tab.ID, survivor.ID}
555
556 err := a.CloseTab(tab.ID)
557 if err == nil || !strings.Contains(err.Error(), "save current session before closing tab") {
558 t.Fatalf("CloseTab error = %v, want persistence failure", err)
559 }
560 if _, ok := a.tabs[tab.ID]; !ok {
561 t.Fatal("tab was removed even though its session could not be saved")
562 }
563 if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
564 t.Fatalf("tab controller/path changed after failed close: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
565 }
566 }
567
568 // TestCloseTabSurvivorKeepsAutosave ensures the survivor tab is untouched: the
569 // closing/drain logic is per-tab and must not leak to other tabs.
570 func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
571 doomedPath := filepath.Join(t.TempDir(), "doomed.jsonl")
572 survivorPath := filepath.Join(t.TempDir(), "survivor.jsonl")
573
574 a, _ := appWithTab(t, doomedPath)
575 survivorCtrl := controllerWithContent(t, survivorPath)
576 survivor := &WorkspaceTab{
577 ID: "survivor_tab",
578 Ctrl: survivorCtrl,
579 Scope: "global",
580 Ready: true,
581 disabledMCP: map[string]ServerView{},
582 }
583 survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
584 a.tabs["survivor_tab"] = survivor
585 a.activeTabID = "test_tab"
586
587 survivor.sink.Emit(event.Event{Kind: event.TurnDone})
588 requireAutosaved(t, survivor, survivorPath, "acknowledged")
589
590 if err := a.CloseTab("test_tab"); err != nil {
591 t.Fatalf("CloseTab: %v", err)
592 }
593
594 if got := survivor.Ctrl.SessionPath(); got != survivorPath {
595 t.Fatalf("survivor session path = %q, want %q", got, survivorPath)
596 }
597 if survivor.closing {
598 t.Fatal("survivor tab was marked closing — closing flag leaked across tabs")
599 }
600 }
601
602 func TestLegacyDeleteSessionClearsRemovedRuntimeSessionPath(t *testing.T) {
603 isolateDesktopUserDirs(t)
604
605 dir := t.TempDir()
606 path := filepath.Join(dir, "delete-open.jsonl")
607 ctrl := controllerWithContent(t, path)
608 tab := &WorkspaceTab{
609 ID: "delete_open",
610 Scope: "global",
611 Ready: true,
612 Ctrl: ctrl,
613 disabledMCP: map[string]ServerView{},
614 }
615 app := &App{
616 tabs: map[string]*WorkspaceTab{"delete_open": tab},
617 activeTabID: "delete_open",
618 }
619 if err := ctrl.Snapshot(); err != nil {
620 t.Fatalf("snapshot: %v", err)
621 }
622
623 if err := app.deleteSession(path); err != nil {
624 t.Fatalf("DeleteSession: %v", err)
625 }
626
627 if got := ctrl.SessionPath(); got != "" {
628 t.Fatalf("removed controller session path = %q, want empty before trash move can race Windows file locks", got)
629 }
630 trashPath := filepath.Join(dir, sessionTrashDir, "delete-open.jsonl", "delete-open.jsonl")
631 if _, err := os.Stat(trashPath); err != nil {
632 t.Fatalf("session should be in trash: %v", err)
633 }
634 }
635
636 func TestTrashTopicClearsRemovedRuntimeSessionPath(t *testing.T) {
637 isolateDesktopUserDirs(t)
638
639 projectRoot := t.TempDir()
640 topicID := "topic_clear_removed_runtime"
641 if err := addProject(projectRoot, ""); err != nil {
642 t.Fatalf("add project: %v", err)
643 }
644 if err := setTopicTitle(projectRoot, topicID, "Clear removed runtime"); err != nil {
645 t.Fatalf("set topic title: %v", err)
646 }
647 dir := t.TempDir()
648 path := filepath.Join(dir, "trash-open-topic.jsonl")
649 ctrl := controllerWithContent(t, path)
650 if err := ctrl.Snapshot(); err != nil {
651 t.Fatalf("snapshot: %v", err)
652 }
653 if err := agent.SaveBranchMeta(path, agent.BranchMeta{
654 CreatedAt: time.Now().Add(-time.Minute),
655 UpdatedAt: time.Now(),
656 Scope: "project",
657 WorkspaceRoot: projectRoot,
658 TopicID: topicID,
659 TopicTitle: "Clear removed runtime",
660 }); err != nil {
661 t.Fatalf("save branch meta: %v", err)
662 }
663 tab := &WorkspaceTab{
664 ID: "trash_open",
665 Scope: "project",
666 WorkspaceRoot: projectRoot,
667 TopicID: topicID,
668 TopicTitle: "Clear removed runtime",
669 Ready: true,
670 Ctrl: ctrl,
671 disabledMCP: map[string]ServerView{},
672 }
673 survivor := &WorkspaceTab{
674 ID: "survivor",
675 Scope: "global",
676 Ready: true,
677 disabledMCP: map[string]ServerView{},
678 }
679 app := &App{
680 tabs: map[string]*WorkspaceTab{"trash_open": tab, "survivor": survivor},
681 tabOrder: []string{"trash_open", "survivor"},
682 activeTabID: "trash_open",
683 }
684
685 pinDesktopSessionRoot(t, app)
686 if err := app.TrashTopic(topicID); err != nil {
687 t.Fatalf("TrashTopic: %v", err)
688 }
689
690 if got := ctrl.SessionPath(); got != "" {
691 t.Fatalf("removed topic controller session path = %q, want empty before trash move can race Windows file locks", got)
692 }
693 assertLegacyLifecycle(t, app, path, "archived")
694 }
695
695 lines GO