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