diff --git a/src/commands/agents/agents.tsx b/src/commands/agents/agents.tsx index 1d2c559743..b1e686008f 100644 --- a/src/commands/agents/agents.tsx +++ b/src/commands/agents/agents.tsx @@ -2,10 +2,10 @@ import * as React from 'react'; import { AgentsMenu } from '../../components/agents/AgentsMenu.js'; import type { ToolUseContext } from '../../Tool.js'; import { getTools } from '../../tools.js'; -import type { LocalJSXCommandOnDone } from '../../types/command.js'; -export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContext): Promise { +import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js'; +export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContext & LocalJSXCommandContext): Promise { const appState = context.getAppState(); const permissionContext = appState.toolPermissionContext; const tools = getTools(permissionContext); - return ; + return ; } diff --git a/src/components/PromptInput/PromptInputQueuedCommands.test.tsx b/src/components/PromptInput/PromptInputQueuedCommands.test.tsx index 28525e704c..e725105867 100644 --- a/src/components/PromptInput/PromptInputQueuedCommands.test.tsx +++ b/src/components/PromptInput/PromptInputQueuedCommands.test.tsx @@ -5,6 +5,8 @@ import { releaseSharedMutationLock, } from '../../test/sharedMutationLock.js' import { renderToString } from '../../utils/staticRender.js' +import * as realCommandQueue from '../../hooks/useCommandQueue.js' +import { AppStateProvider } from 'src/state/AppState.js' describe('PromptInputQueuedCommands', () => { beforeEach(async () => { @@ -18,16 +20,12 @@ describe('PromptInputQueuedCommands', () => { ], })) - mock.module('src/state/AppState.js', () => ({ - useAppState: ( - selector: (state: { viewingAgentTaskId?: string; isBriefOnly: boolean }) => unknown, - ) => selector({ viewingAgentTaskId: undefined, isBriefOnly: false }), - })) }) afterEach(() => { try { mock.restore() + mock.module('../../hooks/useCommandQueue.js', () => realCommandQueue) } finally { releaseSharedMutationLock() } @@ -36,7 +34,12 @@ describe('PromptInputQueuedCommands', () => { it('shows a next-turn guidance banner for queued prompt messages', async () => { const { PromptInputQueuedCommands } = await import('./PromptInputQueuedCommands.js') - const output = await renderToString(, 100) + const output = await renderToString( + + + , + 100, + ) expect(output).toContain('1 message queued for next turn') expect(output).toContain('Use another library') diff --git a/src/components/StartupScreen.test.ts b/src/components/StartupScreen.test.ts index a45ac17b3d..eb217f6b69 100644 --- a/src/components/StartupScreen.test.ts +++ b/src/components/StartupScreen.test.ts @@ -277,6 +277,7 @@ describe('detectProvider — modelOverride from --model flag', () => { }) test('modelOverride alias is resolved for Anthropic', () => { + process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'claude-opus-4-6' const result = detectProvider('opus') expect(result.name).toBe('Anthropic') expect(result.model).toContain('opus') diff --git a/src/components/agents/AgentsList.test.tsx b/src/components/agents/AgentsList.test.tsx new file mode 100644 index 0000000000..ba44ef0bdd --- /dev/null +++ b/src/components/agents/AgentsList.test.tsx @@ -0,0 +1,185 @@ +import { PassThrough } from 'node:stream' + +import { afterEach, beforeEach, expect, test } from 'bun:test' +import React from 'react' +import stripAnsi from 'strip-ansi' + +import { createRoot } from '../../ink.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' +import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' +import { AgentsList } from './AgentsList.js' + +const SYNC_START = '\x1B[?2026h' +const SYNC_END = '\x1B[?2026l' + +function extractLastFrame(output: string): string { + let lastFrame: string | null = null + let cursor = 0 + + while (cursor < output.length) { + const start = output.indexOf(SYNC_START, cursor) + if (start === -1) break + const contentStart = start + SYNC_START.length + const end = output.indexOf(SYNC_END, contentStart) + if (end === -1) break + const frame = output.slice(contentStart, end) + if (frame.trim().length > 0) lastFrame = frame + cursor = end + SYNC_END.length + } + + return lastFrame ?? output +} + +function createTestStreams() { + let output = '' + const stdout = new PassThrough() + const stdin = new PassThrough() as PassThrough & { + isTTY: boolean + setRawMode: () => void + ref: () => void + unref: () => void + } + stdin.isTTY = true + stdin.setRawMode = () => {} + stdin.ref = () => {} + stdin.unref = () => {} + ;(stdout as unknown as { columns: number }).columns = 120 + stdout.on('data', chunk => { + output += chunk.toString() + }) + return { stdout, stdin, getOutput: () => output } +} + +async function waitForOutput( + getOutput: () => string, + predicate: (frame: string) => boolean, +): Promise { + const startedAt = Date.now() + + while (Date.now() - startedAt < 2500) { + const frame = stripAnsi(extractLastFrame(getOutput())) + if (predicate(frame)) return frame + await Bun.sleep(10) + } + + throw new Error('Timed out waiting for agents list output') +} + +function createAgent( + agentType: string, + source: AgentDefinition['source'] = 'userSettings', +): AgentDefinition { + return { + agentType, + whenToUse: `Use ${agentType}`, + source, + getSystemPrompt: () => `You are ${agentType}`, + } +} + +beforeEach(async () => { + await acquireSharedMutationLock('components/agents/AgentsList.test.tsx') +}) + +afterEach(() => { + releaseSharedMutationLock() +}) + +test('shows and marks the active session agent', async () => { + const { stdout, stdin, getOutput } = createTestStreams() + const root = await createRoot({ + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + + root.render( + {}} + onSelect={() => {}} + />, + ) + + try { + const output = await waitForOutput( + getOutput, + frame => frame.includes('Current session agent: reviewer'), + ) + expect(output).toContain('reviewer') + expect(output).toContain('active') + } finally { + root.unmount() + stdin.end() + stdout.end() + } +}) + +test('shows none when no session agent is active', async () => { + const { stdout, stdin, getOutput } = createTestStreams() + const root = await createRoot({ + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + + root.render( + {}} + onSelect={() => {}} + />, + ) + + try { + await waitForOutput(getOutput, frame => + frame.includes('Current session agent: none'), + ) + } finally { + root.unmount() + stdin.end() + stdout.end() + } +}) + +test('does not mark shadowed agent rows as active', async () => { + const { stdout, stdin, getOutput } = createTestStreams() + const root = await createRoot({ + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + + root.render( + {}} + onSelect={() => {}} + />, + ) + + try { + const output = await waitForOutput( + getOutput, + frame => frame.includes('shadowed by user') && frame.includes('active'), + ) + + expect(output.match(/\bactive\b/g) ?? []).toHaveLength(1) + } finally { + root.unmount() + stdin.end() + stdout.end() + } +}) diff --git a/src/components/agents/AgentsList.tsx b/src/components/agents/AgentsList.tsx index 908ff70667..d3bee64b30 100644 --- a/src/components/agents/AgentsList.tsx +++ b/src/components/agents/AgentsList.tsx @@ -19,6 +19,7 @@ type Props = { onSelect: (agent: AgentDefinition) => void; onCreateNew?: () => void; changes?: string[]; + activeAgentName?: string; }; export function AgentsList(t0) { const $ = _c(96); @@ -28,7 +29,8 @@ export function AgentsList(t0) { onBack, onSelect, onCreateNew, - changes + changes, + activeAgentName } = t0; const [selectedAgent, setSelectedAgent] = React.useState(null); const [isCreateNewSelected, setIsCreateNewSelected] = React.useState(true); @@ -51,41 +53,35 @@ export function AgentsList(t0) { t2 = $[3]; } const renderCreateNewOption = t2; - let t3; - if ($[4] !== isCreateNewSelected || $[5] !== selectedAgent?.agentType || $[6] !== selectedAgent?.source) { - t3 = agent_0 => { - const isBuiltIn = agent_0.source === "built-in"; - const isSelected = !isBuiltIn && !isCreateNewSelected && selectedAgent?.agentType === agent_0.agentType && selectedAgent?.source === agent_0.source; - const { - isOverridden, - overriddenBy - } = getOverrideInfo(agent_0); - const dimmed = isBuiltIn || isOverridden; - const textColor = !isBuiltIn && isSelected ? "suggestion" : undefined; - const resolvedModel = resolveAgentModelDisplay(agent_0); - return {isBuiltIn ? "" : isSelected ? `${figures.pointer} ` : " "}{agent_0.agentType}{resolvedModel && {" \xB7 "}{resolvedModel}}{agent_0.memory && {" \xB7 "}{agent_0.memory} memory}{overriddenBy && {" "}{figures.warning} shadowed by {getOverrideSourceLabel(overriddenBy)}}; - }; - $[4] = isCreateNewSelected; - $[5] = selectedAgent?.agentType; - $[6] = selectedAgent?.source; - $[7] = t3; - } else { - t3 = $[7]; - } - const renderAgent = t3; + const renderAgent = agent_0 => { + const isSelected = !isCreateNewSelected && selectedAgent?.agentType === agent_0.agentType && selectedAgent?.source === agent_0.source; + const isActive = agent_0.agentType === activeAgentName && !agent_0.overriddenBy; + const { + isOverridden, + overriddenBy + } = getOverrideInfo(agent_0); + const dimmed = agent_0.source === "built-in" || isOverridden; + const textColor = isSelected ? "suggestion" : undefined; + const resolvedModel = resolveAgentModelDisplay(agent_0); + return {isSelected ? `${figures.pointer} ` : " "}{agent_0.agentType}{resolvedModel && {" \xB7 "}{resolvedModel}}{agent_0.memory && {" \xB7 "}{agent_0.memory} memory}{isActive && {figures.tick} active}{overriddenBy && {" "}{figures.warning} shadowed by {getOverrideSourceLabel(overriddenBy)}}; + }; let t4; if ($[8] !== sortedAgents || $[9] !== source) { bb0: { const nonBuiltIn = sortedAgents.filter(_temp2); if (source === "all") { - t4 = AGENT_SOURCE_GROUPS.filter(_temp3).flatMap(t5 => { + t4 = AGENT_SOURCE_GROUPS.flatMap(t5 => { const { source: groupSource } = t5; - return nonBuiltIn.filter(a_0 => a_0.source === groupSource); + return sortedAgents.filter(a_0 => a_0.source === groupSource); }); break bb0; } + if (source === "built-in") { + t4 = sortedAgents; + break bb0; + } t4 = nonBuiltIn; } $[8] = sortedAgents; @@ -264,16 +260,7 @@ export function AgentsList(t0) { } else { t27 = $[64]; } - let t28; - if ($[65] !== handleKeyDown || $[66] !== t23 || $[67] !== t27) { - t28 = {t23}{t24}{t25}{t26}{t27}; - $[65] = handleKeyDown; - $[66] = t23; - $[67] = t27; - $[68] = t28; - } else { - t28 = $[68]; - } + const t28 = Current session agent: {activeAgentName ?? "none"}{t23}{t24}{t25}{t26}{t27}; let t29; if ($[69] !== onBack || $[70] !== sourceTitle || $[71] !== t28) { t29 = {t28}; @@ -300,13 +287,7 @@ export function AgentsList(t0) { t18 = `${t23} agents`; t19 = onBack; t20 = true; - if ($[75] !== changes) { - t21 = changes && changes.length > 0 && {changes[changes.length - 1]}; - $[75] = changes; - $[76] = t21; - } else { - t21 = $[76]; - } + t21 = <>Current session agent: {activeAgentName ?? "none"}{changes && changes.length > 0 && {changes[changes.length - 1]}}; T0 = Box; t11 = "column"; t12 = 0; diff --git a/src/components/agents/AgentsMenu.test.tsx b/src/components/agents/AgentsMenu.test.tsx new file mode 100644 index 0000000000..cc30b430e1 --- /dev/null +++ b/src/components/agents/AgentsMenu.test.tsx @@ -0,0 +1,358 @@ +import { PassThrough } from 'node:stream' + +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import React from 'react' +import stripAnsi from 'strip-ansi' + +import { createRoot } from '../../ink.js' +import { + getDefaultAppState, + AppStateProvider, + useSetAppState, +} from '../../state/AppState.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' +import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' +import * as realUseMergedTools from '../../hooks/useMergedTools.js' +import type { ModeState } from './types.js' + +const SYNC_START = '\x1B[?2026h' +const SYNC_END = '\x1B[?2026l' + +function extractLastFrame(output: string): string { + let lastFrame: string | null = null + let cursor = 0 + + while (cursor < output.length) { + const start = output.indexOf(SYNC_START, cursor) + if (start === -1) break + const contentStart = start + SYNC_START.length + const end = output.indexOf(SYNC_END, contentStart) + if (end === -1) break + const frame = output.slice(contentStart, end) + if (frame.trim().length > 0) lastFrame = frame + cursor = end + SYNC_END.length + } + + return lastFrame ?? output +} + +function createTestStreams() { + let output = '' + const stdout = new PassThrough() + const stdin = new PassThrough() as PassThrough & { + isTTY: boolean + setRawMode: () => void + ref: () => void + unref: () => void + } + stdin.isTTY = true + stdin.setRawMode = () => {} + stdin.ref = () => {} + stdin.unref = () => {} + ;(stdout as unknown as { columns: number }).columns = 120 + stdout.on('data', chunk => { + output += chunk.toString() + }) + return { stdout, stdin, getOutput: () => output } +} + +async function waitForOutput( + getOutput: () => string, + predicate: (frame: string) => boolean, +): Promise { + const startedAt = Date.now() + let frame = '' + + while (Date.now() - startedAt < 2500) { + frame = stripAnsi(extractLastFrame(getOutput())) + if (predicate(frame)) return frame + await Bun.sleep(10) + } + + throw new Error(`Timed out waiting for agents menu output:\n${frame}`) +} + +function createAgent( + agentType: string, + source: AgentDefinition['source'] = 'userSettings', +): AgentDefinition { + return { + agentType, + whenToUse: `Use ${agentType}`, + source, + getSystemPrompt: () => `You are ${agentType}`, + } +} + +type AgentsMenuComponent = typeof import('./AgentsMenu.js').AgentsMenu + +async function importAgentsMenu(): Promise { + const nonce = `${Date.now()}-${Math.random()}` + return (await import(`./AgentsMenu.js?agents-menu-test=${nonce}`)).AgentsMenu +} + +function AgentsMenuHarness({ + AgentsMenu, + initialModeState, + onSetActiveAgent, +}: { + AgentsMenu: AgentsMenuComponent + initialModeState: ModeState + onSetActiveAgent?: (agent: AgentDefinition) => void +}) { + const setAppState = useSetAppState() + + return ( + {}} + initialModeState={initialModeState} + onSetActiveAgent={agent => { + onSetActiveAgent?.(agent) + setAppState(state => ({ + ...state, + agent: agent.agentType, + })) + }} + /> + ) +} + +beforeEach(async () => { + await acquireSharedMutationLock('components/agents/AgentsMenu.test.tsx') + mock.module('../../hooks/useMergedTools.js', () => ({ + ...realUseMergedTools, + useMergedTools: () => [], + })) +}) + +afterEach(() => { + try { + mock.restore() + mock.module('../../hooks/useMergedTools.js', () => realUseMergedTools) + } finally { + releaseSharedMutationLock() + } +}) + +test('sets a different active session agent from the agent menu', async () => { + const AgentsMenu = await importAgentsMenu() + const reviewer = createAgent('reviewer') + const analyzer = createAgent('analyzer') + const initialState = { + ...getDefaultAppState(), + agent: 'reviewer', + agentDefinitions: { + activeAgents: [reviewer, analyzer], + allAgents: [reviewer, analyzer], + }, + } + const { stdout, stdin, getOutput } = createTestStreams() + const root = await createRoot({ + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + let callbackAgent: AgentDefinition | undefined + let latestAgent = initialState.agent + + root.render( + { + latestAgent = newState.agent + }} + > + { + callbackAgent = agent + }} + /> + , + ) + + try { + await waitForOutput(getOutput, frame => + frame.includes('Set as active agent'), + ) + + stdin.write('2') + + await waitForOutput(getOutput, frame => + frame.includes('Active session agent set to: analyzer'), + ) + + expect(callbackAgent?.agentType).toBe('analyzer') + expect(latestAgent).toBe('analyzer') + } finally { + root.unmount() + stdin.end() + stdout.end() + } +}) + +test('sets the effective agent definition for a shadowed selected row', async () => { + const AgentsMenu = await importAgentsMenu() + const builtInReviewer = createAgent('reviewer', 'built-in') + const userReviewer = createAgent('reviewer') + const planner = createAgent('planner') + const initialState = { + ...getDefaultAppState(), + agent: 'planner', + agentDefinitions: { + activeAgents: [userReviewer, planner], + allAgents: [builtInReviewer, userReviewer, planner], + }, + } + const { stdout, stdin, getOutput } = createTestStreams() + const root = await createRoot({ + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + let callbackAgent: AgentDefinition | undefined + let latestAgent = initialState.agent + + root.render( + { + latestAgent = newState.agent + }} + > + { + callbackAgent = agent + }} + /> + , + ) + + try { + await waitForOutput(getOutput, frame => + frame.includes('Set as active agent'), + ) + + stdin.write('2') + + const output = await waitForOutput(getOutput, frame => + frame.includes('Active session agent set to: reviewer'), + ) + + expect(output).toContain('Current session agent: reviewer') + expect(callbackAgent).toBe(userReviewer) + expect(latestAgent).toBe('reviewer') + } finally { + root.unmount() + stdin.end() + stdout.end() + } +}) + +test('shows active-agent status instead of duplicate set-active action', async () => { + const AgentsMenu = await importAgentsMenu() + const reviewer = createAgent('reviewer') + const analyzer = createAgent('analyzer') + const initialState = { + ...getDefaultAppState(), + agent: 'reviewer', + agentDefinitions: { + activeAgents: [reviewer, analyzer], + allAgents: [reviewer, analyzer], + }, + } + const { stdout, stdin, getOutput } = createTestStreams() + const root = await createRoot({ + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + + root.render( + + {}} + initialModeState={{ + mode: 'agent-menu', + agent: reviewer, + previousMode: { mode: 'list-agents', source: 'all' }, + }} + /> + , + ) + + try { + const output = await waitForOutput(getOutput, frame => + frame.includes('Active agent'), + ) + + expect(output).not.toContain('Set as active agent') + } finally { + root.unmount() + stdin.end() + stdout.end() + } +}) + +test('omits set-active action when no session setter is available', async () => { + const AgentsMenu = await importAgentsMenu() + const reviewer = createAgent('reviewer') + const analyzer = createAgent('analyzer') + const initialState = { + ...getDefaultAppState(), + agent: 'reviewer', + agentDefinitions: { + activeAgents: [reviewer, analyzer], + allAgents: [reviewer, analyzer], + }, + } + const { stdout, stdin, getOutput } = createTestStreams() + const root = await createRoot({ + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + + root.render( + + {}} + initialModeState={{ + mode: 'agent-menu', + agent: analyzer, + previousMode: { mode: 'list-agents', source: 'all' }, + }} + /> + , + ) + + try { + const output = await waitForOutput(getOutput, frame => + frame.includes('View agent'), + ) + + expect(output).not.toContain('Set as active agent') + expect(output).not.toContain('Active agent') + } finally { + root.unmount() + stdin.end() + stdout.end() + } +}) diff --git a/src/components/agents/AgentsMenu.tsx b/src/components/agents/AgentsMenu.tsx index 093ff4de48..b608f737b9 100644 --- a/src/components/agents/AgentsMenu.tsx +++ b/src/components/agents/AgentsMenu.tsx @@ -27,16 +27,20 @@ type Props = { onExit: (result?: string, options?: { display?: CommandResultDisplay; }) => void; + onSetActiveAgent?: (agent: AgentDefinition) => void; + initialModeState?: ModeState; }; export function AgentsMenu(t0) { const $ = _c(157); const { tools, - onExit + onExit, + onSetActiveAgent, + initialModeState } = t0; let t1; if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { + t1 = initialModeState ?? { mode: "list-agents", source: "all" }; @@ -48,6 +52,7 @@ export function AgentsMenu(t0) { const agentDefinitions = useAppState(_temp); const mcpTools = useAppState(_temp2); const toolPermissionContext = useAppState(_temp3); + const activeAgentName = useAppState(s => s.agent); const setAppState = useSetAppState(); const { allAgents, @@ -250,18 +255,7 @@ export function AgentsMenu(t0) { } else { t17 = $[39]; } - let t18; - if ($[40] !== changes || $[41] !== modeState.source || $[42] !== resolvedAgents || $[43] !== t15 || $[44] !== t16) { - t18 = ; - $[40] = changes; - $[41] = modeState.source; - $[42] = resolvedAgents; - $[43] = t15; - $[44] = t16; - $[45] = t18; - } else { - t18 = $[45]; - } + const t18 = ; let t19; if ($[46] === Symbol.for("react.memo_cache_sentinel")) { t19 = ; @@ -326,93 +320,75 @@ export function AgentsMenu(t0) { const freshAgent_1 = t13; const agentToUse = freshAgent_1 || modeState.agent; const isEditable = agentToUse.source !== "built-in" && agentToUse.source !== "plugin" && agentToUse.source !== "flagSettings"; - let t14; - if ($[60] === Symbol.for("react.memo_cache_sentinel")) { - t14 = { - label: "View agent", - value: "view" - }; - $[60] = t14; - } else { - t14 = $[60]; - } - let t15; - if ($[61] !== isEditable) { - t15 = isEditable ? [{ - label: "Edit agent", - value: "edit" - }, { - label: "Delete agent", - value: "delete" - }] : []; - $[61] = isEditable; - $[62] = t15; - } else { - t15 = $[62]; - } - let t16; - if ($[63] === Symbol.for("react.memo_cache_sentinel")) { - t16 = { - label: "Back", - value: "back" - }; - $[63] = t16; - } else { - t16 = $[63]; - } - let t17; - if ($[64] !== t15) { - t17 = [t14, ...t15, t16]; - $[64] = t15; - $[65] = t17; - } else { - t17 = $[65]; - } - const menuItems = t17; - let t18; - if ($[66] !== agentToUse || $[67] !== modeState) { - t18 = value_0 => { - bb129: switch (value_0) { - case "view": - { - setModeState({ - mode: "view-agent", - agent: agentToUse, - previousMode: modeState.previousMode - }); - break bb129; - } - case "edit": - { - setModeState({ - mode: "edit-agent", - agent: agentToUse, - previousMode: modeState - }); - break bb129; - } - case "delete": - { - setModeState({ - mode: "delete-confirm", - agent: agentToUse, - previousMode: modeState - }); - break bb129; - } - case "back": - { - setModeState(modeState.previousMode); - } - } - }; - $[66] = agentToUse; - $[67] = modeState; - $[68] = t18; - } else { - t18 = $[68]; - } - const handleMenuSelect = t18; + const isActiveAgent = agentToUse.agentType === activeAgentName; + const sessionAgentToUse = agents.find(a_10 => a_10.agentType === agentToUse.agentType) ?? agentToUse; + const editableItems = isEditable ? [{ + label: "Edit agent", + value: "edit" + }, { + label: "Delete agent", + value: "delete" + }] : []; + const activeAgentItems = isActiveAgent ? [{ + label: "Active agent", + value: "active-agent", + disabled: true + }] : onSetActiveAgent ? [{ + label: "Set as active agent", + value: "set-active" + }] : []; + const menuItems = [{ + label: "View agent", + value: "view" + }, ...activeAgentItems, ...editableItems, { + label: "Back", + value: "back" + }]; + const handleMenuSelect = value_0 => { + bb129: switch (value_0) { + case "view": + { + setModeState({ + mode: "view-agent", + agent: agentToUse, + previousMode: modeState.previousMode + }); + break bb129; + } + case "set-active": + { + onSetActiveAgent?.(sessionAgentToUse); + setChanges(prev => [...prev, `Active session agent set to: ${chalk.bold(sessionAgentToUse.agentType)}`]); + setModeState({ + mode: "list-agents", + source: "all" + }); + break bb129; + } + case "edit": + { + setModeState({ + mode: "edit-agent", + agent: agentToUse, + previousMode: modeState + }); + break bb129; + } + case "delete": + { + setModeState({ + mode: "delete-confirm", + agent: agentToUse, + previousMode: modeState + }); + break bb129; + } + case "back": + { + setModeState(modeState.previousMode); + } + } + }; let t19; if ($[69] !== modeState.previousMode) { t19 = () => setModeState(modeState.previousMode); diff --git a/src/main.tsx b/src/main.tsx index acdd052148..60767c0629 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2020,6 +2020,8 @@ async function run(): Promise { // NOTE: Model resolution happens after setup() to ensure trust is established before AWS auth const userSpecifiedModel = options.model === 'default' ? getDefaultMainLoopModel() : options.model; const userSpecifiedFallbackModel = fallbackModel === 'default' ? getDefaultMainLoopModel() : fallbackModel; + const hasExplicitModelOverride = userSpecifiedModel !== undefined; + const baseMainLoopModel = userSpecifiedModel ?? getUserSpecifiedModelSetting() ?? null; // Reuse preSetupCwd unless setup() chdir'd (worktreeEnabled). Saves a // getCwd() syscall in the common path. @@ -3072,6 +3074,8 @@ async function run(): Promise { mcpClients, autoConnectIdeFlag: ide, mainThreadAgentDefinition, + baseMainLoopModel, + hasExplicitModelOverride, disableSlashCommands, dynamicMcpConfig, strictMcpConfig, diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index f2aeb5d260..8d7109fb42 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -29,7 +29,7 @@ import { startPreventSleep, stopPreventSleep } from '../services/preventSleep.js import { useTerminalNotification } from '../ink/useTerminalNotification.js'; import { hasCursorUpViewportYankBug } from '../ink/terminal.js'; import { createFileStateCacheWithSizeLimit, mergeFileStateCaches, READ_FILE_STATE_CACHE_SIZE } from '../utils/fileStateCache.js'; -import { updateLastInteractionTime, getLastInteractionTime, getOriginalCwd, getProjectRoot, getSessionId, switchSession, setCostStateForRestore, getTurnHookDurationMs, getTurnHookCount, resetTurnHookDuration, getTurnToolDurationMs, getTurnToolCount, resetTurnToolDuration, getTurnClassifierDurationMs, getTurnClassifierCount, resetTurnClassifierDuration } from '../bootstrap/state.js'; +import { updateLastInteractionTime, getLastInteractionTime, getOriginalCwd, getProjectRoot, getSessionId, switchSession, setCostStateForRestore, getTurnHookDurationMs, getTurnHookCount, resetTurnHookDuration, getTurnToolDurationMs, getTurnToolCount, resetTurnToolDuration, getTurnClassifierDurationMs, getTurnClassifierCount, resetTurnClassifierDuration, setMainLoopModelOverride, setMainThreadAgentType } from '../bootstrap/state.js'; import { asSessionId, asAgentId } from '../types/ids.js'; import { logForDebugging } from '../utils/debug.js'; import { QueryGuard } from '../utils/QueryGuard.js'; @@ -175,7 +175,7 @@ import type { ContentBlockParam, ImageBlockParam } from '@anthropic-ai/sdk/resou import type { ProcessUserInputContext } from '../utils/processUserInput/processUserInput.js'; import type { PastedContent } from '../utils/config.js'; import { copyPlanForFork, copyPlanForResume, getPlanSlug, setPlanSlug } from '../utils/plans.js'; -import { clearSessionMetadata, resetSessionFilePointer, adoptResumedSessionFile, removeTranscriptMessage, restoreSessionMetadata, getCurrentSessionTitle, isEphemeralToolProgress, isLoggableMessage, saveWorktreeState, getAgentTranscript } from '../utils/sessionStorage.js'; +import { clearSessionMetadata, resetSessionFilePointer, adoptResumedSessionFile, removeTranscriptMessage, restoreSessionMetadata, getCurrentSessionTitle, isEphemeralToolProgress, isLoggableMessage, saveWorktreeState, getAgentTranscript, saveAgentSetting } from '../utils/sessionStorage.js'; import { deserializeMessages } from '../utils/conversationRecovery.js'; import { extractReadFilesFromMessages, extractBashToolsFromMessages } from '../utils/queryHelpers.js'; import { resetMicrocompactState } from '../services/compact/microCompact.js'; @@ -188,10 +188,13 @@ import { fileHistoryMakeSnapshot, type FileHistoryState, fileHistoryRewind, type import { type AttributionState, incrementPromptCount } from '../utils/commitAttribution.js'; import { recordAttributionSnapshot } from '../utils/sessionStorage.js'; import { computeStandaloneAgentContext, restoreAgentFromSession, restoreSessionStateFromLog, restoreWorktreeForResume, exitRestoredWorktree } from '../utils/sessionRestore.js'; +import { notifySessionMetadataChanged } from '../utils/sessionState.js'; import { isBgSession, updateSessionName, updateSessionActivity } from '../utils/concurrentSessions.js'; import { isInProcessTeammateTask, type InProcessTeammateTaskState } from '../tasks/InProcessTeammateTask/types.js'; import { restoreRemoteAgentTasks } from '../tasks/RemoteAgentTask/RemoteAgentTask.js'; import { useInboxPoller } from '../hooks/useInboxPoller.js'; +import { getActiveSessionAgentModelSelection } from './replActiveAgentModel.js'; +import type { ModelSetting } from '../utils/model/model.js'; // Dead code elimination: conditional import for loop mode /* eslint-disable @typescript-eslint/no-require-imports */ const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/index.js') : null; @@ -563,6 +566,10 @@ export type Props = { disabled?: boolean; // Optional agent definition to use for the main thread mainThreadAgentDefinition?: AgentDefinition; + // The effective non-agent model to restore when switching to an agent that inherits + baseMainLoopModel?: ModelSetting; + // True when startup had an explicit model override that agent switching must preserve + hasExplicitModelOverride?: boolean; // When true, disables all slash commands disableSlashCommands?: boolean; // Task list id: when set, enables tasks mode that watches a task list and auto-processes tasks. @@ -597,6 +604,8 @@ export function REPL({ onTurnComplete, disabled = false, mainThreadAgentDefinition: initialMainThreadAgentDefinition, + baseMainLoopModel = null, + hasExplicitModelOverride = false, disableSlashCommands = false, taskListId, remoteSessionConfig, @@ -673,6 +682,36 @@ export function REPL({ const store = useAppStateStore(); const terminal = useTerminalNotification(); const mainLoopModel = useMainLoopModel(); + const appMainLoopModel = useAppState(s => s.mainLoopModel); + const appMainLoopModelForSession = useAppState(s => s.mainLoopModelForSession); + const initialAgentModelSelection = !hasExplicitModelOverride && initialMainThreadAgentDefinition?.model && initialMainThreadAgentDefinition.model !== 'inherit' ? getActiveSessionAgentModelSelection({ + agent: initialMainThreadAgentDefinition, + baseMainLoopModel, + hasExplicitModelOverride, + hasAgentManagedModel: false + }) : undefined; + const explicitModelOverrideRef = useRef(hasExplicitModelOverride); + const baseMainLoopModelRef = useRef(baseMainLoopModel); + const agentManagedModelRef = useRef(initialAgentModelSelection?.shouldUpdateModel ? initialAgentModelSelection.mainLoopModelForSession : undefined); + const previousMainLoopModelForSessionRef = useRef(appMainLoopModelForSession); + const didTrackInitialModelStateRef = useRef(false); + useEffect(() => { + const previousMainLoopModelForSession = previousMainLoopModelForSessionRef.current; + previousMainLoopModelForSessionRef.current = appMainLoopModelForSession; + + if (!didTrackInitialModelStateRef.current) { + didTrackInitialModelStateRef.current = true; + return; + } + + const currentEffectiveModelSetting = appMainLoopModelForSession ?? appMainLoopModel; + const clearedAgentManagedSessionModel = previousMainLoopModelForSession !== null && appMainLoopModelForSession === null; + if (!clearedAgentManagedSessionModel && currentEffectiveModelSetting === agentManagedModelRef.current) return; + + explicitModelOverrideRef.current = true; + baseMainLoopModelRef.current = currentEffectiveModelSetting; + agentManagedModelRef.current = undefined; + }, [appMainLoopModel, appMainLoopModelForSession]); // Note: standaloneAgentContext is initialized in main.tsx (via initialState) or // ResumeConversation.tsx (via setAppState before rendering REPL) to avoid @@ -2558,6 +2597,31 @@ export function REPL({ }, resume, setConversationId, + setActiveSessionAgent: agent => { + const modelSelection = getActiveSessionAgentModelSelection({ + agent, + baseMainLoopModel: baseMainLoopModelRef.current, + hasExplicitModelOverride: explicitModelOverrideRef.current, + hasAgentManagedModel: agentManagedModelRef.current !== undefined + }); + setMainThreadAgentDefinition(agent); + setMainThreadAgentType(agent.agentType); + saveAgentSetting(agent.agentType); + if (modelSelection.shouldUpdateModel) { + agentManagedModelRef.current = modelSelection.mainLoopModelForSession; + setMainLoopModelOverride(modelSelection.mainLoopModelForSession); + notifySessionMetadataChanged({ + model: modelSelection.mainLoopModelForSession + }); + } + setAppState(prev => ({ + ...prev, + agent: agent.agentType, + ...(modelSelection.shouldUpdateModel ? { + mainLoopModelForSession: modelSelection.mainLoopModelForSession + } : {}) + })); + }, requestPrompt: feature('HOOK_PROMPTS') ? requestPrompt : undefined, contentReplacementState: contentReplacementStateRef.current, syncToolResultReplacements diff --git a/src/screens/ResumeConversation.tsx b/src/screens/ResumeConversation.tsx index c6aac98731..ed78c17e54 100644 --- a/src/screens/ResumeConversation.tsx +++ b/src/screens/ResumeConversation.tsx @@ -31,6 +31,7 @@ import { logError } from '../utils/log.js'; import { createSystemMessage } from '../utils/messages.js'; import { computeStandaloneAgentContext, restoreAgentFromSession, restoreWorktreeForResume } from '../utils/sessionRestore.js'; import { adoptResumedSessionFile, enrichLogs, isCustomTitleEnabled, loadAllProjectsMessageLogsProgressive, loadSameRepoMessageLogsProgressive, recordContentReplacement, resetSessionFilePointer, restoreSessionMetadata, type SessionLogResult } from '../utils/sessionStorage.js'; +import type { ModelSetting } from '../utils/model/model.js'; import type { ThinkingConfig } from '../utils/thinking.js'; import type { ContentReplacementRecord } from '../utils/toolResultStorage.js'; import { REPL } from './REPL.js'; @@ -53,6 +54,8 @@ type Props = { dynamicMcpConfig?: Record; debug: boolean; mainThreadAgentDefinition?: AgentDefinition; + baseMainLoopModel?: ModelSetting; + hasExplicitModelOverride?: boolean; autoConnectIdeFlag?: boolean; strictMcpConfig?: boolean; systemPrompt?: string; @@ -73,6 +76,8 @@ export function ResumeConversation({ dynamicMcpConfig, debug, mainThreadAgentDefinition, + baseMainLoopModel, + hasExplicitModelOverride, autoConnectIdeFlag, strictMcpConfig = false, systemPrompt, @@ -298,7 +303,7 @@ export function ResumeConversation({ return ; } if (resumeData) { - return ; + return ; } if (loading) { return diff --git a/src/screens/replActiveAgentModel.test.ts b/src/screens/replActiveAgentModel.test.ts new file mode 100644 index 0000000000..ef33c5d2dc --- /dev/null +++ b/src/screens/replActiveAgentModel.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'bun:test' + +import { getActiveSessionAgentModelSelection } from './replActiveAgentModel.js' +import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' + +function createAgent(model?: string): AgentDefinition { + return { + agentType: 'agent', + whenToUse: 'Use agent', + source: 'userSettings', + getSystemPrompt: () => 'You are agent', + model, + } +} + +describe('getActiveSessionAgentModelSelection', () => { + test('applies the selected agent model when no explicit model override exists', () => { + const selection = getActiveSessionAgentModelSelection({ + agent: createAgent('agent-specific-model'), + baseMainLoopModel: 'sonnet', + hasExplicitModelOverride: false, + hasAgentManagedModel: false, + }) + + expect(selection.shouldUpdateModel).toBe(true) + expect(selection.mainLoopModelForSession).toBe('agent-specific-model') + }) + + test('preserves an explicit model override when selecting an agent with a model', () => { + const selection = getActiveSessionAgentModelSelection({ + agent: createAgent('agent-specific-model'), + baseMainLoopModel: 'opus', + hasExplicitModelOverride: true, + hasAgentManagedModel: false, + }) + + expect(selection.shouldUpdateModel).toBe(false) + expect(selection.mainLoopModelForSession).toBeUndefined() + }) + + test('uses the base model when selecting an inheriting agent after an agent-managed model', () => { + const selection = getActiveSessionAgentModelSelection({ + agent: createAgent('inherit'), + baseMainLoopModel: 'sonnet', + hasExplicitModelOverride: false, + hasAgentManagedModel: true, + }) + + expect(selection.shouldUpdateModel).toBe(true) + expect(selection.mainLoopModelForSession).toBe('sonnet') + }) + + test('leaves the model untouched for an inheriting agent with no agent-managed model to clear', () => { + const selection = getActiveSessionAgentModelSelection({ + agent: createAgent('inherit'), + baseMainLoopModel: 'sonnet', + hasExplicitModelOverride: false, + hasAgentManagedModel: false, + }) + + expect(selection.shouldUpdateModel).toBe(false) + expect(selection.mainLoopModelForSession).toBeUndefined() + }) +}) diff --git a/src/screens/replActiveAgentModel.ts b/src/screens/replActiveAgentModel.ts new file mode 100644 index 0000000000..6a5890cd43 --- /dev/null +++ b/src/screens/replActiveAgentModel.ts @@ -0,0 +1,49 @@ +import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' +import { + getDefaultMainLoopModelSetting, + type ModelSetting, + parseUserSpecifiedModel, +} from '../utils/model/model.js' + +type ActiveSessionAgentModelSelection = + | { + shouldUpdateModel: false + mainLoopModelForSession?: undefined + } + | { + shouldUpdateModel: true + mainLoopModelForSession: ModelSetting + } + +export function getActiveSessionAgentModelSelection({ + agent, + baseMainLoopModel, + hasExplicitModelOverride, + hasAgentManagedModel, +}: { + agent: AgentDefinition + baseMainLoopModel: ModelSetting | undefined + hasExplicitModelOverride: boolean + hasAgentManagedModel: boolean +}): ActiveSessionAgentModelSelection { + if (hasExplicitModelOverride) { + return { shouldUpdateModel: false } + } + + if (agent.model && agent.model !== 'inherit') { + return { + shouldUpdateModel: true, + mainLoopModelForSession: parseUserSpecifiedModel(agent.model), + } + } + + if (!hasAgentManagedModel) { + return { shouldUpdateModel: false } + } + + return { + shouldUpdateModel: true, + mainLoopModelForSession: + baseMainLoopModel ?? getDefaultMainLoopModelSetting(), + } +} diff --git a/src/state/AppStateStore.ts b/src/state/AppStateStore.ts index f4324ea26a..580363ed76 100644 --- a/src/state/AppStateStore.ts +++ b/src/state/AppStateStore.ts @@ -109,7 +109,8 @@ export type AppState = DeepImmutable<{ footerSelection: FooterItem | null toolPermissionContext: ToolPermissionContext spinnerTip?: string - // Agent name from --agent CLI flag or settings (for logo display) + // Active main-thread agent name for this session. Initially sourced from + // --agent/settings; runtime menu changes update it alongside REPL state. agent: string | undefined // Assistant mode fully enabled (settings + GrowthBook gate + trust). // Single source of truth - computed once in main.tsx before option diff --git a/src/tools/BashTool/BashTool.errorOutput.test.ts b/src/tools/BashTool/BashTool.errorOutput.test.ts index dc7f73a3d9..81c2f4ff4c 100644 --- a/src/tools/BashTool/BashTool.errorOutput.test.ts +++ b/src/tools/BashTool/BashTool.errorOutput.test.ts @@ -48,8 +48,8 @@ describe('BashTool error output (#1231)', () => { }) test('"command not found" message reaches the formatted error', async () => { - const err = await expectShellError('no_such_command_xyz_1231') - expect(err.code).not.toBe(0) + const err = await expectShellError('printf "not found\\n" >&2; exit 127') + expect(err.code).toBe(127) const formatted = formatError(err) expect(formatted).toContain(`Exit code ${err.code}`) expect(formatted.toLowerCase()).toContain('not found') diff --git a/src/types/command.ts b/src/types/command.ts index e88389e2ca..e621ee2390 100644 --- a/src/types/command.ts +++ b/src/types/command.ts @@ -4,6 +4,7 @@ import type { CanUseToolFn } from '../hooks/useCanUseTool.js' import type { CompactionResult } from '../services/compact/compact.js' import type { ScopedMcpServerConfig } from '../services/mcp/types.js' import type { ToolUseContext } from '../Tool.js' +import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' import type { EffortValue } from '../utils/effort.js' import type { IDEExtensionInstallationStatus, IdeType } from '../utils/ide.js' import type { SettingSource } from '../utils/settings/constants.js' @@ -90,6 +91,7 @@ export type LocalJSXCommandContext = ToolUseContext & { config: Record, ) => void onInstallIDEExtension?: (ide: IdeType) => void + setActiveSessionAgent?: (agent: AgentDefinition) => void resume?: ( sessionId: UUID, log: LogOption,