返回 DeepSeek-Reasonix
tabs_order_test.go
根目录 / desktop / tabs_order_test.go
1 package main
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "sync"
9 "testing"
10 "time"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/config"
14 "reasonix/internal/control"
15 "reasonix/internal/event"
16 )
17
18 func testAppWithOrderedTabs(t *testing.T, active string, ids ...string) *App {
19 t.Helper()
20 isolateDesktopUserDirs(t)
21 tabs := make(map[string]*WorkspaceTab, len(ids))
22 for _, id := range ids {
23 tabs[id] = &WorkspaceTab{
24 ID: id,
25 Scope: "global",
26 TopicID: "topic_" + id,
27 TopicTitle: id,
28 Ready: true,
29 disabledMCP: map[string]ServerView{},
30 }
31 }
32 return &App{tabs: tabs, tabOrder: append([]string(nil), ids...), activeTabID: active}
33 }
34
35 func installNoopRuntimeEvents(app *App, sinks ...*tabEventSink) {
36 emit := func(context.Context, string, ...interface{}) {}
37 if app != nil {
38 app.runtimeEvents.emit = emit
39 }
40 for _, sink := range sinks {
41 if sink != nil {
42 sink.runtimeEvents.emit = emit
43 }
44 }
45 }
46
47 func tabIDs(tabs []TabMeta) []string {
48 ids := make([]string, 0, len(tabs))
49 for _, tab := range tabs {
50 ids = append(ids, tab.ID)
51 }
52 return ids
53 }
54
55 func assertTabIDs(t *testing.T, got []TabMeta, want ...string) {
56 t.Helper()
57 gotIDs := tabIDs(got)
58 if len(gotIDs) != len(want) {
59 t.Fatalf("tab ids = %v, want %v", gotIDs, want)
60 }
61 for i := range want {
62 if gotIDs[i] != want[i] {
63 t.Fatalf("tab ids = %v, want %v", gotIDs, want)
64 }
65 }
66 }
67
68 type resetCountingSession struct {
69 control.SessionAPI
70 resets int
71 }
72
73 func (s *resetCountingSession) ResetPlannerSession() {
74 s.resets++
75 }
76
77 type snapshotObservingSession struct {
78 control.SessionAPI
79 onSnapshot func()
80 }
81
82 func (s *snapshotObservingSession) Snapshot() error {
83 if s.onSnapshot != nil {
84 s.onSnapshot()
85 }
86 return nil
87 }
88
89 func expectAppMutexAvailableDuringSnapshot(t *testing.T, app *App, checks chan<- struct{}) func() {
90 t.Helper()
91 return func() {
92 acquired := make(chan struct{})
93 go func() {
94 app.mu.Lock()
95 app.mu.Unlock() //nolint:staticcheck // probe: lock must be immediately acquirable
96 close(acquired)
97 }()
98 select {
99 case <-acquired:
100 case <-time.After(500 * time.Millisecond):
101 t.Error("Snapshot ran while holding app mutex")
102 }
103 if checks == nil {
104 return
105 }
106 select {
107 case checks <- struct{}{}:
108 default:
109 }
110 }
111 }
112
113 func TestListTabsKeepsExplicitOrderWhenActiveChanges(t *testing.T) {
114 app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
115
116 assertTabIDs(t, app.ListTabs(), "a", "b", "c")
117 if err := app.SetActiveTab("c"); err != nil {
118 t.Fatalf("SetActiveTab: %v", err)
119 }
120 assertTabIDs(t, app.ListTabs(), "a", "b", "c")
121 if got := app.activeTabID; got != "c" {
122 t.Fatalf("active tab = %q, want c", got)
123 }
124 }
125
126 func TestSetActiveTabDoesNotResetPlannerSession(t *testing.T) {
127 isolateDesktopUserDirs(t)
128 ctrlA := &resetCountingSession{SessionAPI: control.New(control.Options{Label: "a"})}
129 ctrlB := &resetCountingSession{SessionAPI: control.New(control.Options{Label: "b"})}
130 defer ctrlA.Close()
131 defer ctrlB.Close()
132 app := testAppWithOrderedTabs(t, "a", "a", "b")
133 app.tabs["a"].Ctrl = ctrlA
134 app.tabs["b"].Ctrl = ctrlB
135
136 if err := app.SetActiveTab("b"); err != nil {
137 t.Fatalf("SetActiveTab: %v", err)
138 }
139 if ctrlA.resets != 0 || ctrlB.resets != 0 {
140 t.Fatalf("planner resets on tab activation = active:%d inactive:%d, want 0", ctrlA.resets, ctrlB.resets)
141 }
142 }
143
144 func TestSingleSurfaceTabsFileKeepsActiveEntry(t *testing.T) {
145 f := desktopTabsFile{
146 Tabs: []desktopTabEntry{
147 {ID: "a", Scope: "global", TopicID: "topic-a"},
148 {ID: "b", Scope: "project", WorkspaceRoot: "/tmp/project", TopicID: "topic-b"},
149 {ID: "c", Scope: "global", TopicID: "topic-c"},
150 },
151 ActiveTab: "b",
152 }
153
154 got := singleSurfaceTabsFile(f)
155 if len(got.Tabs) != 1 || got.Tabs[0].ID != "b" || got.ActiveTab != "b" {
156 t.Fatalf("single-surface tabs = %+v, want only active b", got)
157 }
158 }
159
160 func TestSetDesktopLayoutStyleAppliesPolicyAfterWorkspaceAlias(t *testing.T) {
161 app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
162
163 if err := app.SetDesktopLayoutStyle("workspace"); err != nil {
164 t.Fatalf("SetDesktopLayoutStyle(workspace): %v", err)
165 }
166
167 assertTabIDs(t, app.ListTabs(), "b")
168 if got := loadTabsFile(); len(got.Tabs) != 1 || got.Tabs[0].ID != "b" || got.ActiveTab != "b" {
169 t.Fatalf("persisted tabs after workspace alias = %+v, want only active b", got)
170 }
171 }
172
173 func TestKeepOnlyVisibleTabDetachesRunningHiddenTab(t *testing.T) {
174 isolateDesktopUserDirs(t)
175 dir := config.SessionDir()
176 if err := os.MkdirAll(dir, 0o755); err != nil {
177 t.Fatalf("mkdir sessions: %v", err)
178 }
179 path := filepath.Join(dir, "running.jsonl")
180 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
181 ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "running", Sink: event.Discard})
182 running := &WorkspaceTab{
183 ID: "running",
184 Scope: "global",
185 WorkspaceRoot: globalTabWorkspaceRoot(),
186 SessionPath: path,
187 Ctrl: ctrl,
188 Ready: true,
189 sink: &tabEventSink{tabID: "running"},
190 disabledMCP: map[string]ServerView{},
191 }
192 target := &WorkspaceTab{ID: "target", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}}
193 app := &App{
194 tabs: map[string]*WorkspaceTab{"running": running, "target": target},
195 tabOrder: []string{"running", "target"},
196 activeTabID: "running",
197 detachedSessions: map[string]*WorkspaceTab{},
198 }
199
200 ctrl.Submit("block")
201 <-runner.started
202 if _, err := app.keepOnlyVisibleTab("target"); err != nil {
203 t.Fatalf("keepOnlyVisibleTab: %v", err)
204 }
205 assertTabIDs(t, app.ListTabs(), "target")
206 if !ctrl.Running() {
207 t.Fatal("single-surface pruning cancelled a running controller")
208 }
209 if _, ok := app.detachedSessions[sessionRuntimeKey(path)]; !ok {
210 t.Fatalf("detached runtime missing for %q", path)
211 }
212 if got := loadTabsFile(); len(got.Tabs) != 1 || got.Tabs[0].ID != "target" {
213 t.Fatalf("persisted tabs after pruning = %+v, want only target", got)
214 }
215
216 close(runner.release)
217 waitNotRunning(t, ctrl)
218 ctrl.Close()
219 }
220
221 func TestKeepOnlyVisibleTabSnapshotsHiddenTabWithoutAppLock(t *testing.T) {
222 isolateDesktopUserDirs(t)
223 app := &App{
224 tabs: map[string]*WorkspaceTab{},
225 tabOrder: []string{"hidden", "target"},
226 activeTabID: "hidden",
227 }
228 snapshotChecks := make(chan struct{}, 2)
229 hiddenCtrl := &snapshotObservingSession{
230 SessionAPI: control.New(control.Options{Label: "hidden"}),
231 onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
232 }
233 hidden := &WorkspaceTab{
234 ID: "hidden",
235 Scope: "global",
236 WorkspaceRoot: globalTabWorkspaceRoot(),
237 TopicID: "topic-hidden",
238 Ctrl: hiddenCtrl,
239 Ready: true,
240 sink: &tabEventSink{tabID: "hidden"},
241 disabledMCP: map[string]ServerView{},
242 }
243 target := &WorkspaceTab{
244 ID: "target",
245 Scope: "global",
246 Ready: true,
247 disabledMCP: map[string]ServerView{},
248 }
249 app.tabs["hidden"] = hidden
250 app.tabs["target"] = target
251
252 if _, err := app.keepOnlyVisibleTab("target"); err != nil {
253 t.Fatalf("keepOnlyVisibleTab: %v", err)
254 }
255 select {
256 case <-snapshotChecks:
257 case <-time.After(time.Second):
258 t.Fatal("hidden tab was not snapshotted before pruning")
259 }
260 assertTabIDs(t, app.ListTabs(), "target")
261 }
262
263 func TestCloseTabSnapshotsWithoutAppLock(t *testing.T) {
264 isolateDesktopUserDirs(t)
265 app := &App{
266 tabs: map[string]*WorkspaceTab{},
267 tabOrder: []string{"closing", "survivor"},
268 activeTabID: "closing",
269 }
270 snapshotChecks := make(chan struct{}, 1)
271 closingCtrl := &snapshotObservingSession{
272 SessionAPI: control.New(control.Options{Label: "closing"}),
273 onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
274 }
275 closing := &WorkspaceTab{
276 ID: "closing",
277 Scope: "global",
278 WorkspaceRoot: globalTabWorkspaceRoot(),
279 TopicID: "topic-closing",
280 Ctrl: closingCtrl,
281 Ready: true,
282 sink: &tabEventSink{tabID: "closing"},
283 disabledMCP: map[string]ServerView{},
284 }
285 survivor := &WorkspaceTab{
286 ID: "survivor",
287 Scope: "global",
288 Ready: true,
289 disabledMCP: map[string]ServerView{},
290 }
291 app.tabs["closing"] = closing
292 app.tabs["survivor"] = survivor
293
294 if err := app.CloseTab("closing"); err != nil {
295 t.Fatalf("CloseTab: %v", err)
296 }
297 select {
298 case <-snapshotChecks:
299 case <-time.After(time.Second):
300 t.Fatal("closing tab was not snapshotted")
301 }
302 assertTabIDs(t, app.ListTabs(), "survivor")
303 }
304
305 func TestKeepOnlyVisibleTabCancelsBuildingHiddenTab(t *testing.T) {
306 isolateDesktopUserDirs(t)
307 cancelled := false
308 building := &WorkspaceTab{
309 ID: "building",
310 Scope: "global",
311 Ready: false,
312 buildCancel: func() { cancelled = true },
313 sink: &tabEventSink{tabID: "building"},
314 disabledMCP: map[string]ServerView{},
315 }
316 target := &WorkspaceTab{ID: "target", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}}
317 app := &App{
318 tabs: map[string]*WorkspaceTab{"building": building, "target": target},
319 tabOrder: []string{"building", "target"},
320 activeTabID: "building",
321 }
322
323 if _, err := app.keepOnlyVisibleTab("target"); err != nil {
324 t.Fatalf("keepOnlyVisibleTab: %v", err)
325 }
326
327 assertTabIDs(t, app.ListTabs(), "target")
328 if !cancelled {
329 t.Fatal("building tab build was not cancelled")
330 }
331 if !building.removed {
332 t.Fatal("building tab was not marked removed")
333 }
334 }
335
336 func TestConcurrentActivateTopicSerializesSingleSurfacePruning(t *testing.T) {
337 isolateDesktopUserDirs(t)
338 app := NewApp()
339 t.Cleanup(func() { app.shutdown(context.Background()) })
340
341 topics := []string{
342 "topic-a",
343 "topic-b",
344 "topic-c",
345 "topic-d",
346 "topic-e",
347 "topic-f",
348 "topic-g",
349 "topic-h",
350 }
351 start := make(chan struct{})
352 errs := make(chan error, len(topics))
353 var wg sync.WaitGroup
354 for _, topicID := range topics {
355 wg.Add(1)
356 go func(topicID string) {
357 defer wg.Done()
358 <-start
359 _, err := app.ActivateTopic("global", "", topicID, "")
360 errs <- err
361 }(topicID)
362 }
363 close(start)
364 wg.Wait()
365 close(errs)
366
367 for err := range errs {
368 if err != nil {
369 t.Fatalf("ActivateTopic returned error under concurrent navigation: %v", err)
370 }
371 }
372 tabs := app.ListTabs()
373 if len(tabs) != 1 {
374 t.Fatalf("ListTabs returned %d tabs after single-surface navigation, want 1: %+v", len(tabs), tabs)
375 }
376 if !tabs[0].Active {
377 t.Fatalf("remaining tab is not active: %+v", tabs[0])
378 }
379 }
380
381 func TestClearTabBuildCancelKeepsSuccessfulControllerContext(t *testing.T) {
382 ctx, cancel := context.WithCancel(context.Background())
383 defer cancel()
384 tab := &WorkspaceTab{ID: "tab", buildGeneration: 1, buildCancel: cancel}
385 app := &App{}
386
387 app.clearTabBuildCancel(tab, 1, cancel, true)
388
389 if tab.buildCancel != nil {
390 t.Fatal("build cancel was not cleared")
391 }
392 select {
393 case <-ctx.Done():
394 t.Fatal("successful tab build context was cancelled")
395 default:
396 }
397 }
398
399 func TestClearTabBuildCancelCancelsAbandonedBuildContext(t *testing.T) {
400 ctx, cancel := context.WithCancel(context.Background())
401 tab := &WorkspaceTab{ID: "tab", buildGeneration: 1, buildCancel: cancel}
402 app := &App{}
403
404 app.clearTabBuildCancel(tab, 1, cancel, false)
405
406 if tab.buildCancel != nil {
407 t.Fatal("build cancel was not cleared")
408 }
409 select {
410 case <-ctx.Done():
411 default:
412 t.Fatal("abandoned tab build context was not cancelled")
413 }
414 }
415
416 func TestEnsureSessionLeaseSerializesConcurrentSameTabAcquire(t *testing.T) {
417 isolateDesktopUserDirs(t)
418 dir := config.SessionDir()
419 if err := os.MkdirAll(dir, 0o755); err != nil {
420 t.Fatalf("mkdir sessions: %v", err)
421 }
422 path := filepath.Join(dir, "same-tab-concurrent-lease.jsonl")
423 tab := &WorkspaceTab{ID: "tab"}
424 t.Cleanup(tab.releaseSessionLease)
425
426 acquired := make(chan struct{})
427 releaseHook := make(chan struct{})
428 var once sync.Once
429 sessionLeaseAcquireHookForTest = func() {
430 once.Do(func() {
431 close(acquired)
432 <-releaseHook
433 })
434 }
435 t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil })
436
437 firstErr := make(chan error, 1)
438 go func() {
439 firstErr <- tab.ensureSessionLease(path)
440 }()
441
442 select {
443 case <-acquired:
444 case err := <-firstErr:
445 t.Fatalf("first ensureSessionLease returned before hook: %v", err)
446 case <-time.After(2 * time.Second):
447 t.Fatal("first ensureSessionLease did not acquire lease")
448 }
449
450 secondErr := make(chan error, 1)
451 go func() {
452 secondErr <- tab.ensureSessionLease(path)
453 }()
454
455 select {
456 case err := <-secondErr:
457 t.Fatalf("second ensureSessionLease returned while first acquire was unbound: %v", err)
458 case <-time.After(50 * time.Millisecond):
459 }
460
461 close(releaseHook)
462 if err := <-firstErr; err != nil {
463 t.Fatalf("first ensureSessionLease: %v", err)
464 }
465 if err := <-secondErr; err != nil {
466 t.Fatalf("second ensureSessionLease should reuse the tab lease: %v", err)
467 }
468 }
469
470 func TestAttachExistingSessionRuntimeSkipsRemovedTab(t *testing.T) {
471 isolateDesktopUserDirs(t)
472 dir := config.SessionDir()
473 if err := os.MkdirAll(dir, 0o755); err != nil {
474 t.Fatalf("mkdir sessions: %v", err)
475 }
476 path := filepath.Join(dir, "detached.jsonl")
477 key := sessionRuntimeKey(path)
478 detachedCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "detached", Sink: event.Discard})
479 defer detachedCtrl.Close()
480 detached := &WorkspaceTab{
481 ID: "detached",
482 Scope: "global",
483 SessionPath: path,
484 Ctrl: detachedCtrl,
485 Ready: true,
486 SharedHostKey: "detached-host",
487 sink: &tabEventSink{tabID: "detached"},
488 disabledMCP: map[string]ServerView{},
489 }
490 target := &WorkspaceTab{
491 ID: "target",
492 Scope: "global",
493 SessionPath: path,
494 removed: true,
495 sink: &tabEventSink{tabID: "target"},
496 disabledMCP: map[string]ServerView{},
497 }
498 app := &App{
499 tabs: map[string]*WorkspaceTab{},
500 detachedSessions: map[string]*WorkspaceTab{key: detached},
501 }
502
503 if app.attachExistingSessionRuntime(target, path, nil) {
504 t.Fatal("removed tab reattached a detached runtime")
505 }
506 if app.detachedSessions[key] != detached {
507 t.Fatal("detached runtime was removed")
508 }
509 if target.Ctrl != nil || target.Ready {
510 t.Fatal("removed target tab was mutated")
511 }
512 }
513
514 func TestRemoveWorkspaceDropsVisibleTabsAndPersistedEntries(t *testing.T) {
515 isolateDesktopUserDirs(t)
516 projectRoot := t.TempDir()
517 if err := addProject(projectRoot, "Project"); err != nil {
518 t.Fatalf("add project: %v", err)
519 }
520 app := &App{
521 tabs: map[string]*WorkspaceTab{
522 "project": {ID: "project", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "topic-project", Ready: true, disabledMCP: map[string]ServerView{}},
523 "global": {ID: "global", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), TopicID: "topic-global", Ready: true, disabledMCP: map[string]ServerView{}},
524 },
525 tabOrder: []string{"project", "global"},
526 activeTabID: "project",
527 detachedSessions: map[string]*WorkspaceTab{},
528 }
529 app.mu.Lock()
530 app.saveTabsLocked()
531 app.mu.Unlock()
532
533 if err := app.RemoveWorkspace(projectRoot); err != nil {
534 t.Fatalf("RemoveWorkspace: %v", err)
535 }
536 assertTabIDs(t, app.ListTabs(), "global")
537 if got := app.ListWorkspaces(); len(got) != 0 {
538 t.Fatalf("workspaces after remove = %+v, want none", got)
539 }
540 if got := loadTabsFile(); len(got.Tabs) != 1 || got.Tabs[0].ID != "global" {
541 t.Fatalf("persisted tabs after workspace remove = %+v, want only global", got)
542 }
543 }
544
545 func TestRemoveWorkspaceSnapshotsProjectTabBeforeRemovingBinding(t *testing.T) {
546 isolateDesktopUserDirs(t)
547 projectRoot := t.TempDir()
548 if err := addProject(projectRoot, "Project"); err != nil {
549 t.Fatalf("add project: %v", err)
550 }
551 app := &App{
552 tabs: map[string]*WorkspaceTab{
553 "project": {ID: "project", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "topic-project", Ready: true, disabledMCP: map[string]ServerView{}},
554 "global": {ID: "global", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), TopicID: "topic-global", Ready: true, disabledMCP: map[string]ServerView{}},
555 },
556 tabOrder: []string{"project", "global"},
557 activeTabID: "project",
558 detachedSessions: map[string]*WorkspaceTab{},
559 }
560 sawBindingDuringSnapshot := false
561 app.tabs["project"].Ctrl = &snapshotObservingSession{
562 SessionAPI: control.New(control.Options{Label: "project"}),
563 onSnapshot: func() {
564 sawBindingDuringSnapshot = app.tabs["project"] != nil
565 },
566 }
567
568 if err := app.RemoveWorkspace(projectRoot); err != nil {
569 t.Fatalf("RemoveWorkspace: %v", err)
570 }
571 if !sawBindingDuringSnapshot {
572 t.Fatal("project tab was removed from app.tabs before Snapshot")
573 }
574 }
575
576 func TestRemoveWorkspaceSnapshotsProjectTabWithoutAppLock(t *testing.T) {
577 isolateDesktopUserDirs(t)
578 projectRoot := t.TempDir()
579 if err := addProject(projectRoot, "Project"); err != nil {
580 t.Fatalf("add project: %v", err)
581 }
582 app := &App{
583 tabs: map[string]*WorkspaceTab{
584 "project": {ID: "project", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "topic-project", Ready: true, disabledMCP: map[string]ServerView{}},
585 "global": {ID: "global", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), TopicID: "topic-global", Ready: true, disabledMCP: map[string]ServerView{}},
586 },
587 tabOrder: []string{"project", "global"},
588 activeTabID: "project",
589 detachedSessions: map[string]*WorkspaceTab{},
590 }
591 snapshotChecks := make(chan struct{}, 1)
592 app.tabs["project"].Ctrl = &snapshotObservingSession{
593 SessionAPI: control.New(control.Options{Label: "project"}),
594 onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
595 }
596
597 if err := app.RemoveWorkspace(projectRoot); err != nil {
598 t.Fatalf("RemoveWorkspace: %v", err)
599 }
600 select {
601 case <-snapshotChecks:
602 case <-time.After(time.Second):
603 t.Fatal("project tab was not snapshotted before removing workspace")
604 }
605 assertTabIDs(t, app.ListTabs(), "global")
606 }
607
608 func TestRemoveWorkspaceRejectsRunningProjectRuntime(t *testing.T) {
609 isolateDesktopUserDirs(t)
610 projectRoot := t.TempDir()
611 if err := addProject(projectRoot, "Project"); err != nil {
612 t.Fatalf("add project: %v", err)
613 }
614 dir := desktopSessionDir(projectRoot)
615 if err := os.MkdirAll(dir, 0o755); err != nil {
616 t.Fatalf("mkdir project sessions: %v", err)
617 }
618 path := filepath.Join(dir, "running.jsonl")
619 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
620 ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "running", Sink: event.Discard})
621 project := &WorkspaceTab{
622 ID: "project",
623 Scope: "project",
624 WorkspaceRoot: projectRoot,
625 TopicID: "topic-project",
626 SessionPath: path,
627 Ctrl: ctrl,
628 Ready: true,
629 sink: &tabEventSink{tabID: "project"},
630 disabledMCP: map[string]ServerView{},
631 }
632 app := &App{
633 tabs: map[string]*WorkspaceTab{
634 "project": project,
635 "global": {ID: "global", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), Ready: true, disabledMCP: map[string]ServerView{}},
636 },
637 tabOrder: []string{"project", "global"},
638 activeTabID: "project",
639 detachedSessions: map[string]*WorkspaceTab{},
640 }
641
642 ctrl.Submit("block")
643 <-runner.started
644 if err := app.RemoveWorkspace(projectRoot); err == nil {
645 t.Fatal("RemoveWorkspace succeeded with a running project session")
646 }
647 if got := app.ListWorkspaces(); len(got) != 1 || got[0].Path != normalizeProjectRoot(projectRoot) {
648 t.Fatalf("workspaces after rejected remove = %+v, want project retained", got)
649 }
650
651 close(runner.release)
652 waitNotRunning(t, ctrl)
653 ctrl.Close()
654 }
655
656 func TestListTabsRepairsStaleOrderWithoutRacing(t *testing.T) {
657 app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
658 app.tabOrder = []string{"a"}
659
660 var wg sync.WaitGroup
661 start := make(chan struct{})
662 errs := make(chan string, 8)
663 iterations := 100
664 if testing.Short() {
665 iterations = 5
666 }
667 for i := 0; i < 8; i++ {
668 wg.Add(1)
669 go func() {
670 defer wg.Done()
671 <-start
672 for j := 0; j < iterations; j++ {
673 if got := strings.Join(tabIDs(app.ListTabs()), ","); got != "a,b,c" {
674 errs <- got
675 return
676 }
677 }
678 }()
679 }
680 close(start)
681 wg.Wait()
682 close(errs)
683 for got := range errs {
684 t.Fatalf("tab ids = %q, want a,b,c", got)
685 }
686
687 if got := strings.Join(app.tabOrder, ","); got != "a,b,c" {
688 t.Fatalf("repaired tab order = %q, want a,b,c", got)
689 }
690 }
691
692 func TestSaveTabsSkipsOlderSnapshot(t *testing.T) {
693 app := testAppWithOrderedTabs(t, "a", "a", "b")
694
695 app.mu.Lock()
696 dir, oldEntries, oldActiveID, oldVersion := app.saveTabsCollectLocked()
697 app.activeTabID = "b"
698 _, newEntries, newActiveID, newVersion := app.saveTabsCollectLocked()
699 app.mu.Unlock()
700
701 app.saveTabsWrite(dir, newEntries, newActiveID, newVersion)
702 app.saveTabsWrite(dir, oldEntries, oldActiveID, oldVersion)
703
704 if got := loadTabsFile().ActiveTab; got != "b" {
705 t.Fatalf("persisted active tab = %q, want b", got)
706 }
707 }
708
709 func TestReorderTabsPersistsSubmittedOrder(t *testing.T) {
710 app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
711
712 if err := app.ReorderTabs([]string{"c", "a", "b"}); err != nil {
713 t.Fatalf("ReorderTabs: %v", err)
714 }
715 assertTabIDs(t, app.ListTabs(), "c", "a", "b")
716 if got := app.activeTabID; got != "a" {
717 t.Fatalf("active tab = %q, want a", got)
718 }
719 }
720
721 func TestCloseActiveTabChoosesNeighborByOrder(t *testing.T) {
722 app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
723 if err := app.CloseTab("b"); err != nil {
724 t.Fatalf("CloseTab(b): %v", err)
725 }
726 assertTabIDs(t, app.ListTabs(), "a", "c")
727 if got := app.activeTabID; got != "c" {
728 t.Fatalf("active tab after closing middle = %q, want c", got)
729 }
730
731 if err := app.CloseTab("c"); err != nil {
732 t.Fatalf("CloseTab(c): %v", err)
733 }
734 assertTabIDs(t, app.ListTabs(), "a")
735 if got := app.activeTabID; got != "a" {
736 t.Fatalf("active tab after closing last = %q, want a", got)
737 }
738 }
739
740 func TestCloseActiveTabDoesNotResetSurvivorPlannerSession(t *testing.T) {
741 isolateDesktopUserDirs(t)
742 ctrlClosed := control.New(control.Options{Label: "closed"})
743 ctrlSurvivor := &resetCountingSession{SessionAPI: control.New(control.Options{Label: "survivor"})}
744 defer ctrlSurvivor.Close()
745 app := testAppWithOrderedTabs(t, "a", "a", "b")
746 app.tabs["a"].Ctrl = ctrlClosed
747 app.tabs["b"].Ctrl = ctrlSurvivor
748
749 if err := app.CloseTab("a"); err != nil {
750 t.Fatalf("CloseTab: %v", err)
751 }
752 if ctrlSurvivor.resets != 0 {
753 t.Fatalf("survivor planner resets = %d, want 0", ctrlSurvivor.resets)
754 }
755 }
756
757 func TestCloseRunningTabDetachesSessionRuntime(t *testing.T) {
758 isolateDesktopUserDirs(t)
759 dir := config.SessionDir()
760 if err := os.MkdirAll(dir, 0o755); err != nil {
761 t.Fatalf("mkdir sessions: %v", err)
762 }
763 path := filepath.Join(dir, "running.jsonl")
764 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
765 ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "running", Sink: event.Discard})
766 tab := &WorkspaceTab{
767 ID: "running",
768 Scope: "global",
769 WorkspaceRoot: globalTabWorkspaceRoot(),
770 SessionPath: path,
771 Ctrl: ctrl,
772 Ready: true,
773 sink: &tabEventSink{tabID: "running"},
774 disabledMCP: map[string]ServerView{},
775 }
776 app := &App{
777 tabs: map[string]*WorkspaceTab{
778 "running": tab,
779 "other": {ID: "other", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}},
780 },
781 tabOrder: []string{"running", "other"},
782 activeTabID: "running",
783 detachedSessions: map[string]*WorkspaceTab{},
784 }
785
786 ctrl.Submit("block")
787 <-runner.started
788 if err := app.CloseTab("running"); err != nil {
789 t.Fatalf("CloseTab(running): %v", err)
790 }
791 if !ctrl.Running() {
792 t.Fatal("closing a visible tab cancelled its running controller")
793 }
794 if _, ok := app.detachedSessions[sessionRuntimeKey(path)]; !ok {
795 t.Fatalf("detached runtime missing for %q", path)
796 }
797 if tab.sink.ctx != nil {
798 t.Fatal("detached tab sink should stop emitting to the closed view")
799 }
800
801 close(runner.release)
802 waitNotRunning(t, ctrl)
803 ctrl.Close()
804 }
805
806 func TestBuildTabControllerReattachesDetachedSessionRuntime(t *testing.T) {
807 isolateDesktopUserDirs(t)
808 dir := desktopSessionDir(globalTabWorkspaceRoot())
809 if err := os.MkdirAll(dir, 0o755); err != nil {
810 t.Fatalf("mkdir sessions: %v", err)
811 }
812 path := filepath.Join(dir, "reattach.jsonl")
813 oldSink := &tabEventSink{tabID: "old"}
814 oldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "detached", Sink: oldSink})
815 defer oldCtrl.Close()
816 app := NewApp()
817 app.detachedSessions[sessionRuntimeKey(path)] = &WorkspaceTab{
818 ID: "old",
819 Scope: "global",
820 WorkspaceRoot: globalTabWorkspaceRoot(),
821 SessionPath: path,
822 Ctrl: oldCtrl,
823 Label: "detached",
824 Ready: true,
825 sink: oldSink,
826 model: "detached-model",
827 disabledMCP: map[string]ServerView{},
828 }
829 tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "new")
830 tab.SessionPath = path
831 tab.sink = &tabEventSink{tabID: "new", app: app}
832 app.tabs[tab.ID] = tab
833 app.tabOrder = []string{tab.ID}
834 app.activeTabID = tab.ID
835
836 app.buildTabController(tab)
837 if tab.Ctrl != oldCtrl {
838 t.Fatalf("reattached controller = %p, want detached %p", tab.Ctrl, oldCtrl)
839 }
840 if tab.sink != oldSink {
841 t.Fatalf("reattached sink = %p, want detached %p", tab.sink, oldSink)
842 }
843 if oldSink.tabID != "new" {
844 t.Fatalf("sink tab id = %q, want new", oldSink.tabID)
845 }
846 if _, ok := app.detachedSessions[sessionRuntimeKey(path)]; ok {
847 t.Fatal("detached runtime was not removed after reattach")
848 }
849 }
850
851 func TestBuildTabControllerReusesOpenSessionPathRuntime(t *testing.T) {
852 isolateDesktopUserDirs(t)
853 dir := desktopSessionDir(globalTabWorkspaceRoot())
854 if err := os.MkdirAll(dir, 0o755); err != nil {
855 t.Fatalf("mkdir sessions: %v", err)
856 }
857 path := filepath.Join(dir, "open-runtime.jsonl")
858 oldSink := &tabEventSink{tabID: "old"}
859 oldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "open", Sink: oldSink})
860 defer oldCtrl.Close()
861 app := NewApp()
862 oldTab := &WorkspaceTab{
863 ID: "old",
864 Scope: "global",
865 WorkspaceRoot: globalTabWorkspaceRoot(),
866 SessionPath: path,
867 Ctrl: oldCtrl,
868 Label: "open",
869 Ready: true,
870 sink: oldSink,
871 disabledMCP: map[string]ServerView{},
872 }
873 tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "new")
874 tab.SessionPath = path
875 tab.sink = &tabEventSink{tabID: "new", app: app}
876 app.tabs[oldTab.ID] = oldTab
877 app.tabs[tab.ID] = tab
878 app.tabOrder = []string{oldTab.ID, tab.ID}
879 app.activeTabID = tab.ID
880
881 app.buildTabController(tab)
882 if tab.Ctrl != oldCtrl {
883 t.Fatalf("reused controller = %p, want open %p", tab.Ctrl, oldCtrl)
884 }
885 if oldSink.tabID != "new" {
886 t.Fatalf("sink tab id = %q, want new", oldSink.tabID)
887 }
888 if _, ok := app.tabs[oldTab.ID]; ok {
889 t.Fatal("source tab for reused runtime should be removed")
890 }
891 }
892
893 func TestBuildTabControllerBlocksWhenSessionLeaseHeld(t *testing.T) {
894 isolateDesktopUserDirs(t)
895 dir := desktopSessionDir(globalTabWorkspaceRoot())
896 if err := os.MkdirAll(dir, 0o755); err != nil {
897 t.Fatalf("mkdir sessions: %v", err)
898 }
899 path := filepath.Join(dir, "leased.jsonl")
900 if err := os.WriteFile(path, nil, 0o644); err != nil {
901 t.Fatalf("write placeholder session: %v", err)
902 }
903 lease, err := agent.TryAcquireSessionLease(path)
904 if err != nil {
905 t.Fatalf("TryAcquireSessionLease: %v", err)
906 }
907 defer lease.Release()
908
909 app := NewApp()
910 tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "leased")
911 tab.SessionPath = path
912 tab.sink = &tabEventSink{tabID: "leased", app: app}
913 app.tabs[tab.ID] = tab
914 app.tabOrder = []string{tab.ID}
915 app.activeTabID = tab.ID
916
917 app.buildTabController(tab)
918 if tab.Ctrl != nil {
919 t.Fatalf("tab controller = %T, want nil when lease is held", tab.Ctrl)
920 }
921 if tab.Ready {
922 t.Fatal("tab with no controller must not report ready")
923 }
924 app.mu.RLock()
925 runtimeView := app.sessionRuntimeViewLocked(tab)
926 app.mu.RUnlock()
927 if runtimeView.Phase != sessionRuntimeLeaseBlocked {
928 t.Fatalf("runtime phase = %q, want %q", runtimeView.Phase, sessionRuntimeLeaseBlocked)
929 }
930 // The surfaced startup error is the sanitized busy message: the raw lease
931 // error would leak the session path and the holder's host-pid-writer id
932 // into the topbar banner.
933 if !strings.Contains(tab.StartupErr, "already open in another Reasonix window") {
934 t.Fatalf("startup error = %q, want user-facing busy message", tab.StartupErr)
935 }
936 if strings.Contains(tab.StartupErr, agent.ErrSessionLeaseHeld.Error()) ||
937 strings.Contains(tab.StartupErr, path) {
938 t.Fatalf("startup error leaked raw lease details: %q", tab.StartupErr)
939 }
940 }
941
942 func TestDeferredStartupRetryBuildsAfterLeaseRelease(t *testing.T) {
943 isolateDesktopUserDirs(t)
944 prevInterval := deferredRebuildRetryInterval
945 deferredRebuildRetryInterval = 20 * time.Millisecond
946 t.Cleanup(func() { deferredRebuildRetryInterval = prevInterval })
947
948 dir := desktopSessionDir(globalTabWorkspaceRoot())
949 if err := os.MkdirAll(dir, 0o755); err != nil {
950 t.Fatalf("mkdir sessions: %v", err)
951 }
952 path := filepath.Join(dir, "startup-retry.jsonl")
953 if err := os.WriteFile(path, nil, 0o644); err != nil {
954 t.Fatalf("write placeholder session: %v", err)
955 }
956 lease, err := agent.TryAcquireSessionLease(path)
957 if err != nil {
958 t.Fatalf("TryAcquireSessionLease: %v", err)
959 }
960 released := false
961 t.Cleanup(func() {
962 if !released {
963 lease.Release()
964 }
965 })
966
967 app := NewApp()
968 app.ctx = context.Background()
969 app.readyHook = func() {}
970 app.enableDeferredRebuildRetry()
971 t.Cleanup(app.stopDeferredRebuildRetry)
972 tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "startup_retry")
973 tab.SessionPath = path
974 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
975 installNoopRuntimeEvents(app, tab.sink)
976 app.tabs[tab.ID] = tab
977 app.tabOrder = []string{tab.ID}
978 app.activeTabID = tab.ID
979 t.Cleanup(func() {
980 if ctrl := app.controllerForTab(tab); ctrl != nil {
981 ctrl.Close()
982 }
983 tab.releaseSessionLease()
984 })
985
986 app.buildTabController(tab)
987 if tab.Ctrl != nil {
988 t.Fatalf("controller = %T, want nil while external lease is held", tab.Ctrl)
989 }
990 if !tab.StartupErrLeaseHeld {
991 t.Fatalf("startup retry flag = false, startup err = %q", tab.StartupErr)
992 }
993 if !app.deferredRebuildPending(tab.ID) {
994 t.Fatal("startup retry was not scheduled while the lease was held")
995 }
996
997 lease.Release()
998 released = true
999
1000 deadline := time.Now().Add(10 * time.Second)
1001 for time.Now().Before(deadline) {
1002 if !app.deferredRebuildPending(tab.ID) && app.controllerForTab(tab) != nil {
1003 break
1004 }
1005 time.Sleep(10 * time.Millisecond)
1006 }
1007 if app.deferredRebuildPending(tab.ID) {
1008 t.Fatal("startup retry is still pending after the lease was released")
1009 }
1010 if ctrl := app.controllerForTab(tab); ctrl == nil {
1011 t.Fatal("controller was not rebuilt after the lease was released")
1012 }
1013 if tab.StartupErr != "" || tab.StartupErrLeaseHeld {
1014 t.Fatalf("startup error after retry = %q retryable=%v, want cleared", tab.StartupErr, tab.StartupErrLeaseHeld)
1015 }
1016 }
1017
1018 func TestTabAndCtrlByIDRecoversStartupLeaseBeforeAction(t *testing.T) {
1019 isolateDesktopUserDirs(t)
1020 dir := desktopSessionDir(globalTabWorkspaceRoot())
1021 if err := os.MkdirAll(dir, 0o755); err != nil {
1022 t.Fatalf("mkdir sessions: %v", err)
1023 }
1024 path := filepath.Join(dir, "startup-before-action.jsonl")
1025 if err := os.WriteFile(path, nil, 0o644); err != nil {
1026 t.Fatalf("write placeholder session: %v", err)
1027 }
1028 lease, err := agent.TryAcquireSessionLease(path)
1029 if err != nil {
1030 t.Fatalf("TryAcquireSessionLease: %v", err)
1031 }
1032 released := false
1033 t.Cleanup(func() {
1034 if !released {
1035 lease.Release()
1036 }
1037 })
1038
1039 app := NewApp()
1040 app.ctx = context.Background()
1041 app.readyHook = func() {}
1042 tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "startup_action")
1043 tab.SessionPath = path
1044 tab.sink = &tabEventSink{tabID: tab.ID, app: app}
1045 installNoopRuntimeEvents(app, tab.sink)
1046 app.tabs[tab.ID] = tab
1047 app.tabOrder = []string{tab.ID}
1048 app.activeTabID = tab.ID
1049 t.Cleanup(func() {
1050 if ctrl := app.controllerForTab(tab); ctrl != nil {
1051 ctrl.Close()
1052 }
1053 tab.releaseSessionLease()
1054 })
1055
1056 app.buildTabController(tab)
1057 if !tab.StartupErrLeaseHeld {
1058 t.Fatalf("startup retry flag = false, startup err = %q", tab.StartupErr)
1059 }
1060 lease.Release()
1061 released = true
1062
1063 gotTab, ctrl := app.tabAndCtrlByID(tab.ID)
1064 if gotTab != tab {
1065 t.Fatalf("tabAndCtrlByID tab = %p, want %p", gotTab, tab)
1066 }
1067 if ctrl == nil {
1068 t.Fatal("tabAndCtrlByID did not rebuild the controller before returning")
1069 }
1070 if app.deferredRebuildPending(tab.ID) {
1071 t.Fatal("startup retry remained pending after synchronous recovery")
1072 }
1073 if tab.StartupErr != "" || tab.StartupErrLeaseHeld {
1074 t.Fatalf("startup error after synchronous recovery = %q retryable=%v, want cleared", tab.StartupErr, tab.StartupErrLeaseHeld)
1075 }
1076 }
1077
1078 func TestOpenGlobalTabResolvesTopicToLatestSessionRuntime(t *testing.T) {
1079 isolateDesktopUserDirs(t)
1080 dir := desktopSessionDir(globalTabWorkspaceRoot())
1081 if err := os.MkdirAll(dir, 0o755); err != nil {
1082 t.Fatalf("mkdir sessions: %v", err)
1083 }
1084 topicID := "topic_multi_session"
1085 topicTitle := "Multi session topic"
1086 oldPath := writeTopicSessionWithPrompt(t, dir, "old.jsonl", topicID, topicTitle, "", "old session prompt", time.Now().Add(-2*time.Hour))
1087 newPath := writeTopicSessionWithPrompt(t, dir, "new.jsonl", topicID, topicTitle, "", "new session prompt", time.Now().Add(-time.Hour))
1088
1089 runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
1090 oldSink := &tabEventSink{tabID: "topic-tab"}
1091 oldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: oldSink})
1092 defer oldCtrl.Close()
1093 app := NewApp()
1094 oldSink.app = app
1095 oldTab := &WorkspaceTab{
1096 ID: "topic-tab",
1097 Scope: "global",
1098 WorkspaceRoot: globalTabWorkspaceRoot(),
1099 TopicID: topicID,
1100 TopicTitle: topicTitle,
1101 SessionPath: oldPath,
1102 Ctrl: oldCtrl,
1103 Ready: true,
1104 sink: oldSink,
1105 disabledMCP: map[string]ServerView{},
1106 }
1107 app.tabs[oldTab.ID] = oldTab
1108 app.tabOrder = []string{oldTab.ID}
1109 app.activeTabID = oldTab.ID
1110
1111 oldCtrl.Submit("keep old runtime running")
1112 <-runner.started
1113
1114 meta, err := app.OpenGlobalTab(topicID)
1115 if err != nil {
1116 t.Fatalf("OpenGlobalTab: %v", err)
1117 }
1118 if meta.ID != oldTab.ID {
1119 t.Fatalf("OpenGlobalTab reused tab %q, want %q", meta.ID, oldTab.ID)
1120 }
1121 if !oldCtrl.Running() {
1122 t.Fatal("old session runtime was cancelled while selecting topic")
1123 }
1124 if detached := app.detachedSessions[sessionRuntimeKey(oldPath)]; detached == nil || detached.Ctrl != oldCtrl {
1125 t.Fatalf("old runtime was not detached under its session path: %+v", detached)
1126 }
1127 visible := app.tabs[oldTab.ID]
1128 if visible == nil || visible.Ctrl == nil {
1129 t.Fatalf("visible tab was not rebuilt: %+v", visible)
1130 }
1131 if got := filepath.Clean(visible.Ctrl.SessionPath()); got != filepath.Clean(newPath) {
1132 t.Fatalf("visible session path = %q, want %q", got, newPath)
1133 }
1134 history := visible.Ctrl.History()
1135 if len(history) != 2 || string(history[0].Role) != "system" || strings.TrimSpace(history[0].Content) == "" ||
1136 string(history[1].Role) != "user" || history[1].Content != "new session prompt" {
1137 t.Fatalf("visible history = %+v, want fresh system prompt and latest session prompt", history)
1138 }
1139
1140 close(runner.release)
1141 waitNotRunning(t, oldCtrl)
1142 }
1143
1144 func TestReorderTabsRejectsInvalidOrder(t *testing.T) {
1145 app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
1146 for name, order := range map[string][]string{
1147 "missing": {"a", "b"},
1148 "unknown": {"a", "b", "missing"},
1149 "duplicate": {"a", "b", "b"},
1150 } {
1151 t.Run(name, func(t *testing.T) {
1152 if err := app.ReorderTabs(order); err == nil {
1153 t.Fatalf("ReorderTabs(%v) succeeded, want error", order)
1154 }
1155 })
1156 }
1157 assertTabIDs(t, app.ListTabs(), "a", "b", "c")
1158 }
1159
1160 func TestNewUniqueTabIDLockedUsesFreshRandomID(t *testing.T) {
1161 app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
1162
1163 app.mu.Lock()
1164 got := app.newUniqueTabIDLocked()
1165 app.mu.Unlock()
1166 if _, exists := app.tabs[got]; exists {
1167 t.Fatalf("newUniqueTabIDLocked returned existing id %q", got)
1168 }
1169 if !strings.HasPrefix(got, "tab_") {
1170 t.Fatalf("tab id = %q, want tab_ prefix", got)
1171 }
1172 if len(got) != len("tab_")+32 {
1173 t.Fatalf("tab id = %q, length %d, want 36", got, len(got))
1174 }
1175 }
1176
1177 func TestRestoredTabIDLockedReplacesEmptyAndDuplicateIDs(t *testing.T) {
1178 app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
1179
1180 app.mu.Lock()
1181 kept := app.restoredTabIDLocked("d")
1182 duplicate := app.restoredTabIDLocked("a")
1183 empty := app.restoredTabIDLocked(" ")
1184 app.mu.Unlock()
1185
1186 if kept != "d" {
1187 t.Fatalf("restored unique id = %q, want d", kept)
1188 }
1189 for name, got := range map[string]string{"duplicate": duplicate, "empty": empty} {
1190 if _, exists := app.tabs[got]; exists {
1191 t.Fatalf("%s restored id %q already exists", name, got)
1192 }
1193 if !strings.HasPrefix(got, "tab_") {
1194 t.Fatalf("%s restored id = %q, want tab_ prefix", name, got)
1195 }
1196 }
1197 }
1198
1198 lines GO