Files
2026-07-13 13:00:08 +08:00

1191 lines
36 KiB
Go

package main
import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
)
func testAppWithOrderedTabs(t *testing.T, active string, ids ...string) *App {
t.Helper()
isolateDesktopUserDirs(t)
tabs := make(map[string]*WorkspaceTab, len(ids))
for _, id := range ids {
tabs[id] = &WorkspaceTab{
ID: id,
Scope: "global",
TopicID: "topic_" + id,
TopicTitle: id,
Ready: true,
disabledMCP: map[string]ServerView{},
}
}
return &App{tabs: tabs, tabOrder: append([]string(nil), ids...), activeTabID: active}
}
func installNoopRuntimeEvents(app *App, sinks ...*tabEventSink) {
emit := func(context.Context, string, ...interface{}) {}
if app != nil {
app.runtimeEvents.emit = emit
}
for _, sink := range sinks {
if sink != nil {
sink.runtimeEvents.emit = emit
}
}
}
func tabIDs(tabs []TabMeta) []string {
ids := make([]string, 0, len(tabs))
for _, tab := range tabs {
ids = append(ids, tab.ID)
}
return ids
}
func assertTabIDs(t *testing.T, got []TabMeta, want ...string) {
t.Helper()
gotIDs := tabIDs(got)
if len(gotIDs) != len(want) {
t.Fatalf("tab ids = %v, want %v", gotIDs, want)
}
for i := range want {
if gotIDs[i] != want[i] {
t.Fatalf("tab ids = %v, want %v", gotIDs, want)
}
}
}
type resetCountingSession struct {
control.SessionAPI
resets int
}
func (s *resetCountingSession) ResetPlannerSession() {
s.resets++
}
type snapshotObservingSession struct {
control.SessionAPI
onSnapshot func()
}
func (s *snapshotObservingSession) Snapshot() error {
if s.onSnapshot != nil {
s.onSnapshot()
}
return nil
}
func expectAppMutexAvailableDuringSnapshot(t *testing.T, app *App, checks chan<- struct{}) func() {
t.Helper()
return func() {
acquired := make(chan struct{})
go func() {
app.mu.Lock()
app.mu.Unlock() //nolint:staticcheck // probe: lock must be immediately acquirable
close(acquired)
}()
select {
case <-acquired:
case <-time.After(500 * time.Millisecond):
t.Error("Snapshot ran while holding app mutex")
}
if checks == nil {
return
}
select {
case checks <- struct{}{}:
default:
}
}
}
func TestListTabsKeepsExplicitOrderWhenActiveChanges(t *testing.T) {
app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
assertTabIDs(t, app.ListTabs(), "a", "b", "c")
if err := app.SetActiveTab("c"); err != nil {
t.Fatalf("SetActiveTab: %v", err)
}
assertTabIDs(t, app.ListTabs(), "a", "b", "c")
if got := app.activeTabID; got != "c" {
t.Fatalf("active tab = %q, want c", got)
}
}
func TestSetActiveTabDoesNotResetPlannerSession(t *testing.T) {
isolateDesktopUserDirs(t)
ctrlA := &resetCountingSession{SessionAPI: control.New(control.Options{Label: "a"})}
ctrlB := &resetCountingSession{SessionAPI: control.New(control.Options{Label: "b"})}
defer ctrlA.Close()
defer ctrlB.Close()
app := testAppWithOrderedTabs(t, "a", "a", "b")
app.tabs["a"].Ctrl = ctrlA
app.tabs["b"].Ctrl = ctrlB
if err := app.SetActiveTab("b"); err != nil {
t.Fatalf("SetActiveTab: %v", err)
}
if ctrlA.resets != 0 || ctrlB.resets != 0 {
t.Fatalf("planner resets on tab activation = active:%d inactive:%d, want 0", ctrlA.resets, ctrlB.resets)
}
}
func TestSingleSurfaceTabsFileKeepsActiveEntry(t *testing.T) {
f := desktopTabsFile{
Tabs: []desktopTabEntry{
{ID: "a", Scope: "global", TopicID: "topic-a"},
{ID: "b", Scope: "project", WorkspaceRoot: "/tmp/project", TopicID: "topic-b"},
{ID: "c", Scope: "global", TopicID: "topic-c"},
},
ActiveTab: "b",
}
got := singleSurfaceTabsFile(f)
if len(got.Tabs) != 1 || got.Tabs[0].ID != "b" || got.ActiveTab != "b" {
t.Fatalf("single-surface tabs = %+v, want only active b", got)
}
}
func TestSetDesktopLayoutStyleAppliesPolicyAfterWorkspaceAlias(t *testing.T) {
app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
if err := app.SetDesktopLayoutStyle("workspace"); err != nil {
t.Fatalf("SetDesktopLayoutStyle(workspace): %v", err)
}
assertTabIDs(t, app.ListTabs(), "b")
if got := loadTabsFile(); len(got.Tabs) != 1 || got.Tabs[0].ID != "b" || got.ActiveTab != "b" {
t.Fatalf("persisted tabs after workspace alias = %+v, want only active b", got)
}
}
func TestKeepOnlyVisibleTabDetachesRunningHiddenTab(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "running.jsonl")
runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "running", Sink: event.Discard})
running := &WorkspaceTab{
ID: "running",
Scope: "global",
WorkspaceRoot: globalTabWorkspaceRoot(),
SessionPath: path,
Ctrl: ctrl,
Ready: true,
sink: &tabEventSink{tabID: "running"},
disabledMCP: map[string]ServerView{},
}
target := &WorkspaceTab{ID: "target", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}}
app := &App{
tabs: map[string]*WorkspaceTab{"running": running, "target": target},
tabOrder: []string{"running", "target"},
activeTabID: "running",
detachedSessions: map[string]*WorkspaceTab{},
}
ctrl.Submit("block")
<-runner.started
if _, err := app.keepOnlyVisibleTab("target"); err != nil {
t.Fatalf("keepOnlyVisibleTab: %v", err)
}
assertTabIDs(t, app.ListTabs(), "target")
if !ctrl.Running() {
t.Fatal("single-surface pruning cancelled a running controller")
}
if _, ok := app.detachedSessions[sessionRuntimeKey(path)]; !ok {
t.Fatalf("detached runtime missing for %q", path)
}
if got := loadTabsFile(); len(got.Tabs) != 1 || got.Tabs[0].ID != "target" {
t.Fatalf("persisted tabs after pruning = %+v, want only target", got)
}
close(runner.release)
waitNotRunning(t, ctrl)
ctrl.Close()
}
func TestKeepOnlyVisibleTabSnapshotsHiddenTabWithoutAppLock(t *testing.T) {
isolateDesktopUserDirs(t)
app := &App{
tabs: map[string]*WorkspaceTab{},
tabOrder: []string{"hidden", "target"},
activeTabID: "hidden",
}
snapshotChecks := make(chan struct{}, 2)
hiddenCtrl := &snapshotObservingSession{
SessionAPI: control.New(control.Options{Label: "hidden"}),
onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
}
hidden := &WorkspaceTab{
ID: "hidden",
Scope: "global",
WorkspaceRoot: globalTabWorkspaceRoot(),
TopicID: "topic-hidden",
Ctrl: hiddenCtrl,
Ready: true,
sink: &tabEventSink{tabID: "hidden"},
disabledMCP: map[string]ServerView{},
}
target := &WorkspaceTab{
ID: "target",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
app.tabs["hidden"] = hidden
app.tabs["target"] = target
if _, err := app.keepOnlyVisibleTab("target"); err != nil {
t.Fatalf("keepOnlyVisibleTab: %v", err)
}
select {
case <-snapshotChecks:
case <-time.After(time.Second):
t.Fatal("hidden tab was not snapshotted before pruning")
}
assertTabIDs(t, app.ListTabs(), "target")
}
func TestCloseTabSnapshotsWithoutAppLock(t *testing.T) {
isolateDesktopUserDirs(t)
app := &App{
tabs: map[string]*WorkspaceTab{},
tabOrder: []string{"closing", "survivor"},
activeTabID: "closing",
}
snapshotChecks := make(chan struct{}, 1)
closingCtrl := &snapshotObservingSession{
SessionAPI: control.New(control.Options{Label: "closing"}),
onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
}
closing := &WorkspaceTab{
ID: "closing",
Scope: "global",
WorkspaceRoot: globalTabWorkspaceRoot(),
TopicID: "topic-closing",
Ctrl: closingCtrl,
Ready: true,
sink: &tabEventSink{tabID: "closing"},
disabledMCP: map[string]ServerView{},
}
survivor := &WorkspaceTab{
ID: "survivor",
Scope: "global",
Ready: true,
disabledMCP: map[string]ServerView{},
}
app.tabs["closing"] = closing
app.tabs["survivor"] = survivor
if err := app.CloseTab("closing"); err != nil {
t.Fatalf("CloseTab: %v", err)
}
select {
case <-snapshotChecks:
case <-time.After(time.Second):
t.Fatal("closing tab was not snapshotted")
}
assertTabIDs(t, app.ListTabs(), "survivor")
}
func TestKeepOnlyVisibleTabCancelsBuildingHiddenTab(t *testing.T) {
isolateDesktopUserDirs(t)
cancelled := false
building := &WorkspaceTab{
ID: "building",
Scope: "global",
Ready: false,
buildCancel: func() { cancelled = true },
sink: &tabEventSink{tabID: "building"},
disabledMCP: map[string]ServerView{},
}
target := &WorkspaceTab{ID: "target", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}}
app := &App{
tabs: map[string]*WorkspaceTab{"building": building, "target": target},
tabOrder: []string{"building", "target"},
activeTabID: "building",
}
if _, err := app.keepOnlyVisibleTab("target"); err != nil {
t.Fatalf("keepOnlyVisibleTab: %v", err)
}
assertTabIDs(t, app.ListTabs(), "target")
if !cancelled {
t.Fatal("building tab build was not cancelled")
}
if !building.removed {
t.Fatal("building tab was not marked removed")
}
}
func TestConcurrentActivateTopicSerializesSingleSurfacePruning(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
t.Cleanup(func() { app.shutdown(context.Background()) })
topics := []string{
"topic-a",
"topic-b",
"topic-c",
"topic-d",
"topic-e",
"topic-f",
"topic-g",
"topic-h",
}
start := make(chan struct{})
errs := make(chan error, len(topics))
var wg sync.WaitGroup
for _, topicID := range topics {
wg.Add(1)
go func(topicID string) {
defer wg.Done()
<-start
_, err := app.ActivateTopic("global", "", topicID, "")
errs <- err
}(topicID)
}
close(start)
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("ActivateTopic returned error under concurrent navigation: %v", err)
}
}
tabs := app.ListTabs()
if len(tabs) != 1 {
t.Fatalf("ListTabs returned %d tabs after single-surface navigation, want 1: %+v", len(tabs), tabs)
}
if !tabs[0].Active {
t.Fatalf("remaining tab is not active: %+v", tabs[0])
}
}
func TestClearTabBuildCancelKeepsSuccessfulControllerContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
tab := &WorkspaceTab{ID: "tab", buildGeneration: 1, buildCancel: cancel}
app := &App{}
app.clearTabBuildCancel(tab, 1, cancel, true)
if tab.buildCancel != nil {
t.Fatal("build cancel was not cleared")
}
select {
case <-ctx.Done():
t.Fatal("successful tab build context was cancelled")
default:
}
}
func TestClearTabBuildCancelCancelsAbandonedBuildContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
tab := &WorkspaceTab{ID: "tab", buildGeneration: 1, buildCancel: cancel}
app := &App{}
app.clearTabBuildCancel(tab, 1, cancel, false)
if tab.buildCancel != nil {
t.Fatal("build cancel was not cleared")
}
select {
case <-ctx.Done():
default:
t.Fatal("abandoned tab build context was not cancelled")
}
}
func TestEnsureSessionLeaseSerializesConcurrentSameTabAcquire(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "same-tab-concurrent-lease.jsonl")
tab := &WorkspaceTab{ID: "tab"}
t.Cleanup(tab.releaseSessionLease)
acquired := make(chan struct{})
releaseHook := make(chan struct{})
var once sync.Once
sessionLeaseAcquireHookForTest = func() {
once.Do(func() {
close(acquired)
<-releaseHook
})
}
t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil })
firstErr := make(chan error, 1)
go func() {
firstErr <- tab.ensureSessionLease(path)
}()
select {
case <-acquired:
case err := <-firstErr:
t.Fatalf("first ensureSessionLease returned before hook: %v", err)
case <-time.After(2 * time.Second):
t.Fatal("first ensureSessionLease did not acquire lease")
}
secondErr := make(chan error, 1)
go func() {
secondErr <- tab.ensureSessionLease(path)
}()
select {
case err := <-secondErr:
t.Fatalf("second ensureSessionLease returned while first acquire was unbound: %v", err)
case <-time.After(50 * time.Millisecond):
}
close(releaseHook)
if err := <-firstErr; err != nil {
t.Fatalf("first ensureSessionLease: %v", err)
}
if err := <-secondErr; err != nil {
t.Fatalf("second ensureSessionLease should reuse the tab lease: %v", err)
}
}
func TestAttachExistingSessionRuntimeSkipsRemovedTab(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "detached.jsonl")
key := sessionRuntimeKey(path)
detachedCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "detached", Sink: event.Discard})
defer detachedCtrl.Close()
detached := &WorkspaceTab{
ID: "detached",
Scope: "global",
SessionPath: path,
Ctrl: detachedCtrl,
Ready: true,
SharedHostKey: "detached-host",
sink: &tabEventSink{tabID: "detached"},
disabledMCP: map[string]ServerView{},
}
target := &WorkspaceTab{
ID: "target",
Scope: "global",
SessionPath: path,
removed: true,
sink: &tabEventSink{tabID: "target"},
disabledMCP: map[string]ServerView{},
}
app := &App{
tabs: map[string]*WorkspaceTab{},
detachedSessions: map[string]*WorkspaceTab{key: detached},
}
if app.attachExistingSessionRuntime(target, path, nil) {
t.Fatal("removed tab reattached a detached runtime")
}
if app.detachedSessions[key] != detached {
t.Fatal("detached runtime was removed")
}
if target.Ctrl != nil || target.Ready {
t.Fatal("removed target tab was mutated")
}
}
func TestRemoveWorkspaceDropsVisibleTabsAndPersistedEntries(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
if err := addProject(projectRoot, "Project"); err != nil {
t.Fatalf("add project: %v", err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "topic-project", Ready: true, disabledMCP: map[string]ServerView{}},
"global": {ID: "global", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), TopicID: "topic-global", Ready: true, disabledMCP: map[string]ServerView{}},
},
tabOrder: []string{"project", "global"},
activeTabID: "project",
detachedSessions: map[string]*WorkspaceTab{},
}
app.mu.Lock()
app.saveTabsLocked()
app.mu.Unlock()
if err := app.RemoveWorkspace(projectRoot); err != nil {
t.Fatalf("RemoveWorkspace: %v", err)
}
assertTabIDs(t, app.ListTabs(), "global")
if got := app.ListWorkspaces(); len(got) != 0 {
t.Fatalf("workspaces after remove = %+v, want none", got)
}
if got := loadTabsFile(); len(got.Tabs) != 1 || got.Tabs[0].ID != "global" {
t.Fatalf("persisted tabs after workspace remove = %+v, want only global", got)
}
}
func TestRemoveWorkspaceSnapshotsProjectTabBeforeRemovingBinding(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
if err := addProject(projectRoot, "Project"); err != nil {
t.Fatalf("add project: %v", err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "topic-project", Ready: true, disabledMCP: map[string]ServerView{}},
"global": {ID: "global", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), TopicID: "topic-global", Ready: true, disabledMCP: map[string]ServerView{}},
},
tabOrder: []string{"project", "global"},
activeTabID: "project",
detachedSessions: map[string]*WorkspaceTab{},
}
sawBindingDuringSnapshot := false
app.tabs["project"].Ctrl = &snapshotObservingSession{
SessionAPI: control.New(control.Options{Label: "project"}),
onSnapshot: func() {
sawBindingDuringSnapshot = app.tabs["project"] != nil
},
}
if err := app.RemoveWorkspace(projectRoot); err != nil {
t.Fatalf("RemoveWorkspace: %v", err)
}
if !sawBindingDuringSnapshot {
t.Fatal("project tab was removed from app.tabs before Snapshot")
}
}
func TestRemoveWorkspaceSnapshotsProjectTabWithoutAppLock(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
if err := addProject(projectRoot, "Project"); err != nil {
t.Fatalf("add project: %v", err)
}
app := &App{
tabs: map[string]*WorkspaceTab{
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "topic-project", Ready: true, disabledMCP: map[string]ServerView{}},
"global": {ID: "global", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), TopicID: "topic-global", Ready: true, disabledMCP: map[string]ServerView{}},
},
tabOrder: []string{"project", "global"},
activeTabID: "project",
detachedSessions: map[string]*WorkspaceTab{},
}
snapshotChecks := make(chan struct{}, 1)
app.tabs["project"].Ctrl = &snapshotObservingSession{
SessionAPI: control.New(control.Options{Label: "project"}),
onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
}
if err := app.RemoveWorkspace(projectRoot); err != nil {
t.Fatalf("RemoveWorkspace: %v", err)
}
select {
case <-snapshotChecks:
case <-time.After(time.Second):
t.Fatal("project tab was not snapshotted before removing workspace")
}
assertTabIDs(t, app.ListTabs(), "global")
}
func TestRemoveWorkspaceRejectsRunningProjectRuntime(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
if err := addProject(projectRoot, "Project"); err != nil {
t.Fatalf("add project: %v", err)
}
dir := desktopSessionDir(projectRoot)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir project sessions: %v", err)
}
path := filepath.Join(dir, "running.jsonl")
runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "running", Sink: event.Discard})
project := &WorkspaceTab{
ID: "project",
Scope: "project",
WorkspaceRoot: projectRoot,
TopicID: "topic-project",
SessionPath: path,
Ctrl: ctrl,
Ready: true,
sink: &tabEventSink{tabID: "project"},
disabledMCP: map[string]ServerView{},
}
app := &App{
tabs: map[string]*WorkspaceTab{
"project": project,
"global": {ID: "global", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), Ready: true, disabledMCP: map[string]ServerView{}},
},
tabOrder: []string{"project", "global"},
activeTabID: "project",
detachedSessions: map[string]*WorkspaceTab{},
}
ctrl.Submit("block")
<-runner.started
if err := app.RemoveWorkspace(projectRoot); err == nil {
t.Fatal("RemoveWorkspace succeeded with a running project session")
}
if got := app.ListWorkspaces(); len(got) != 1 || got[0].Path != normalizeProjectRoot(projectRoot) {
t.Fatalf("workspaces after rejected remove = %+v, want project retained", got)
}
close(runner.release)
waitNotRunning(t, ctrl)
ctrl.Close()
}
func TestListTabsRepairsStaleOrderWithoutRacing(t *testing.T) {
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
app.tabOrder = []string{"a"}
var wg sync.WaitGroup
start := make(chan struct{})
errs := make(chan string, 8)
iterations := 100
if testing.Short() {
iterations = 5
}
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
for j := 0; j < iterations; j++ {
if got := strings.Join(tabIDs(app.ListTabs()), ","); got != "a,b,c" {
errs <- got
return
}
}
}()
}
close(start)
wg.Wait()
close(errs)
for got := range errs {
t.Fatalf("tab ids = %q, want a,b,c", got)
}
if got := strings.Join(app.tabOrder, ","); got != "a,b,c" {
t.Fatalf("repaired tab order = %q, want a,b,c", got)
}
}
func TestSaveTabsSkipsOlderSnapshot(t *testing.T) {
app := testAppWithOrderedTabs(t, "a", "a", "b")
app.mu.Lock()
dir, oldEntries, oldActiveID, oldVersion := app.saveTabsCollectLocked()
app.activeTabID = "b"
_, newEntries, newActiveID, newVersion := app.saveTabsCollectLocked()
app.mu.Unlock()
app.saveTabsWrite(dir, newEntries, newActiveID, newVersion)
app.saveTabsWrite(dir, oldEntries, oldActiveID, oldVersion)
if got := loadTabsFile().ActiveTab; got != "b" {
t.Fatalf("persisted active tab = %q, want b", got)
}
}
func TestReorderTabsPersistsSubmittedOrder(t *testing.T) {
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
if err := app.ReorderTabs([]string{"c", "a", "b"}); err != nil {
t.Fatalf("ReorderTabs: %v", err)
}
assertTabIDs(t, app.ListTabs(), "c", "a", "b")
if got := app.activeTabID; got != "a" {
t.Fatalf("active tab = %q, want a", got)
}
}
func TestCloseActiveTabChoosesNeighborByOrder(t *testing.T) {
app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
if err := app.CloseTab("b"); err != nil {
t.Fatalf("CloseTab(b): %v", err)
}
assertTabIDs(t, app.ListTabs(), "a", "c")
if got := app.activeTabID; got != "c" {
t.Fatalf("active tab after closing middle = %q, want c", got)
}
if err := app.CloseTab("c"); err != nil {
t.Fatalf("CloseTab(c): %v", err)
}
assertTabIDs(t, app.ListTabs(), "a")
if got := app.activeTabID; got != "a" {
t.Fatalf("active tab after closing last = %q, want a", got)
}
}
func TestCloseActiveTabDoesNotResetSurvivorPlannerSession(t *testing.T) {
isolateDesktopUserDirs(t)
ctrlClosed := control.New(control.Options{Label: "closed"})
ctrlSurvivor := &resetCountingSession{SessionAPI: control.New(control.Options{Label: "survivor"})}
defer ctrlSurvivor.Close()
app := testAppWithOrderedTabs(t, "a", "a", "b")
app.tabs["a"].Ctrl = ctrlClosed
app.tabs["b"].Ctrl = ctrlSurvivor
if err := app.CloseTab("a"); err != nil {
t.Fatalf("CloseTab: %v", err)
}
if ctrlSurvivor.resets != 0 {
t.Fatalf("survivor planner resets = %d, want 0", ctrlSurvivor.resets)
}
}
func TestCloseRunningTabDetachesSessionRuntime(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "running.jsonl")
runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "running", Sink: event.Discard})
tab := &WorkspaceTab{
ID: "running",
Scope: "global",
WorkspaceRoot: globalTabWorkspaceRoot(),
SessionPath: path,
Ctrl: ctrl,
Ready: true,
sink: &tabEventSink{tabID: "running"},
disabledMCP: map[string]ServerView{},
}
app := &App{
tabs: map[string]*WorkspaceTab{
"running": tab,
"other": {ID: "other", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}},
},
tabOrder: []string{"running", "other"},
activeTabID: "running",
detachedSessions: map[string]*WorkspaceTab{},
}
ctrl.Submit("block")
<-runner.started
if err := app.CloseTab("running"); err != nil {
t.Fatalf("CloseTab(running): %v", err)
}
if !ctrl.Running() {
t.Fatal("closing a visible tab cancelled its running controller")
}
if _, ok := app.detachedSessions[sessionRuntimeKey(path)]; !ok {
t.Fatalf("detached runtime missing for %q", path)
}
if tab.sink.ctx != nil {
t.Fatal("detached tab sink should stop emitting to the closed view")
}
close(runner.release)
waitNotRunning(t, ctrl)
ctrl.Close()
}
func TestBuildTabControllerReattachesDetachedSessionRuntime(t *testing.T) {
isolateDesktopUserDirs(t)
dir := desktopSessionDir(globalTabWorkspaceRoot())
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "reattach.jsonl")
oldSink := &tabEventSink{tabID: "old"}
oldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "detached", Sink: oldSink})
defer oldCtrl.Close()
app := NewApp()
app.detachedSessions[sessionRuntimeKey(path)] = &WorkspaceTab{
ID: "old",
Scope: "global",
WorkspaceRoot: globalTabWorkspaceRoot(),
SessionPath: path,
Ctrl: oldCtrl,
Label: "detached",
Ready: true,
sink: oldSink,
model: "detached-model",
disabledMCP: map[string]ServerView{},
}
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "new")
tab.SessionPath = path
tab.sink = &tabEventSink{tabID: "new", app: app}
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
app.buildTabController(tab)
if tab.Ctrl != oldCtrl {
t.Fatalf("reattached controller = %p, want detached %p", tab.Ctrl, oldCtrl)
}
if tab.sink != oldSink {
t.Fatalf("reattached sink = %p, want detached %p", tab.sink, oldSink)
}
if oldSink.tabID != "new" {
t.Fatalf("sink tab id = %q, want new", oldSink.tabID)
}
if _, ok := app.detachedSessions[sessionRuntimeKey(path)]; ok {
t.Fatal("detached runtime was not removed after reattach")
}
}
func TestBuildTabControllerReusesOpenSessionPathRuntime(t *testing.T) {
isolateDesktopUserDirs(t)
dir := desktopSessionDir(globalTabWorkspaceRoot())
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "open-runtime.jsonl")
oldSink := &tabEventSink{tabID: "old"}
oldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "open", Sink: oldSink})
defer oldCtrl.Close()
app := NewApp()
oldTab := &WorkspaceTab{
ID: "old",
Scope: "global",
WorkspaceRoot: globalTabWorkspaceRoot(),
SessionPath: path,
Ctrl: oldCtrl,
Label: "open",
Ready: true,
sink: oldSink,
disabledMCP: map[string]ServerView{},
}
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "new")
tab.SessionPath = path
tab.sink = &tabEventSink{tabID: "new", app: app}
app.tabs[oldTab.ID] = oldTab
app.tabs[tab.ID] = tab
app.tabOrder = []string{oldTab.ID, tab.ID}
app.activeTabID = tab.ID
app.buildTabController(tab)
if tab.Ctrl != oldCtrl {
t.Fatalf("reused controller = %p, want open %p", tab.Ctrl, oldCtrl)
}
if oldSink.tabID != "new" {
t.Fatalf("sink tab id = %q, want new", oldSink.tabID)
}
if _, ok := app.tabs[oldTab.ID]; ok {
t.Fatal("source tab for reused runtime should be removed")
}
}
func TestBuildTabControllerBlocksWhenSessionLeaseHeld(t *testing.T) {
isolateDesktopUserDirs(t)
dir := desktopSessionDir(globalTabWorkspaceRoot())
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "leased.jsonl")
if err := os.WriteFile(path, nil, 0o644); err != nil {
t.Fatalf("write placeholder session: %v", err)
}
lease, err := agent.TryAcquireSessionLease(path)
if err != nil {
t.Fatalf("TryAcquireSessionLease: %v", err)
}
defer lease.Release()
app := NewApp()
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "leased")
tab.SessionPath = path
tab.sink = &tabEventSink{tabID: "leased", app: app}
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
app.buildTabController(tab)
if tab.Ctrl != nil {
t.Fatalf("tab controller = %T, want nil when lease is held", tab.Ctrl)
}
if !tab.Ready {
t.Fatal("tab should be ready with startup error")
}
// The surfaced startup error is the sanitized busy message: the raw lease
// error would leak the session path and the holder's host-pid-writer id
// into the topbar banner.
if !strings.Contains(tab.StartupErr, "already open in another Reasonix window") {
t.Fatalf("startup error = %q, want user-facing busy message", tab.StartupErr)
}
if strings.Contains(tab.StartupErr, agent.ErrSessionLeaseHeld.Error()) ||
strings.Contains(tab.StartupErr, path) {
t.Fatalf("startup error leaked raw lease details: %q", tab.StartupErr)
}
}
func TestDeferredStartupRetryBuildsAfterLeaseRelease(t *testing.T) {
isolateDesktopUserDirs(t)
prevInterval := deferredRebuildRetryInterval
deferredRebuildRetryInterval = 20 * time.Millisecond
t.Cleanup(func() { deferredRebuildRetryInterval = prevInterval })
dir := desktopSessionDir(globalTabWorkspaceRoot())
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "startup-retry.jsonl")
if err := os.WriteFile(path, nil, 0o644); err != nil {
t.Fatalf("write placeholder session: %v", err)
}
lease, err := agent.TryAcquireSessionLease(path)
if err != nil {
t.Fatalf("TryAcquireSessionLease: %v", err)
}
released := false
t.Cleanup(func() {
if !released {
lease.Release()
}
})
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
app.enableDeferredRebuildRetry()
t.Cleanup(app.stopDeferredRebuildRetry)
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "startup_retry")
tab.SessionPath = path
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
installNoopRuntimeEvents(app, tab.sink)
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
t.Cleanup(func() {
if ctrl := app.controllerForTab(tab); ctrl != nil {
ctrl.Close()
}
tab.releaseSessionLease()
})
app.buildTabController(tab)
if tab.Ctrl != nil {
t.Fatalf("controller = %T, want nil while external lease is held", tab.Ctrl)
}
if !tab.StartupErrLeaseHeld {
t.Fatalf("startup retry flag = false, startup err = %q", tab.StartupErr)
}
if !app.deferredRebuildPending(tab.ID) {
t.Fatal("startup retry was not scheduled while the lease was held")
}
lease.Release()
released = true
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if !app.deferredRebuildPending(tab.ID) && app.controllerForTab(tab) != nil {
break
}
time.Sleep(10 * time.Millisecond)
}
if app.deferredRebuildPending(tab.ID) {
t.Fatal("startup retry is still pending after the lease was released")
}
if ctrl := app.controllerForTab(tab); ctrl == nil {
t.Fatal("controller was not rebuilt after the lease was released")
}
if tab.StartupErr != "" || tab.StartupErrLeaseHeld {
t.Fatalf("startup error after retry = %q retryable=%v, want cleared", tab.StartupErr, tab.StartupErrLeaseHeld)
}
}
func TestTabAndCtrlByIDRecoversStartupLeaseBeforeAction(t *testing.T) {
isolateDesktopUserDirs(t)
dir := desktopSessionDir(globalTabWorkspaceRoot())
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
path := filepath.Join(dir, "startup-before-action.jsonl")
if err := os.WriteFile(path, nil, 0o644); err != nil {
t.Fatalf("write placeholder session: %v", err)
}
lease, err := agent.TryAcquireSessionLease(path)
if err != nil {
t.Fatalf("TryAcquireSessionLease: %v", err)
}
released := false
t.Cleanup(func() {
if !released {
lease.Release()
}
})
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "startup_action")
tab.SessionPath = path
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
installNoopRuntimeEvents(app, tab.sink)
app.tabs[tab.ID] = tab
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
t.Cleanup(func() {
if ctrl := app.controllerForTab(tab); ctrl != nil {
ctrl.Close()
}
tab.releaseSessionLease()
})
app.buildTabController(tab)
if !tab.StartupErrLeaseHeld {
t.Fatalf("startup retry flag = false, startup err = %q", tab.StartupErr)
}
lease.Release()
released = true
gotTab, ctrl := app.tabAndCtrlByID(tab.ID)
if gotTab != tab {
t.Fatalf("tabAndCtrlByID tab = %p, want %p", gotTab, tab)
}
if ctrl == nil {
t.Fatal("tabAndCtrlByID did not rebuild the controller before returning")
}
if app.deferredRebuildPending(tab.ID) {
t.Fatal("startup retry remained pending after synchronous recovery")
}
if tab.StartupErr != "" || tab.StartupErrLeaseHeld {
t.Fatalf("startup error after synchronous recovery = %q retryable=%v, want cleared", tab.StartupErr, tab.StartupErrLeaseHeld)
}
}
func TestOpenGlobalTabResolvesTopicToLatestSessionRuntime(t *testing.T) {
isolateDesktopUserDirs(t)
dir := desktopSessionDir(globalTabWorkspaceRoot())
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
topicID := "topic_multi_session"
topicTitle := "Multi session topic"
oldPath := writeTopicSessionWithPrompt(t, dir, "old.jsonl", topicID, topicTitle, "", "old session prompt", time.Now().Add(-2*time.Hour))
newPath := writeTopicSessionWithPrompt(t, dir, "new.jsonl", topicID, topicTitle, "", "new session prompt", time.Now().Add(-time.Hour))
runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
oldSink := &tabEventSink{tabID: "topic-tab"}
oldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: oldSink})
defer oldCtrl.Close()
app := NewApp()
oldSink.app = app
oldTab := &WorkspaceTab{
ID: "topic-tab",
Scope: "global",
WorkspaceRoot: globalTabWorkspaceRoot(),
TopicID: topicID,
TopicTitle: topicTitle,
SessionPath: oldPath,
Ctrl: oldCtrl,
Ready: true,
sink: oldSink,
disabledMCP: map[string]ServerView{},
}
app.tabs[oldTab.ID] = oldTab
app.tabOrder = []string{oldTab.ID}
app.activeTabID = oldTab.ID
oldCtrl.Submit("keep old runtime running")
<-runner.started
meta, err := app.OpenGlobalTab(topicID)
if err != nil {
t.Fatalf("OpenGlobalTab: %v", err)
}
if meta.ID != oldTab.ID {
t.Fatalf("OpenGlobalTab reused tab %q, want %q", meta.ID, oldTab.ID)
}
if !oldCtrl.Running() {
t.Fatal("old session runtime was cancelled while selecting topic")
}
if detached := app.detachedSessions[sessionRuntimeKey(oldPath)]; detached == nil || detached.Ctrl != oldCtrl {
t.Fatalf("old runtime was not detached under its session path: %+v", detached)
}
visible := app.tabs[oldTab.ID]
if visible == nil || visible.Ctrl == nil {
t.Fatalf("visible tab was not rebuilt: %+v", visible)
}
if got := filepath.Clean(visible.Ctrl.SessionPath()); got != filepath.Clean(newPath) {
t.Fatalf("visible session path = %q, want %q", got, newPath)
}
history := visible.Ctrl.History()
if len(history) == 0 || history[0].Content != "new session prompt" {
t.Fatalf("visible history = %+v, want latest session prompt", history)
}
close(runner.release)
waitNotRunning(t, oldCtrl)
}
func TestReorderTabsRejectsInvalidOrder(t *testing.T) {
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
for name, order := range map[string][]string{
"missing": {"a", "b"},
"unknown": {"a", "b", "missing"},
"duplicate": {"a", "b", "b"},
} {
t.Run(name, func(t *testing.T) {
if err := app.ReorderTabs(order); err == nil {
t.Fatalf("ReorderTabs(%v) succeeded, want error", order)
}
})
}
assertTabIDs(t, app.ListTabs(), "a", "b", "c")
}
func TestNewUniqueTabIDLockedUsesFreshRandomID(t *testing.T) {
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
app.mu.Lock()
got := app.newUniqueTabIDLocked()
app.mu.Unlock()
if _, exists := app.tabs[got]; exists {
t.Fatalf("newUniqueTabIDLocked returned existing id %q", got)
}
if !strings.HasPrefix(got, "tab_") {
t.Fatalf("tab id = %q, want tab_ prefix", got)
}
if len(got) != len("tab_")+32 {
t.Fatalf("tab id = %q, length %d, want 36", got, len(got))
}
}
func TestRestoredTabIDLockedReplacesEmptyAndDuplicateIDs(t *testing.T) {
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
app.mu.Lock()
kept := app.restoredTabIDLocked("d")
duplicate := app.restoredTabIDLocked("a")
empty := app.restoredTabIDLocked(" ")
app.mu.Unlock()
if kept != "d" {
t.Fatalf("restored unique id = %q, want d", kept)
}
for name, got := range map[string]string{"duplicate": duplicate, "empty": empty} {
if _, exists := app.tabs[got]; exists {
t.Fatalf("%s restored id %q already exists", name, got)
}
if !strings.HasPrefix(got, "tab_") {
t.Fatalf("%s restored id = %q, want tab_ prefix", name, got)
}
}
}