diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index 4c8a6795bb01..e614d565b44f 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -216,6 +216,10 @@ describe("tui session actions", () => { agentNames, updateHeader, updateFooter, + resolveSessionSelection: vi.fn((_raw?: string, agentId = state.currentAgentId) => ({ + key: `agent:${agentId}:${state.sessionMainKey}`, + agentId, + })), }); await expect(refreshAgents()).resolves.toEqual({ ok: true, value: undefined }); @@ -227,6 +231,7 @@ describe("tui session actions", () => { { id: "system-agent", kind: "system", name: "System Agent" }, ]); expect(state.currentAgentId).toBe("team-lead"); + expect(state.currentSessionKey).toBe("agent:team-lead:primary"); expect([...agentNames]).toEqual([ ["team-lead", "Lead Agent"], ["system-agent", "System Agent"], @@ -235,6 +240,129 @@ describe("tui session actions", () => { expect(updateFooter).toHaveBeenCalledTimes(1); }); + it.each([ + { + scope: "per-sender" as const, + previousKey: "agent:research:main", + nextKey: "agent:ops:main", + }, + { + scope: "global" as const, + previousKey: "global", + nextKey: "global", + }, + ])( + "retires the complete $scope session when its selected agent disappears", + async ({ scope, previousKey, nextKey }) => { + const state = createBaseState({ + agents: [{ id: "research" }], + currentAgentId: "research", + currentSessionKey: previousKey, + currentSessionId: "old-session", + sessionMainKey: "main", + sessionScope: scope, + activeChatRunId: "old-run", + pendingSubmit: acceptedSubmit("pending-run"), + historyLoaded: true, + sessionInfo: { updatedAt: 100, thinkingLevel: "high", verboseLevel: "full" }, + }); + sendPendingUser(state, "pending-run", "stale prompt"); + const loadHistory = vi.fn(); + const invalidateRunOwnership = vi.fn(); + const clearLocalRunIds = vi.fn(); + const clearAll = vi.fn(); + const clearPendingUsers = vi.fn(); + const btw = createBtwPresenter(); + const { refreshAgents } = createTestSessionActions({ + client: makeTuiBackend({ + loadHistory, + listAgents: vi.fn().mockResolvedValue({ + defaultId: "ops", + mainKey: "main", + scope, + agents: [{ id: "ops" }], + }), + }), + chatLog: makeChatLog({ clearAll, clearPendingUsers }), + btw, + state, + invalidateRunOwnership, + clearLocalRunIds, + resolveSessionSelection: vi.fn((_raw?: string, agentId = state.currentAgentId) => ({ + key: scope === "global" ? "global" : `agent:${agentId}:main`, + agentId, + })), + }); + + await expect(refreshAgents()).resolves.toEqual({ ok: true, value: undefined }); + + expect(state).toMatchObject({ + currentAgentId: "ops", + currentSessionKey: nextKey, + currentSessionId: null, + activeChatRunId: null, + pendingSubmit: null, + historyLoaded: false, + sessionInfo: { updatedAt: null }, + }); + expect(state.sessionInfo.thinkingLevel).toBe("high"); + expect(state.sessionInfo.verboseLevel).toBeUndefined(); + expect(state.sessionProjection?.entries).toEqual([]); + expect(invalidateRunOwnership).toHaveBeenCalledOnce(); + expect(clearLocalRunIds).toHaveBeenCalledOnce(); + expect(clearAll).toHaveBeenCalledOnce(); + expect(clearPendingUsers).toHaveBeenCalledOnce(); + expect(btw.clear).toHaveBeenCalledOnce(); + expect(loadHistory).not.toHaveBeenCalled(); + }, + ); + + it("preserves the complete selected session when its agent remains in the roster", async () => { + const state = createBaseState({ + agents: [{ id: "research" }], + currentAgentId: "research", + currentSessionKey: "agent:research:incident", + currentSessionId: "current-session", + sessionScope: "per-sender", + activeChatRunId: "current-run", + pendingSubmit: acceptedSubmit("pending-run"), + historyLoaded: true, + sessionInfo: { updatedAt: 100, thinkingLevel: "high" }, + }); + sendPendingUser(state, "pending-run", "current prompt"); + const previousProjection = state.sessionProjection; + const invalidateRunOwnership = vi.fn(); + const resolveSessionSelection = vi.fn(); + const { refreshAgents } = createTestSessionActions({ + client: makeTuiBackend({ + listAgents: vi.fn().mockResolvedValue({ + defaultId: "ops", + mainKey: "main", + scope: "per-sender", + agents: [{ id: "ops" }, { id: "research" }], + }), + }), + state, + invalidateRunOwnership, + resolveSessionSelection, + }); + + await expect(refreshAgents()).resolves.toEqual({ ok: true, value: undefined }); + + expect(state).toMatchObject({ + currentAgentId: "research", + currentSessionKey: "agent:research:incident", + currentSessionId: "current-session", + activeChatRunId: "current-run", + historyLoaded: true, + sessionInfo: { updatedAt: 100, thinkingLevel: "high" }, + }); + expect(state.pendingSubmit).toEqual(acceptedSubmit("pending-run")); + expect(state.sessionProjection).toBe(previousProjection); + expect(invalidateRunOwnership).not.toHaveBeenCalled(); + expect(resolveSessionSelection).not.toHaveBeenCalled(); + }); + it("queues session refreshes and applies the latest result", async () => { let resolveFirst: ((value: unknown) => void) | undefined; let resolveSecond: ((value: unknown) => void) | undefined; diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index db33c871e96d..ea6d31f332c8 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -50,7 +50,7 @@ type SessionActionContext = { agentNames: Map; initialSessionInput: string; initialSessionAgentId: string | null; - resolveSessionSelection: (raw?: string) => { key: string; agentId: string }; + resolveSessionSelection: (raw?: string, agentId?: string) => { key: string; agentId: string }; updateHeader: () => void; updateFooter: () => void; updateAutocompleteProvider: () => void; @@ -88,6 +88,45 @@ export function createSessionActions(context: SessionActionContext) { agentId: state.currentAgentId, }); + const applySessionSelection = (nextSelection: { key: string; agentId: string }) => { + const previousSelection = captureSessionSelection(); + const selectionChanged = !( + nextSelection.agentId === previousSelection.agentId && + agentSessionKeysMatchByRequestKey(nextSelection.key, previousSelection.sessionKey) + ); + if (selectionChanged) { + // Retire the previous session's runs before history can adopt a new + // in-flight owner; otherwise its completion can promote an old run. + invalidateRunOwnership?.(); + reduceTuiSessionProjection(state, { + type: "sessionReset", + scope: readTuiSessionProjectionScope(state), + }); + } + state.currentAgentId = nextSelection.agentId; + state.currentSessionKey = nextSelection.key; + state.activeChatRunId = null; + submit.clearPendingSubmit(state); + setActivityStatus("idle"); + if (selectionChanged) { + state.currentSessionId = null; + 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.historyLoaded = false; + if (selectionChanged) { + // Live prompt identities belong to the old selection, not its pending successor. + chatLog.clearAll(); + } + chatLog.clearPendingUsers(); + clearLocalRunIds?.(); + btw.clear(); + updateHeader(); + updateFooter(); + }; + const isCurrentSessionSelection = (selection: { sessionKey: string; agentId: string }): boolean => state.currentAgentId === selection.agentId && agentSessionKeysMatchByRequestKey(state.currentSessionKey, selection.sessionKey); @@ -134,8 +173,12 @@ export function createSessionActions(context: SessionActionContext) { } state.initialSessionApplied = true; } else if (!state.agents.some((agent) => agent.id === state.currentAgentId)) { - state.currentAgentId = + const nextAgentId = state.agents[0]?.id ?? normalizeAgentId(result.defaultId ?? state.currentAgentId); + if (nextAgentId !== state.currentAgentId) { + applySessionSelection(resolveSessionSelection(undefined, nextAgentId)); + return; + } } updateHeader(); updateFooter(); @@ -599,44 +642,7 @@ export function createSessionActions(context: SessionActionContext) { }; const setSession = async (rawKey: string) => { - const previousSelection = captureSessionSelection(); - const nextSelection = resolveSessionSelection(rawKey); - const nextKey = nextSelection.key; - const selectionChanged = !( - nextSelection.agentId === previousSelection.agentId && - agentSessionKeysMatchByRequestKey(nextKey, previousSelection.sessionKey) - ); - if (selectionChanged) { - // Retire the previous session's runs before history can adopt a new - // in-flight owner; otherwise its completion can promote an old run. - invalidateRunOwnership?.(); - reduceTuiSessionProjection(state, { - type: "sessionReset", - scope: readTuiSessionProjectionScope(state), - }); - } - state.currentAgentId = nextSelection.agentId; - state.currentSessionKey = nextKey; - state.activeChatRunId = null; - submit.clearPendingSubmit(state); - setActivityStatus("idle"); - if (selectionChanged) { - state.currentSessionId = null; - 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.historyLoaded = false; - if (selectionChanged) { - // Live prompt identities belong to the old selection, not its pending successor. - chatLog.clearAll(); - } - chatLog.clearPendingUsers(); - clearLocalRunIds?.(); - btw.clear(); - updateHeader(); - updateFooter(); + applySessionSelection(resolveSessionSelection(rawKey)); await loadHistory(); }; diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 7598a5a2be51..0d79f242e535 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -1037,12 +1037,12 @@ async function runTuiUnlocked(opts: RunTuiOptions): Promise { return name ? `${id} (${name})` : id; }; - const resolveSessionSelection = (raw?: string) => { + const resolveSessionSelection = (raw?: string, agentId = state.currentAgentId) => { return resolveTuiSessionSelection({ raw, cfg: config, sessionScope: state.sessionScope, - currentAgentId: state.currentAgentId, + currentAgentId: agentId, sessionMainKey: state.sessionMainKey, }); };