From be7c7bb83d73a88cf3c5213f62fd063fa36d8631 Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:11:20 -0400 Subject: [PATCH] fix(cli): resolve subagent grouping and UI state persistence (#22252) --- .../messages/SubagentGroupDisplay.test.tsx | 120 ++++++++ .../messages/SubagentGroupDisplay.tsx | 269 ++++++++++++++++++ .../messages/SubagentProgressDisplay.test.tsx | 16 +- .../messages/SubagentProgressDisplay.tsx | 27 +- .../components/messages/ToolGroupMessage.tsx | 58 +++- .../components/messages/ToolResultDisplay.tsx | 7 +- .../SubagentGroupDisplay.test.tsx.snap | 9 + .../SubagentProgressDisplay.test.tsx.snap | 28 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 70 +++-- .../core/src/agents/local-invocation.test.ts | 30 +- packages/core/src/agents/local-invocation.ts | 28 +- packages/core/src/agents/types.ts | 2 + packages/core/src/index.ts | 1 + 13 files changed, 596 insertions(+), 69 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/SubagentGroupDisplay.test.tsx.snap diff --git a/packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx b/packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx new file mode 100644 index 0000000000..197b78e356 --- /dev/null +++ b/packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { waitFor } from '../../../test-utils/async.js'; +import { render } from '../../../test-utils/render.js'; +import { SubagentGroupDisplay } from './SubagentGroupDisplay.js'; +import { Kind, CoreToolCallStatus } from '@google/gemini-cli-core'; +import type { IndividualToolCallDisplay } from '../../types.js'; +import { KeypressProvider } from '../../contexts/KeypressContext.js'; +import { OverflowProvider } from '../../contexts/OverflowContext.js'; +import { vi } from 'vitest'; +import { Text } from 'ink'; + +vi.mock('../../utils/MarkdownDisplay.js', () => ({ + MarkdownDisplay: ({ text }: { text: string }) => {text}, +})); + +describe('', () => { + const mockToolCalls: IndividualToolCallDisplay[] = [ + { + callId: 'call-1', + name: 'agent_1', + description: 'Test agent 1', + confirmationDetails: undefined, + status: CoreToolCallStatus.Executing, + kind: Kind.Agent, + resultDisplay: { + isSubagentProgress: true, + agentName: 'api-monitor', + state: 'running', + recentActivity: [ + { + id: 'act-1', + type: 'tool_call', + status: 'running', + content: '', + displayName: 'Action Required', + description: 'Verify server is running', + }, + ], + }, + }, + { + callId: 'call-2', + name: 'agent_2', + description: 'Test agent 2', + confirmationDetails: undefined, + status: CoreToolCallStatus.Success, + kind: Kind.Agent, + resultDisplay: { + isSubagentProgress: true, + agentName: 'db-manager', + state: 'completed', + result: 'Database schema validated', + recentActivity: [ + { + id: 'act-2', + type: 'thought', + status: 'completed', + content: 'Database schema validated', + }, + ], + }, + }, + ]; + + const renderSubagentGroup = ( + toolCallsToRender: IndividualToolCallDisplay[], + height?: number, + ) => ( + + + + + + ); + + it('renders nothing if there are no agent tool calls', async () => { + const { lastFrame } = render(renderSubagentGroup([], 40)); + expect(lastFrame({ allowEmpty: true })).toBe(''); + }); + + it('renders collapsed view by default with correct agent counts and states', async () => { + const { lastFrame, waitUntilReady } = render( + renderSubagentGroup(mockToolCalls, 40), + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('expands when availableTerminalHeight is undefined', async () => { + const { lastFrame, rerender } = render( + renderSubagentGroup(mockToolCalls, 40), + ); + + // Default collapsed view + await waitFor(() => { + expect(lastFrame()).toContain('(ctrl+o to expand)'); + }); + + // Expand view + rerender(renderSubagentGroup(mockToolCalls, undefined)); + await waitFor(() => { + expect(lastFrame()).toContain('(ctrl+o to collapse)'); + }); + + // Collapse view + rerender(renderSubagentGroup(mockToolCalls, 40)); + await waitFor(() => { + expect(lastFrame()).toContain('(ctrl+o to expand)'); + }); + }); +}); diff --git a/packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx b/packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx new file mode 100644 index 0000000000..2d3f8a44c8 --- /dev/null +++ b/packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx @@ -0,0 +1,269 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useEffect, useId } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import type { IndividualToolCallDisplay } from '../../types.js'; +import { + isSubagentProgress, + checkExhaustive, + type SubagentActivityItem, +} from '@google/gemini-cli-core'; +import { + SubagentProgressDisplay, + formatToolArgs, +} from './SubagentProgressDisplay.js'; +import { useOverflowActions } from '../../contexts/OverflowContext.js'; + +export interface SubagentGroupDisplayProps { + toolCalls: IndividualToolCallDisplay[]; + availableTerminalHeight?: number; + terminalWidth: number; + borderColor?: string; + borderDimColor?: boolean; + isFirst?: boolean; + isExpandable?: boolean; +} + +export const SubagentGroupDisplay: React.FC = ({ + toolCalls, + availableTerminalHeight, + terminalWidth, + borderColor, + borderDimColor, + isFirst, + isExpandable = true, +}) => { + const isExpanded = availableTerminalHeight === undefined; + const overflowActions = useOverflowActions(); + const uniqueId = useId(); + const overflowId = `subagent-${uniqueId}`; + + useEffect(() => { + if (isExpandable && overflowActions) { + // Register with the global overflow system so "ctrl+o to expand" shows in the sticky footer + // and AppContainer passes the shortcut through. + overflowActions.addOverflowingId(overflowId); + } + return () => { + if (overflowActions) { + overflowActions.removeOverflowingId(overflowId); + } + }; + }, [isExpandable, overflowActions, overflowId]); + + if (toolCalls.length === 0) { + return null; + } + + let headerText = ''; + if (toolCalls.length === 1) { + const singleAgent = toolCalls[0].resultDisplay; + if (isSubagentProgress(singleAgent)) { + switch (singleAgent.state) { + case 'completed': + headerText = 'Agent Completed'; + break; + case 'cancelled': + headerText = 'Agent Cancelled'; + break; + case 'error': + headerText = 'Agent Error'; + break; + default: + headerText = 'Running Agent...'; + break; + } + } else { + headerText = 'Running Agent...'; + } + } else { + let completedCount = 0; + let runningCount = 0; + for (const tc of toolCalls) { + const progress = tc.resultDisplay; + if (isSubagentProgress(progress)) { + if (progress.state === 'completed') completedCount++; + else if (progress.state === 'running') runningCount++; + } else { + // It hasn't emitted progress yet, but it is "running" + runningCount++; + } + } + + if (completedCount === toolCalls.length) { + headerText = `${toolCalls.length} Agents Completed`; + } else if (completedCount > 0) { + headerText = `${toolCalls.length} Agents (${runningCount} running, ${completedCount} completed)...`; + } else { + headerText = `Running ${toolCalls.length} Agents...`; + } + } + const toggleText = `(ctrl+o to ${isExpanded ? 'collapse' : 'expand'})`; + + const renderCollapsedRow = ( + key: string, + agentName: string, + icon: React.ReactNode, + content: string, + displayArgs?: string, + ) => ( + + + {icon} + + + + {agentName} + + + + · + + + + {content} + {displayArgs && ` ${displayArgs}`} + + + + ); + + return ( + + + + + {headerText} + + {isExpandable && {toggleText}} + + + {toolCalls.map((toolCall) => { + const progress = toolCall.resultDisplay; + + if (!isSubagentProgress(progress)) { + const agentName = toolCall.name || 'agent'; + if (!isExpanded) { + return renderCollapsedRow( + toolCall.callId, + agentName, + !, + 'Starting...', + ); + } else { + return ( + + + ! + + {agentName} + + + + Starting... + + + ); + } + } + + const lastActivity: SubagentActivityItem | undefined = + progress.recentActivity[progress.recentActivity.length - 1]; + + // Collapsed View: Show single compact line per agent + if (!isExpanded) { + let content = 'Starting...'; + let formattedArgs: string | undefined; + + if (progress.state === 'completed') { + if ( + progress.terminateReason && + progress.terminateReason !== 'GOAL' + ) { + content = `Finished Early (${progress.terminateReason})`; + } else { + content = 'Completed successfully'; + } + } else if (lastActivity) { + // Match expanded view logic exactly: + // Primary text: displayName || content + content = lastActivity.displayName || lastActivity.content; + + // Secondary text: description || formatToolArgs(args) + if (lastActivity.description) { + formattedArgs = lastActivity.description; + } else if (lastActivity.type === 'tool_call' && lastActivity.args) { + formattedArgs = formatToolArgs(lastActivity.args); + } + } + + const displayArgs = + progress.state === 'completed' ? '' : formattedArgs; + + const renderStatusIcon = () => { + const state = progress.state ?? 'running'; + switch (state) { + case 'running': + return !; + case 'completed': + return ; + case 'cancelled': + return ; + case 'error': + return ; + default: + return checkExhaustive(state); + } + }; + + return renderCollapsedRow( + toolCall.callId, + progress.agentName, + renderStatusIcon(), + lastActivity?.type === 'thought' ? `💭 ${content}` : content, + displayArgs, + ); + } + + // Expanded View: Render full history + return ( + + + + ); + })} + + ); +}; diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx index e8b67301ad..f2c57f9662 100644 --- a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx @@ -36,7 +36,7 @@ describe('', () => { }; const { lastFrame, waitUntilReady } = render( - , + , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); @@ -60,7 +60,7 @@ describe('', () => { }; const { lastFrame, waitUntilReady } = render( - , + , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); @@ -82,7 +82,7 @@ describe('', () => { }; const { lastFrame, waitUntilReady } = render( - , + , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); @@ -104,7 +104,7 @@ describe('', () => { }; const { lastFrame, waitUntilReady } = render( - , + , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); @@ -128,7 +128,7 @@ describe('', () => { }; const { lastFrame, waitUntilReady } = render( - , + , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); @@ -149,7 +149,7 @@ describe('', () => { }; const { lastFrame, waitUntilReady } = render( - , + , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); @@ -164,7 +164,7 @@ describe('', () => { }; const { lastFrame, waitUntilReady } = render( - , + , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); @@ -185,7 +185,7 @@ describe('', () => { }; const { lastFrame, waitUntilReady } = render( - , + , ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx index b34a904b3e..5d1086c759 100644 --- a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx @@ -8,18 +8,21 @@ import type React from 'react'; import { Box, Text } from 'ink'; import { theme } from '../../semantic-colors.js'; import Spinner from 'ink-spinner'; +import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; import type { SubagentProgress, SubagentActivityItem, } from '@google/gemini-cli-core'; import { TOOL_STATUS } from '../../constants.js'; import { STATUS_INDICATOR_WIDTH } from './ToolShared.js'; +import { safeJsonToMarkdown } from '@google/gemini-cli-core'; export interface SubagentProgressDisplayProps { progress: SubagentProgress; + terminalWidth: number; } -const formatToolArgs = (args?: string): string => { +export const formatToolArgs = (args?: string): string => { if (!args) return ''; try { const parsed: unknown = JSON.parse(args); @@ -54,7 +57,7 @@ const formatToolArgs = (args?: string): string => { export const SubagentProgressDisplay: React.FC< SubagentProgressDisplayProps -> = ({ progress }) => { +> = ({ progress, terminalWidth }) => { let headerText: string | undefined; let headerColor = theme.text.secondary; @@ -67,6 +70,9 @@ export const SubagentProgressDisplay: React.FC< } else if (progress.state === 'completed') { headerText = `Subagent ${progress.agentName} completed.`; headerColor = theme.status.success; + } else { + headerText = `Running subagent ${progress.agentName}...`; + headerColor = theme.text.primary; } return ( @@ -146,6 +152,23 @@ export const SubagentProgressDisplay: React.FC< return null; })} + + {progress.state === 'completed' && progress.result && ( + + {progress.terminateReason && progress.terminateReason !== 'GOAL' && ( + + + Agent Finished Early ({progress.terminateReason}) + + + )} + + + )} ); }; diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index ee3a98930f..69da3a1029 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -15,12 +15,14 @@ import type { import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js'; import { ToolMessage } from './ToolMessage.js'; import { ShellToolMessage } from './ShellToolMessage.js'; +import { SubagentGroupDisplay } from './SubagentGroupDisplay.js'; import { theme } from '../../semantic-colors.js'; import { useConfig } from '../../contexts/ConfigContext.js'; import { isShellTool } from './ToolShared.js'; import { shouldHideToolCall, CoreToolCallStatus, + Kind, } from '@google/gemini-cli-core'; import { useUIState } from '../../contexts/UIStateContext.js'; import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js'; @@ -125,12 +127,36 @@ export const ToolGroupMessage: React.FC = ({ let countToolCallsWithResults = 0; for (const tool of visibleToolCalls) { - if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') { + if ( + tool.kind !== Kind.Agent && + tool.resultDisplay !== undefined && + tool.resultDisplay !== '' + ) { countToolCallsWithResults++; } } const countOneLineToolCalls = - visibleToolCalls.length - countToolCallsWithResults; + visibleToolCalls.filter((t) => t.kind !== Kind.Agent).length - + countToolCallsWithResults; + const groupedTools = useMemo(() => { + const groups: Array< + IndividualToolCallDisplay | IndividualToolCallDisplay[] + > = []; + for (const tool of visibleToolCalls) { + if (tool.kind === Kind.Agent) { + const lastGroup = groups[groups.length - 1]; + if (Array.isArray(lastGroup)) { + lastGroup.push(tool); + } else { + groups.push([tool]); + } + } else { + groups.push(tool); + } + } + return groups; + }, [visibleToolCalls]); + const availableTerminalHeightPerToolMessage = availableTerminalHeight ? Math.max( Math.floor( @@ -167,8 +193,29 @@ export const ToolGroupMessage: React.FC = ({ width={terminalWidth} paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN} > - {visibleToolCalls.map((tool, index) => { + {groupedTools.map((group, index) => { const isFirst = index === 0; + const resolvedIsFirst = + borderTopOverride !== undefined + ? borderTopOverride && isFirst + : isFirst; + + if (Array.isArray(group)) { + return ( + + ); + } + + const tool = group; const isShellToolCall = isShellTool(tool.name); const commonProps = { @@ -176,10 +223,7 @@ export const ToolGroupMessage: React.FC = ({ availableTerminalHeight: availableTerminalHeightPerToolMessage, terminalWidth: contentWidth, emphasis: 'medium' as const, - isFirst: - borderTopOverride !== undefined - ? borderTopOverride && isFirst - : isFirst, + isFirst: resolvedIsFirst, borderColor, borderDimColor, isExpandable, diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 0bbe3446e0..3b7cfaa8da 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -102,7 +102,12 @@ export const ToolResultDisplay: React.FC = ({ ); } else if (isSubagentProgress(contentData)) { - content = ; + content = ( + + ); } else if (typeof contentData === 'string' && renderOutputAsMarkdown) { content = ( > renders collapsed view by default with correct agent counts and states 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ≡ 2 Agents (1 running, 1 completed)... (ctrl+o to expand) │ +│ ! api-monitor · Action Required Verify server is running │ +│ ✓ db-manager · 💭 Completed successfully │ +" +`; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/SubagentProgressDisplay.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/SubagentProgressDisplay.test.tsx.snap index 8a4c5bd4c4..2d31c9c652 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/SubagentProgressDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/SubagentProgressDisplay.test.tsx.snap @@ -1,7 +1,9 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[` > renders "Request cancelled." with the info icon 1`] = ` -"ℹ Request cancelled. +"Running subagent TestAgent... + +ℹ Request cancelled. " `; @@ -11,31 +13,43 @@ exports[` > renders cancelled state correctly 1`] = ` `; exports[` > renders correctly with command fallback 1`] = ` -"⠋ run_shell_command echo hello +"Running subagent TestAgent... + +⠋ run_shell_command echo hello " `; exports[` > renders correctly with description in args 1`] = ` -"⠋ run_shell_command Say hello +"Running subagent TestAgent... + +⠋ run_shell_command Say hello " `; exports[` > renders correctly with displayName and description from item 1`] = ` -"⠋ RunShellCommand Executing echo hello +"Running subagent TestAgent... + +⠋ RunShellCommand Executing echo hello " `; exports[` > renders correctly with file_path 1`] = ` -"✓ write_file /tmp/test.txt +"Running subagent TestAgent... + +✓ write_file /tmp/test.txt " `; exports[` > renders thought bubbles correctly 1`] = ` -"💭 Thinking about life +"Running subagent TestAgent... + +💭 Thinking about life " `; exports[` > truncates long args 1`] = ` -"⠋ run_shell_command This is a very long description that should definitely be tr... +"Running subagent TestAgent... + +⠋ run_shell_command This is a very long description that should definitely be tr... " `; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index c394b866ad..2034e14b87 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -38,6 +38,7 @@ import { GeminiCliOperation, getPlanModeExitMessage, isBackgroundExecutionData, + Kind, } from '@google/gemini-cli-core'; import type { Config, @@ -408,7 +409,8 @@ export const useGeminiStream = ( // Push completed tools to history as they finish useEffect(() => { const toolsToPush: TrackedToolCall[] = []; - for (const tc of toolCalls) { + for (let i = 0; i < toolCalls.length; i++) { + const tc = toolCalls[i]; if (pushedToolCallIdsRef.current.has(tc.request.callId)) continue; if ( @@ -416,6 +418,40 @@ export const useGeminiStream = ( tc.status === 'error' || tc.status === 'cancelled' ) { + // TODO(#22883): This lookahead logic is a tactical UI fix to prevent parallel agents + // from tearing visually when they finish at slightly different times. + // Architecturally, `useGeminiStream` should not be responsible for stitching + // together semantic batches using timing/refs. `packages/core` should be + // refactored to emit structured `ToolBatch` or `Turn` objects, and this layer + // should simply render those semantic boundaries. + // If this is an agent tool, look ahead to ensure all subsequent + // contiguous agents in the same batch are also finished before pushing. + const isAgent = tc.tool?.kind === Kind.Agent; + if (isAgent) { + let contigAgentsComplete = true; + for (let j = i + 1; j < toolCalls.length; j++) { + const nextTc = toolCalls[j]; + if (nextTc.tool?.kind === Kind.Agent) { + if ( + nextTc.status !== 'success' && + nextTc.status !== 'error' && + nextTc.status !== 'cancelled' + ) { + contigAgentsComplete = false; + break; + } + } else { + // End of the contiguous agent block + break; + } + } + + if (!contigAgentsComplete) { + // Wait for the entire contiguous block of agents to finish + break; + } + } + toolsToPush.push(tc); } else { // Stop at first non-terminal tool to preserve order @@ -425,27 +461,27 @@ export const useGeminiStream = ( if (toolsToPush.length > 0) { const newPushed = new Set(pushedToolCallIdsRef.current); - let isFirst = isFirstToolInGroupRef.current; for (const tc of toolsToPush) { newPushed.add(tc.request.callId); - const isLastInBatch = tc === toolCalls[toolCalls.length - 1]; - - const historyItem = mapTrackedToolCallsToDisplay(tc, { - borderTop: isFirst, - borderBottom: isLastInBatch, - ...getToolGroupBorderAppearance( - { type: 'tool_group', tools: toolCalls }, - activeShellPtyId, - !!isShellFocused, - [], - backgroundShells, - ), - }); - addItem(historyItem); - isFirst = false; } + const isLastInBatch = + toolsToPush[toolsToPush.length - 1] === toolCalls[toolCalls.length - 1]; + + const historyItem = mapTrackedToolCallsToDisplay(toolsToPush, { + borderTop: isFirstToolInGroupRef.current, + borderBottom: isLastInBatch, + ...getToolGroupBorderAppearance( + { type: 'tool_group', tools: toolCalls }, + activeShellPtyId, + !!isShellFocused, + [], + backgroundShells, + ), + }); + addItem(historyItem); + setPushedToolCallIds(newPushed); setIsFirstToolInGroup(false); } diff --git a/packages/core/src/agents/local-invocation.test.ts b/packages/core/src/agents/local-invocation.test.ts index b56fea54b6..0cd77176ba 100644 --- a/packages/core/src/agents/local-invocation.test.ts +++ b/packages/core/src/agents/local-invocation.test.ts @@ -207,8 +207,11 @@ describe('LocalSubagentInvocation', () => { ), }, ]); - expect(result.returnDisplay).toBe('Analysis complete.'); - expect(result.returnDisplay).not.toContain('Termination Reason'); + const display = result.returnDisplay as SubagentProgress; + expect(display.isSubagentProgress).toBe(true); + expect(display.state).toBe('completed'); + expect(display.result).toBe('Analysis complete.'); + expect(display.terminateReason).toBe(AgentTerminateMode.GOAL); }); it('should show detailed UI for non-goal terminations (e.g., TIMEOUT)', async () => { @@ -220,11 +223,11 @@ describe('LocalSubagentInvocation', () => { const result = await invocation.execute(signal, updateOutput); - expect(result.returnDisplay).toContain( - '### Subagent MockAgent Finished Early', - ); - expect(result.returnDisplay).toContain('**Termination Reason:** TIMEOUT'); - expect(result.returnDisplay).toContain('Partial progress...'); + const display = result.returnDisplay as SubagentProgress; + expect(display.isSubagentProgress).toBe(true); + expect(display.state).toBe('completed'); + expect(display.result).toBe('Partial progress...'); + expect(display.terminateReason).toBe(AgentTerminateMode.TIMEOUT); }); it('should stream THOUGHT_CHUNK activities from the executor', async () => { @@ -250,8 +253,8 @@ describe('LocalSubagentInvocation', () => { await invocation.execute(signal, updateOutput); - expect(updateOutput).toHaveBeenCalledTimes(3); // Initial + 2 updates - const lastCall = updateOutput.mock.calls[2][0] as SubagentProgress; + expect(updateOutput).toHaveBeenCalledTimes(4); // Initial + 2 updates + Final completion + const lastCall = updateOutput.mock.calls[3][0] as SubagentProgress; expect(lastCall.recentActivity).toContainEqual( expect.objectContaining({ type: 'thought', @@ -283,8 +286,8 @@ describe('LocalSubagentInvocation', () => { await invocation.execute(signal, updateOutput); - expect(updateOutput).toHaveBeenCalledTimes(3); - const lastCall = updateOutput.mock.calls[2][0] as SubagentProgress; + expect(updateOutput).toHaveBeenCalledTimes(4); // Initial + 2 updates + Final completion + const lastCall = updateOutput.mock.calls[3][0] as SubagentProgress; expect(lastCall.recentActivity).toContainEqual( expect.objectContaining({ type: 'thought', @@ -312,7 +315,10 @@ describe('LocalSubagentInvocation', () => { // Execute without the optional callback const result = await invocation.execute(signal); expect(result.error).toBeUndefined(); - expect(result.returnDisplay).toBe('Done'); + const display = result.returnDisplay as SubagentProgress; + expect(display.isSubagentProgress).toBe(true); + expect(display.state).toBe('completed'); + expect(display.result).toBe('Done'); }); it('should handle executor run failure', async () => { diff --git a/packages/core/src/agents/local-invocation.ts b/packages/core/src/agents/local-invocation.ts index 6ef30e773c..142a0bc518 100644 --- a/packages/core/src/agents/local-invocation.ts +++ b/packages/core/src/agents/local-invocation.ts @@ -6,7 +6,6 @@ import { type AgentLoopContext } from '../config/agent-loop-context.js'; import { LocalAgentExecutor } from './local-executor.js'; -import { safeJsonToMarkdown } from '../utils/markdownUtils.js'; import { BaseToolInvocation, type ToolResult, @@ -246,28 +245,27 @@ export class LocalSubagentInvocation extends BaseToolInvocation< throw cancelError; } - const displayResult = safeJsonToMarkdown(output.result); + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: this.definition.name, + recentActivity: [...recentActivity], + state: 'completed', + result: output.result, + terminateReason: output.terminate_reason, + }; + + if (updateOutput) { + updateOutput(progress); + } const resultContent = `Subagent '${this.definition.name}' finished. Termination Reason: ${output.terminate_reason} Result: ${output.result}`; - const displayContent = - output.terminate_reason === AgentTerminateMode.GOAL - ? displayResult - : ` -### Subagent ${this.definition.name} Finished Early - -**Termination Reason:** ${output.terminate_reason} - -**Result/Summary:** -${displayResult} -`; - return { llmContent: [{ text: resultContent }], - returnDisplay: displayContent, + returnDisplay: progress, }; } catch (error) { const errorMessage = diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index 41db981a7b..2c703f90fd 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -87,6 +87,8 @@ export interface SubagentProgress { agentName: string; recentActivity: SubagentActivityItem[]; state?: 'running' | 'completed' | 'error' | 'cancelled'; + result?: string; + terminateReason?: AgentTerminateMode; } export function isSubagentProgress(obj: unknown): obj is SubagentProgress { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a76e7aa2d4..47412dd73c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -118,6 +118,7 @@ export * from './utils/channel.js'; export * from './utils/constants.js'; export * from './utils/sessionUtils.js'; export * from './utils/cache.js'; +export * from './utils/markdownUtils.js'; // Export services export * from './services/fileDiscoveryService.js';