fix(cli): resolve subagent grouping and UI state persistence (#22252)

This commit is contained in:
Abhi
2026-03-17 23:11:20 -04:00
committed by GitHub
parent 7bfe6ac418
commit be7c7bb83d
13 changed files with 596 additions and 69 deletions

View File

@@ -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>{text}</Text>,
}));
describe('<SubagentGroupDisplay />', () => {
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,
) => (
<OverflowProvider>
<KeypressProvider>
<SubagentGroupDisplay
toolCalls={toolCallsToRender}
terminalWidth={80}
availableTerminalHeight={height}
isExpandable={true}
/>
</KeypressProvider>
</OverflowProvider>
);
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)');
});
});
});

View File

@@ -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<SubagentGroupDisplayProps> = ({
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,
) => (
<Box key={key} flexDirection="row" marginLeft={0} marginTop={0}>
<Box minWidth={2} flexShrink={0}>
{icon}
</Box>
<Box flexShrink={0}>
<Text bold color={theme.text.primary} wrap="truncate">
{agentName}
</Text>
</Box>
<Box flexShrink={0}>
<Text color={theme.text.secondary}> · </Text>
</Box>
<Box flexShrink={1} minWidth={0}>
<Text color={theme.text.secondary} wrap="truncate">
{content}
{displayArgs && ` ${displayArgs}`}
</Text>
</Box>
</Box>
);
return (
<Box
flexDirection="column"
width={terminalWidth}
borderLeft={true}
borderRight={true}
borderTop={isFirst}
borderBottom={false}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderStyle="round"
paddingLeft={1}
paddingTop={0}
paddingBottom={0}
>
<Box flexDirection="row" gap={1} marginBottom={isExpanded ? 1 : 0}>
<Text color={theme.text.secondary}></Text>
<Text bold color={theme.text.primary}>
{headerText}
</Text>
{isExpandable && <Text color={theme.text.secondary}>{toggleText}</Text>}
</Box>
{toolCalls.map((toolCall) => {
const progress = toolCall.resultDisplay;
if (!isSubagentProgress(progress)) {
const agentName = toolCall.name || 'agent';
if (!isExpanded) {
return renderCollapsedRow(
toolCall.callId,
agentName,
<Text color={theme.text.primary}>!</Text>,
'Starting...',
);
} else {
return (
<Box
key={toolCall.callId}
flexDirection="column"
marginLeft={0}
marginBottom={1}
>
<Box flexDirection="row" gap={1}>
<Text color={theme.text.primary}>!</Text>
<Text bold color={theme.text.primary}>
{agentName}
</Text>
</Box>
<Box marginLeft={2}>
<Text color={theme.text.secondary}>Starting...</Text>
</Box>
</Box>
);
}
}
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 <Text color={theme.text.primary}>!</Text>;
case 'completed':
return <Text color={theme.status.success}></Text>;
case 'cancelled':
return <Text color={theme.status.warning}></Text>;
case 'error':
return <Text color={theme.status.error}></Text>;
default:
return checkExhaustive(state);
}
};
return renderCollapsedRow(
toolCall.callId,
progress.agentName,
renderStatusIcon(),
lastActivity?.type === 'thought' ? `💭 ${content}` : content,
displayArgs,
);
}
// Expanded View: Render full history
return (
<Box
key={toolCall.callId}
flexDirection="column"
marginLeft={0}
marginBottom={1}
>
<SubagentProgressDisplay
progress={progress}
terminalWidth={terminalWidth}
/>
</Box>
);
})}
</Box>
);
};

View File

