Files
DeepSeek-Reasonix/desktop/tabs_order_test.go
T
YHH 4fbbca6a99 refactor(go): adopt modern stdlib idioms and gate them (#7831)
* refactor(go): adopt modern stdlib idioms across kernel and desktop

Mechanical sweep with golangci-lint's modernize/intrange/copyloopvar/
usestdlibvars fixers plus hand-fixes for what they could not rewrite:
slices.Contains, min/max, slices.Backward, WaitGroup.Go, reflect.TypeFor,
maps.Copy, strings.Cut/CutPrefix/SplitSeq, strings.Builder, range-over-int.
Net -549 lines with no behavior change.

Three loops in session.go stay index-based: slices.Backward yields element
copies, so the fixer's rewrite silently dropped writes to DecisionReceipts
and ToolCalls (caught by TestDelivery* and TestDependentSameBatch*). All 49
slices.Backward sites were audited for that shape; only these three write
through the index, and they carry a nolint with the reason.

CaptureAfter's scan loop had an empty body and never ran any logic, so the
fixer could not express it. Drop it and the accessor it was the only caller
of. omitzero is deliberately untouched: turning omitempty into omitzero
changes the JSON written for nested structs, which is a wire-format change
to session files and the extension protocol.

* build(lint): gate the modern-idiom linters

Nothing stopped the codebase from drifting back to pre-1.22 idioms. Both
modules are now clean for modernize, intrange, copyloopvar, and
usestdlibvars, so enable them rather than leaving the sweep as a one-off.

omitzero stays disabled with the reason inline; usetesting, perfsprint, and
errorlint still have residue and land separately.
2026-08-07 15:30:33 +08:00

1196 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, ...any) {}
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 range 8 {
wg.Go(func() {
<-start
for range iterations {
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 with no controller must not report ready")
}
app.mu.RLock()
runtimeView := app.sessionRuntimeViewLocked(tab)
app.mu.RUnlock()
if runtimeView.Phase != sessionRuntimeLeaseBlocked {
t.Fatalf("runtime phase = %q, want %q", runtimeView.Phase, sessionRuntimeLeaseBlocked)
}
// 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) != 2 || string(history[0].Role) != "system" || strings.TrimSpace(history[0].Content) == "" ||
string(history[1].Role) != "user" || history[1].Content != "new session prompt" {
t.Fatalf("visible history = %+v, want fresh system prompt and 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)
}
}
}