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