@@ -36,7 +36,7 @@ describe('<SubagentProgressDisplay />', () => {
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -60,7 +60,7 @@ describe('<SubagentProgressDisplay />', () => {
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -82,7 +82,7 @@ describe('<SubagentProgressDisplay />', () => {
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -104,7 +104,7 @@ describe('<SubagentProgressDisplay />', () => {
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -128,7 +128,7 @@ describe('<SubagentProgressDisplay />', () => {
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -149,7 +149,7 @@ describe('<SubagentProgressDisplay />', () => {
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -164,7 +164,7 @@ describe('<SubagentProgressDisplay />', () => {
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -185,7 +185,7 @@ describe('<SubagentProgressDisplay />', () => {
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();

View File

@@ -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;
})}
</Box>
{progress.state === 'completed' && progress.result && (
<Box flexDirection="column" marginTop={1}>
{progress.terminateReason && progress.terminateReason !== 'GOAL' && (
<Box marginBottom={1}>
<Text color={theme.status.warning} bold>
Agent Finished Early ({progress.terminateReason})
</Text>
</Box>
)}
<MarkdownDisplay
text={safeJsonToMarkdown(progress.result)}
isPending={false}
terminalWidth={terminalWidth}
/>
</Box>
)}
</Box>
);
};

View File

@@ -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<ToolGroupMessageProps> = ({
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<ToolGroupMessageProps> = ({
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 (
<SubagentGroupDisplay
key={group[0].callId}
toolCalls={group}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={contentWidth}
borderColor={borderColor}
borderDimColor={borderDimColor}
isFirst={resolvedIsFirst}
isExpandable={isExpandable}
/>
);
}
const tool = group;
const isShellToolCall = isShellTool(tool.name);
const commonProps = {
@@ -176,10 +223,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
availableTerminalHeight: availableTerminalHeightPerToolMessage,
terminalWidth: contentWidth,
emphasis: 'medium' as const,
isFirst:
borderTopOverride !== undefined
? borderTopOverride && isFirst
: isFirst,
isFirst: resolvedIsFirst,
borderColor,
borderDimColor,
isExpandable,

View File

@@ -102,7 +102,12 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
</Text>
);
} else if (isSubagentProgress(contentData)) {
content = <SubagentProgressDisplay progress={contentData} />;
content = (
<SubagentProgressDisplay
progress={contentData}
terminalWidth={childWidth}
/>
);
} else if (typeof contentData === 'string' && renderOutputAsMarkdown) {
content = (
<MarkdownDisplay

View File

@@ -0,0 +1,9 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<SubagentGroupDisplay /> > 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 │
"
`;

View File

@@ -1,7 +1,9 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<SubagentProgressDisplay /> > renders "Request cancelled." with the info icon 1`] = `
" Request cancelled.
"Running subagent TestAgent...
Request cancelled.
"
`;
@@ -11,31 +13,43 @@ exports[`<SubagentProgressDisplay /> > renders cancelled state correctly 1`] = `
`;
exports[`<SubagentProgressDisplay /> > renders correctly with command fallback 1`] = `
"⠋ run_shell_command echo hello
"Running subagent TestAgent...
⠋ run_shell_command echo hello
"
`;
exports[`<SubagentProgressDisplay /> > renders correctly with description in args 1`] = `
"⠋ run_shell_command Say hello
"Running subagent TestAgent...
⠋ run_shell_command Say hello
"
`;
exports[`<SubagentProgressDisplay /> > renders correctly with displayName and description from item 1`] = `
"⠋ RunShellCommand Executing echo hello
"Running subagent TestAgent...
⠋ RunShellCommand Executing echo hello
"
`;
exports[`<SubagentProgressDisplay /> > renders correctly with file_path 1`] = `
"✓ write_file /tmp/test.txt
"Running subagent TestAgent...
✓ write_file /tmp/test.txt
"
`;
exports[`<SubagentProgressDisplay /> > renders thought bubbles correctly 1`] = `
"💭 Thinking about life
"Running subagent TestAgent...
💭 Thinking about life
"
`;
exports[`<SubagentProgressDisplay /> > 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...
"
`;

View File

@@ -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);
}

View File

@@ -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 () => {

View File

@@ -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 =

View File

@@ -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 {

View File

@@ -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';