diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index 271f35c8a0f8..9c0e493ac162 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -770,6 +770,70 @@ describe("tui command handlers", () => { expect(refreshSessionInfo).toHaveBeenCalled(); }); + it.each([ + { name: "a different agent", replace: false, fails: false }, + { name: "a replacement session", replace: true, fails: false }, + { name: "a rejected old goal", replace: false, fails: true }, + ])("keeps a delayed local goal from leaking into $name", async ({ replace, fails }) => { + const deferred = createDeferred<{ text: string; continuationPrompt?: string }>(); + const harness = createHarness({ + opts: { local: true }, + currentAgentId: "research", + currentSessionKey: "agent:research:private", + currentSessionId: "research-session", + sessionGeneration: 4, + runGoalCommand: vi.fn(() => deferred.promise), + }); + + const pending = harness.handleCommand("/goal start private objective"); + if (replace) { + harness.state.currentSessionId = "replacement-session"; + harness.state.sessionGeneration += 1; + } else { + harness.state.currentAgentId = "ops"; + harness.state.currentSessionKey = "agent:ops:public"; + harness.state.currentSessionId = "ops-session"; + } + if (fails) { + deferred.reject(new Error("private research failure")); + } else { + deferred.resolve({ + text: "private research goal status", + continuationPrompt: "PRIVATE_RESEARCH_OBJECTIVE", + }); + } + await pending; + + expect(harness.sendChat).not.toHaveBeenCalled(); + expect(harness.addSystem).not.toHaveBeenCalled(); + expect(harness.refreshSessionInfo).not.toHaveBeenCalled(); + }); + + it("does not send an old goal continuation after its session changes during refresh", async () => { + const refresh = createDeferred(); + const harness = createHarness({ + opts: { local: true }, + currentAgentId: "research", + currentSessionKey: "agent:research:private", + currentSessionId: "research-session", + runGoalCommand: vi.fn().mockResolvedValue({ + text: "private research goal status", + continuationPrompt: "PRIVATE_RESEARCH_OBJECTIVE", + }), + refreshSessionInfo: vi.fn(() => refresh.promise), + }); + + const pending = harness.handleCommand("/goal start private objective"); + await vi.waitFor(() => expect(harness.refreshSessionInfo).toHaveBeenCalledOnce()); + harness.state.currentAgentId = "ops"; + harness.state.currentSessionKey = "agent:ops:public"; + harness.state.currentSessionId = "ops-session"; + refresh.resolve(); + await pending; + + expect(harness.sendChat).not.toHaveBeenCalled(); + }); + it("wraps command-prefixed local goal objectives before sending", async () => { const slashPrompt = `Pursue this goal exactly as written from this JSON string: "\\/status"`; const slashRunGoalCommand = vi @@ -1587,6 +1651,87 @@ describe("tui command handlers", () => { expect(loadHistory).not.toHaveBeenCalled(); }); + it.each([ + { name: "a different agent", replace: false, fails: false }, + { name: "a replacement session", replace: true, fails: false }, + { name: "a rejected old session", replace: false, fails: true }, + ])("does not let delayed /new hijack $name", async ({ replace, fails }) => { + const deferred = createDeferred<{ ok: true; key: string }>(); + const harness = createHarness({ + currentAgentId: "research", + currentSessionKey: "agent:research:private", + currentSessionId: "research-session", + sessionGeneration: 4, + sessionInfo: { inputTokens: 11, outputTokens: 22, totalTokens: 33 }, + createSession: vi.fn(() => deferred.promise), + }); + + const pending = harness.handleCommand("/new"); + if (replace) { + harness.state.currentSessionId = "replacement-session"; + harness.state.sessionGeneration += 1; + } else { + harness.state.currentAgentId = "ops"; + harness.state.currentSessionKey = "agent:ops:public"; + harness.state.currentSessionId = "ops-session"; + } + if (fails) { + deferred.reject(new Error("private research session failure")); + } else { + deferred.resolve({ ok: true, key: "agent:research:private-child" }); + } + await pending; + + expect(harness.setSession).not.toHaveBeenCalled(); + expect(harness.addSystem).not.toHaveBeenCalled(); + expect(harness.state.sessionInfo).toMatchObject({ + inputTokens: 11, + outputTokens: 22, + totalTokens: 33, + }); + }); + + it.each([ + { name: "reports an adopted session failure", fails: true, switches: false }, + { name: "does not leak an adopted session notice", fails: false, switches: true }, + ])("$name after /new changes the selected session", async ({ fails, switches }) => { + const adoption = createDeferred(); + const createdKey = "agent:research:private-child"; + const harness = createHarness({ + currentAgentId: "research", + currentSessionKey: "agent:research:private", + currentSessionId: "research-session", + createSession: vi.fn().mockResolvedValue({ ok: true, key: createdKey }), + setSession: vi.fn((key: string) => { + harness.state.currentSessionKey = key; + harness.state.currentSessionId = null; + return adoption.promise; + }) as SetSessionMock, + }); + + const pending = harness.handleCommand("/new"); + await vi.waitFor(() => expect(harness.setSession).toHaveBeenCalledOnce()); + if (switches) { + harness.state.currentAgentId = "ops"; + harness.state.currentSessionKey = "agent:ops:public"; + harness.state.currentSessionId = "ops-session"; + } + if (fails) { + adoption.reject(new Error("replacement history unavailable")); + } else { + adoption.resolve(); + } + await pending; + + if (fails) { + expect(harness.addSystem).toHaveBeenCalledWith( + "new session failed: replacement history unavailable", + ); + } else { + expect(harness.addSystem).not.toHaveBeenCalled(); + } + }); + it("reports a reset after adopting the backend's replacement session key", async () => { const resetResult = { ok: true as const, @@ -3120,21 +3265,58 @@ describe("tui command handlers", () => { }); it.each([ - { name: "session result", sessionKey: "agent:main:first", agentId: "main", fails: false }, - { name: "global-agent result", sessionKey: "global", agentId: "main", fails: false }, - { name: "session failure", sessionKey: "agent:main:first", agentId: "main", fails: true }, - ])("suppresses a stale usage-cost $name", async ({ sessionKey, agentId, fails }) => { + { + name: "session result", + sessionKey: "agent:main:first", + agentId: "main", + fails: false, + replace: false, + }, + { + name: "global-agent result", + sessionKey: "global", + agentId: "main", + fails: false, + replace: false, + }, + { + name: "session failure", + sessionKey: "agent:main:first", + agentId: "main", + fails: true, + replace: false, + }, + { + name: "replacement-session result", + sessionKey: "agent:main:first", + agentId: "main", + fails: false, + replace: true, + }, + { + name: "replacement-session failure", + sessionKey: "agent:main:first", + agentId: "main", + fails: true, + replace: true, + }, + ])("suppresses a stale usage-cost $name", async ({ sessionKey, agentId, fails, replace }) => { const deferred = createDeferred<{ text: string }>(); const runUsageCostCommand = vi.fn(() => deferred.promise); const harness = createHarness({ opts: { local: true }, currentSessionKey: sessionKey, currentAgentId: agentId, + currentSessionId: "original-session", + sessionGeneration: 4, runUsageCostCommand, }); const pending = harness.handleCommand("/usage cost"); - if (sessionKey === "global") { + if (replace) { + harness.state.currentSessionId = "replacement-session"; + harness.state.sessionGeneration += 1; + } else if (sessionKey === "global") { harness.state.currentAgentId = "work"; } else { harness.state.currentSessionKey = "agent:main:second"; diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 05e1195e4368..3df05adbbda6 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -560,19 +560,24 @@ export function createCommandHandlers(context: CommandHandlerContext) { }, goal: async (_args, raw) => { if (opts.local === true && client.runGoalCommand) { + const { selection, isCurrent } = captureSessionIncarnation(); try { const result = await client.runGoalCommand({ - sessionKey: state.currentSessionKey, - agentId: state.currentAgentId, + ...selection, command: raw, }); + if (!isCurrent()) { + return; + } chatLog.addSystem(result.text); await refreshSessionInfo(); - if (result.continuationPrompt) { + if (result.continuationPrompt && isCurrent()) { await sendMessage(result.continuationPrompt); } } catch (err) { - chatLog.addSystem(`goal failed: ${formatTuiErrorMessage(err)}`); + if (isCurrent()) { + chatLog.addSystem(`goal failed: ${formatTuiErrorMessage(err)}`); + } } } else { await sendMessage(raw); @@ -703,14 +708,14 @@ export function createCommandHandlers(context: CommandHandlerContext) { addUnsupportedLocalCommand("usage cost"); return; } - const selection = captureSessionSelection(); + const { selection, isCurrent } = captureSessionIncarnation(); try { const result = await client.runUsageCostCommand(selection); - if (isCurrentSessionSelection(selection)) { + if (isCurrent()) { chatLog.addSystem(result.text); } } catch (err) { - if (isCurrentSessionSelection(selection)) { + if (isCurrent()) { chatLog.addSystem(`usage cost failed: ${formatTuiErrorMessage(err)}`); } } @@ -773,27 +778,31 @@ export function createCommandHandlers(context: CommandHandlerContext) { if (rejectUnsafeSessionRollover("new")) { return; } + let creationIncarnation = captureSessionIncarnation(); + const { selection, sessionId } = creationIncarnation; const finishSessionTransition = beginSessionTransition("new"); try { - const uniqueKey = `tui-${randomUUID()}`; const result = await client.createSession({ - key: uniqueKey, - agentId: state.currentAgentId, - ...(state.currentSessionId - ? { parentSessionKey: state.currentSessionKey, succeedsParent: true } - : {}), + key: `tui-${randomUUID()}`, + agentId: selection.agentId, + ...(sessionId ? { parentSessionKey: selection.sessionKey, succeedsParent: true } : {}), }); + if (!creationIncarnation.isCurrent()) { + return; + } if (!result.key) { throw new Error("sessions.create returned no session key"); } - state.sessionInfo.inputTokens = null; - state.sessionInfo.outputTokens = null; - state.sessionInfo.totalTokens = null; - tui.requestRender(); - await setSession(result.key); - chatLog.addSystem(`new session: ${result.key}`); + const adoption = setSession(result.key); + creationIncarnation = captureSessionIncarnation(); + await adoption; + if (creationIncarnation.isCurrent()) { + chatLog.addSystem(`new session: ${result.key}`); + } } catch (err) { - chatLog.addSystem(`new session failed: ${formatTuiErrorMessage(err)}`); + if (creationIncarnation.isCurrent()) { + chatLog.addSystem(`new session failed: ${formatTuiErrorMessage(err)}`); + } } finally { finishSessionTransition(); } diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index aef29bd0ce2e..da5facf189c1 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -200,7 +200,19 @@ describe("tui session actions", () => { activeChatRunId: "research-run", pendingSubmit: acceptedSubmit("research-pending", "private draft"), historyLoaded: true, - sessionInfo: { displayName: "Research secret", updatedAt: 100, verboseLevel: "full" }, + sessionInfo: { + displayName: "Research secret", + updatedAt: 100, + modelProvider: "anthropic", + model: "private-research-model", + thinkingLevel: "high", + thinkingLevels: [{ id: "high", label: "high" }], + agentRuntime: { id: "private-runtime", source: "agent" }, + responseUsage: "full", + effectiveResponseUsage: "full", + contextTokens: 999_999, + verboseLevel: "full", + }, }); sendPendingUser(state, "research-pending", "private draft"); const chatLog = new ChatLog(); @@ -208,6 +220,7 @@ describe("tui session actions", () => { const history = createDeferred<{ messages: unknown[]; sessionInfo: { sessionId: string }; + defaults: { modelProvider: string; model: string; contextTokens: number }; }>(); const loadHistory = vi.fn(() => history.promise); const invalidateRunOwnership = vi.fn(); @@ -229,7 +242,7 @@ describe("tui session actions", () => { expect(state.currentAgentId).toBe("ops"); expect(state.currentSessionKey).toBe("global"); expect(state.currentSessionId).toBeNull(); - expect(state.sessionInfo.displayName).toBeUndefined(); + expect(state.sessionInfo).toEqual({}); expect(state.activeChatRunId).toBeNull(); expect(state.pendingSubmit).toBeNull(); expect(state.historyLoaded).toBe(false); @@ -239,9 +252,21 @@ describe("tui session actions", () => { expect(clearLocalRunIds).toHaveBeenCalledOnce(); expect(loadHistory).toHaveBeenCalledWith({ sessionKey: "global", agentId: "ops", limit: 200 }); - history.resolve({ messages: [], sessionInfo: { sessionId: "ops-session" } }); + history.resolve({ + messages: [], + sessionInfo: { sessionId: "ops-session" }, + defaults: { modelProvider: "openai", model: "gpt-5.4", contextTokens: 128_000 }, + }); await switching; expect(state.currentSessionId).toBe("ops-session"); + expect(state.sessionInfo).toMatchObject({ + modelProvider: "openai", + model: "gpt-5.4", + contextTokens: 128_000, + }); + expect(state.sessionInfo.thinkingLevel).toBeUndefined(); + expect(state.sessionInfo.agentRuntime).toBeUndefined(); + expect(state.sessionInfo.responseUsage).toBeUndefined(); }); it("returns success after applying a normalized fresh agent roster", async () => { @@ -397,9 +422,9 @@ describe("tui session actions", () => { activeChatRunId: null, pendingSubmit: null, historyLoaded: false, - sessionInfo: { updatedAt: null }, + sessionInfo: {}, }); - expect(state.sessionInfo.thinkingLevel).toBe("high"); + expect(state.sessionInfo.thinkingLevel).toBeUndefined(); expect(state.sessionInfo.verboseLevel).toBeUndefined(); expect(state.sessionProjection?.entries).toEqual([]); expect(invalidateRunOwnership).toHaveBeenCalledOnce(); @@ -1685,13 +1710,15 @@ describe("tui session actions", () => { await setSession("agent:main:target"); - expect(state.sessionInfo).toMatchObject({ - displayName: undefined, - fastMode: undefined, - verboseLevel: undefined, - traceLevel: undefined, - reasoningLevel: undefined, - }); + for (const key of [ + "displayName", + "fastMode", + "verboseLevel", + "traceLevel", + "reasoningLevel", + ] as const) { + expect(state.sessionInfo[key]).toBeUndefined(); + } }); it("merges a same-session mode patch without clearing untouched modes", () => { @@ -2198,6 +2225,40 @@ describe("tui session actions", () => { expect(clearAll).toHaveBeenCalledOnce(); }); + it("discards in-flight history when an external event replaces the same session", async () => { + const history = createDeferred(); + const { chatLog, addUser, clearAll } = createHistoryChatLog(); + const state = createBaseState({ + currentSessionId: "session-before-reset", + sessionGeneration: 4, + sessionInfo: { model: "model-before-reset" }, + }); + const { loadHistory } = createTestSessionActions({ + client: makeTuiBackend({ loadHistory: vi.fn(() => history.promise) }), + chatLog, + state, + }); + + const staleHistory = loadHistory(); + state.sessionGeneration = 5; + state.currentSessionId = "session-after-reset"; + state.sessionInfo = { model: "model-after-reset" }; + history.resolve({ + sessionInfo: { + key: "agent:main:main", + sessionId: "session-before-reset", + model: "private-old-model", + }, + messages: [{ role: "user", content: "PRIVATE OLD HISTORY" }], + }); + + await expect(staleHistory).resolves.toEqual({ loaded: false }); + expect(state.currentSessionId).toBe("session-after-reset"); + expect(state.sessionInfo.model).toBe("model-after-reset"); + expect(addUser).not.toHaveBeenCalled(); + expect(clearAll).not.toHaveBeenCalled(); + }); + it("does not clear the selected session for another session's reset result", () => { const addSystem = vi.fn(); const clearAll = vi.fn(); diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 50d0cbf29bb7..b8b4f4eb61f2 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -23,7 +23,6 @@ import { } from "./tui-formatters.js"; import { readTuiSessionUserMessage } from "./tui-session-events.js"; import { - clearTuiSessionModeOverrides, sessionInfoUiEquals, type SessionInfoDefaults, type SessionInfoEntry, @@ -111,11 +110,8 @@ export function createSessionActions(context: SessionActionContext) { submit.clearPendingSubmit(state); setActivityStatus("idle"); state.currentSessionId = null; - state.sessionInfo.displayName = undefined; - clearTuiSessionModeOverrides(state.sessionInfo); - // Session keys can move backwards in updatedAt ordering; drop previous session freshness - // so refresh data for the newly selected session isn't rejected as stale. - state.sessionInfo.updatedAt = null; + state.sessionInfo = {}; + lastSessionDefaults = null; state.historyLoaded = false; // Live prompt identities belong to the old selection, not its pending successor. chatLog.clearAll(); @@ -455,9 +451,12 @@ export function createSessionActions(context: SessionActionContext) { // History rebuilds mutate shared UI state after multiple awaits. Only the // latest request may render, or a slow reload can replace a newer selection. const generation = ++historyLoadGeneration; + const sessionGeneration = state.sessionGeneration ?? 0; const selection = captureSessionSelection(); const isCurrentLoad = () => - generation === historyLoadGeneration && isCurrentSessionSelection(selection); + generation === historyLoadGeneration && + (state.sessionGeneration ?? 0) === sessionGeneration && + isCurrentSessionSelection(selection); try { const history = await client.loadHistory({ sessionKey: selection.sessionKey, diff --git a/src/tui/tui-session-info.ts b/src/tui/tui-session-info.ts index 0544c3d7af99..79f1598f6b79 100644 --- a/src/tui/tui-session-info.ts +++ b/src/tui/tui-session-info.ts @@ -41,11 +41,3 @@ export function sessionInfoUiEquals(left: SessionInfo, right: SessionInfo): bool JSON.stringify(left.goal ?? null) === JSON.stringify(right.goal ?? null)) ); } - -/** Clear selection-owned modes so a switch cannot display its predecessor while loading. */ -export function clearTuiSessionModeOverrides(sessionInfo: SessionInfo): void { - sessionInfo.fastMode = undefined; - sessionInfo.verboseLevel = undefined; - sessionInfo.traceLevel = undefined; - sessionInfo.reasoningLevel = undefined; -}