From 77624401baee4ccc62c97c32cb553febce1c0807 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Mon, 26 Jan 2026 11:03:17 -0800 Subject: [PATCH 01/11] Subagent activity UX. --- .../messages/SubagentProgressDisplay.test.tsx | 131 ++++++++++++++++++ .../messages/SubagentProgressDisplay.tsx | 106 ++++++++++++++ .../components/messages/ToolResultDisplay.tsx | 13 +- .../core/src/agents/local-invocation.test.ts | 78 +++++++---- packages/core/src/agents/local-invocation.ts | 120 ++++++++++++++-- packages/core/src/agents/types.ts | 13 ++ packages/core/src/core/geminiChat.ts | 15 +- packages/core/src/tools/tools.ts | 8 +- 8 files changed, 439 insertions(+), 45 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx new file mode 100644 index 00000000000..c17c8cede95 --- /dev/null +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../../test-utils/render.js'; +import { SubagentProgressDisplay } from './SubagentProgressDisplay.js'; +import type { SubagentProgress } from '@google/gemini-cli-core'; +import { describe, it, expect, vi } from 'vitest'; +import { Text } from 'ink'; + +vi.mock('ink-spinner', () => ({ + default: () => , +})); + +describe('', () => { + it('renders correctly with description in args', () => { + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: 'TestAgent', + recentActivity: [ + { + type: 'tool_call', + content: 'run_shell_command', + args: '{"command": "echo hello", "description": "Say hello"}', + status: 'running', + }, + ], + }; + + const { lastFrame } = render( + , + ); + const frame = lastFrame(); + expect(frame).toContain('Subagent TestAgent is working...'); + expect(frame).toContain('run_shell_command'); + expect(frame).toContain('Say hello'); + expect(frame).not.toContain('{"command": "echo hello"'); + }); + + it('renders correctly with command fallback', () => { + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: 'TestAgent', + recentActivity: [ + { + type: 'tool_call', + content: 'run_shell_command', + args: '{"command": "echo hello"}', + status: 'running', + }, + ], + }; + + const { lastFrame } = render( + , + ); + const frame = lastFrame(); + expect(frame).toContain('echo hello'); + }); + + it('renders correctly with file_path', () => { + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: 'TestAgent', + recentActivity: [ + { + type: 'tool_call', + content: 'write_file', + args: '{"file_path": "/tmp/test.txt", "content": "foo"}', + status: 'completed', + }, + ], + }; + + const { lastFrame } = render( + , + ); + const frame = lastFrame(); + expect(frame).toContain('/tmp/test.txt'); + expect(frame).not.toContain('"content": "foo"'); + }); + + it('truncates long args', () => { + const longDesc = + 'This is a very long description that should definitely be truncated because it exceeds the limit of sixty characters.'; + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: 'TestAgent', + recentActivity: [ + { + type: 'tool_call', + content: 'run_shell_command', + args: JSON.stringify({ description: longDesc }), + status: 'running', + }, + ], + }; + + const { lastFrame } = render( + , + ); + const frame = lastFrame(); + expect(frame).toContain('This is a very long description'); + expect(frame).toContain('...'); + // slice(0, 60) + // "This is a very long description that should definitely be tr" + expect(frame).not.toContain('sixty characters'); + }); + + it('renders thought bubbles correctly', () => { + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: 'TestAgent', + recentActivity: [ + { + type: 'thought', + content: 'Thinking about life', + status: 'running', + }, + ], + }; + + const { lastFrame } = render( + , + ); + const frame = lastFrame(); + expect(frame).toContain('💭 Thinking about life'); + }); +}); diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx new file mode 100644 index 00000000000..c6407ce3fc8 --- /dev/null +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import Spinner from 'ink-spinner'; +import type { + SubagentProgress, + SubagentActivityItem, +} from '@google/gemini-cli-core'; +import { TOOL_STATUS } from '../../constants.js'; +import { STATUS_INDICATOR_WIDTH } from './ToolShared.js'; + +export interface SubagentProgressDisplayProps { + progress: SubagentProgress; +} + +const formatToolArgs = (args?: string): string => { + if (!args) return ''; + try { + const parsed = JSON.parse(args); + if (typeof parsed.description === 'string' && parsed.description) { + return parsed.description; + } + if (typeof parsed.command === 'string') return parsed.command; + if (typeof parsed.file_path === 'string') return parsed.file_path; + if (typeof parsed.dir_path === 'string') return parsed.dir_path; + if (typeof parsed.query === 'string') return parsed.query; + if (typeof parsed.url === 'string') return parsed.url; + if (typeof parsed.target === 'string') return parsed.target; + + return args; + } catch { + return args; + } +}; + +export const SubagentProgressDisplay: React.FC< + SubagentProgressDisplayProps +> = ({ progress }) => ( + + + + Subagent {progress.agentName} is working... + + + + {progress.recentActivity.map( + (item: SubagentActivityItem, index: number) => { + if (item.type === 'thought') { + return ( + + + 💭 + + + {item.content} + + + ); + } else if (item.type === 'tool_call') { + const statusSymbol = + item.status === 'running' ? ( + + ) : item.status === 'completed' ? ( + + {TOOL_STATUS.SUCCESS} + + ) : ( + {TOOL_STATUS.ERROR} + ); + + const formattedArgs = formatToolArgs(item.args); + const displayArgs = + formattedArgs.length > 60 + ? formattedArgs.slice(0, 60) + '...' + : formattedArgs; + + return ( + + {statusSymbol} + + + {item.content} + + {displayArgs && ( + + + {displayArgs} + + + )} + + + ); + } + return null; + }, + )} + + + ); diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 8e0fc4442a5..0144643807f 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -11,7 +11,7 @@ import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js'; import { MaxSizedBox } from '../shared/MaxSizedBox.js'; import { theme } from '../../semantic-colors.js'; -import type { AnsiOutput, AnsiLine } from '@google/gemini-cli-core'; +import type { AnsiOutput, AnsiLine, SubagentProgress } from '@google/gemini-cli-core'; import { useUIState } from '../../contexts/UIStateContext.js'; import { tryParseJSON } from '../../../utils/jsonoutput.js'; import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; @@ -20,6 +20,7 @@ import { ScrollableList } from '../shared/ScrollableList.js'; import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js'; import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js'; import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js'; +import { SubagentProgressDisplay } from './SubagentProgressDisplay.js'; // Large threshold to ensure we don't cause performance issues for very large // outputs that will get truncated further MaxSizedBox anyway. @@ -39,6 +40,14 @@ interface FileDiffResult { fileName: string; } +function isSubagentProgress(obj: unknown): obj is SubagentProgress { + return ( + typeof obj === 'object' && + obj !== null && + (obj as SubagentProgress).isSubagentProgress === true + ); +} + export const ToolResultDisplay: React.FC = ({ resultDisplay, availableTerminalHeight, @@ -167,6 +176,8 @@ export const ToolResultDisplay: React.FC = ({ {formattedJSON} ); + } else if (isSubagentProgress(truncatedResultDisplay)) { + content = ; } else if ( typeof truncatedResultDisplay === 'string' && renderOutputAsMarkdown diff --git a/packages/core/src/agents/local-invocation.test.ts b/packages/core/src/agents/local-invocation.test.ts index 91efcd399fd..0feead2f21f 100644 --- a/packages/core/src/agents/local-invocation.test.ts +++ b/packages/core/src/agents/local-invocation.test.ts @@ -9,12 +9,12 @@ import type { LocalAgentDefinition, SubagentActivityEvent, AgentInputs, + SubagentProgress, } from './types.js'; import { LocalSubagentInvocation } from './local-invocation.js'; import { LocalAgentExecutor } from './local-executor.js'; import { AgentTerminateMode } from './types.js'; import { makeFakeConfig } from '../test-utils/config.js'; -import { ToolErrorType } from '../tools/tool-error.js'; import type { Config } from '../config/config.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { type z } from 'zod'; @@ -29,6 +29,7 @@ let mockConfig: Config; const testDefinition: LocalAgentDefinition = { kind: 'local', name: 'MockAgent', + displayName: 'Mock Agent', description: 'A mock agent.', inputConfig: { inputSchema: { @@ -173,7 +174,12 @@ describe('LocalSubagentInvocation', () => { mockConfig, expect.any(Function), ); - expect(updateOutput).toHaveBeenCalledWith('Subagent starting...\n'); + expect(updateOutput).toHaveBeenCalledWith( + expect.objectContaining({ + isSubagentProgress: true, + agentName: 'MockAgent', + }), + ); expect(mockExecutorInstance.run).toHaveBeenCalledWith(params, signal); @@ -211,13 +217,17 @@ describe('LocalSubagentInvocation', () => { await invocation.execute(signal, updateOutput); - expect(updateOutput).toHaveBeenCalledWith('Subagent starting...\n'); - expect(updateOutput).toHaveBeenCalledWith('🤖💭 Analyzing...'); - expect(updateOutput).toHaveBeenCalledWith('🤖💭 Still thinking.'); - expect(updateOutput).toHaveBeenCalledTimes(3); // Initial message + 2 thoughts + expect(updateOutput).toHaveBeenCalledTimes(3); // Initial + 2 updates + const lastCall = updateOutput.mock.calls[2][0] as SubagentProgress; + expect(lastCall.recentActivity).toContainEqual( + expect.objectContaining({ + type: 'thought', + content: 'Analyzing... Still thinking.', + }), + ); }); - it('should NOT stream other activities (e.g., TOOL_CALL_START, ERROR)', async () => { + it('should stream other activities (e.g., TOOL_CALL_START, ERROR)', async () => { mockExecutorInstance.run.mockImplementation(async () => { const onActivity = MockLocalAgentExecutor.create.mock.calls[0][2]; @@ -226,7 +236,7 @@ describe('LocalSubagentInvocation', () => { isSubagentActivityEvent: true, agentName: 'MockAgent', type: 'TOOL_CALL_START', - data: { name: 'ls' }, + data: { name: 'ls', args: {} }, } as SubagentActivityEvent); onActivity({ isSubagentActivityEvent: true, @@ -240,9 +250,15 @@ describe('LocalSubagentInvocation', () => { await invocation.execute(signal, updateOutput); - // Should only contain the initial "Subagent starting..." message - expect(updateOutput).toHaveBeenCalledTimes(1); - expect(updateOutput).toHaveBeenCalledWith('Subagent starting...\n'); + expect(updateOutput).toHaveBeenCalledTimes(3); + const lastCall = updateOutput.mock.calls[2][0] as SubagentProgress; + expect(lastCall.recentActivity).toContainEqual( + expect.objectContaining({ + type: 'thought', + content: 'Error: Failed', + status: 'error', + }), + ); }); it('should run successfully without an updateOutput callback', async () => { @@ -272,16 +288,19 @@ describe('LocalSubagentInvocation', () => { const result = await invocation.execute(signal, updateOutput); - expect(result.error).toEqual({ - message: error.message, - type: ToolErrorType.EXECUTION_FAILED, - }); - expect(result.returnDisplay).toBe( - `Subagent Failed: MockAgent\nError: ${error.message}`, - ); + expect(result.error).toBeUndefined(); expect(result.llmContent).toBe( `Subagent 'MockAgent' failed. Error: ${error.message}`, ); + const display = result.returnDisplay as SubagentProgress; + expect(display.isSubagentProgress).toBe(true); + expect(display.recentActivity).toContainEqual( + expect.objectContaining({ + type: 'thought', + content: `Error: ${error.message}`, + status: 'error', + }), + ); }); it('should handle executor creation failure', async () => { @@ -291,17 +310,18 @@ describe('LocalSubagentInvocation', () => { const result = await invocation.execute(signal, updateOutput); expect(mockExecutorInstance.run).not.toHaveBeenCalled(); - expect(result.error).toEqual({ - message: creationError.message, - type: ToolErrorType.EXECUTION_FAILED, - }); - expect(result.returnDisplay).toContain(`Error: ${creationError.message}`); + expect(result.error).toBeUndefined(); + expect(result.llmContent).toContain(creationError.message); + + const display = result.returnDisplay as SubagentProgress; + expect(display.recentActivity).toContainEqual( + expect.objectContaining({ + content: `Error: ${creationError.message}`, + status: 'error', + }), + ); }); - /** - * This test verifies that the AbortSignal is correctly propagated and - * that a rejection from the executor due to abortion is handled gracefully. - */ it('should handle abortion signal during execution', async () => { const abortError = new Error('Aborted'); mockExecutorInstance.run.mockRejectedValue(abortError); @@ -318,8 +338,8 @@ describe('LocalSubagentInvocation', () => { params, controller.signal, ); - expect(result.error?.message).toBe('Aborted'); - expect(result.error?.type).toBe(ToolErrorType.EXECUTION_FAILED); + expect(result.error).toBeUndefined(); + expect(result.llmContent).toContain('Aborted'); }); }); }); diff --git a/packages/core/src/agents/local-invocation.ts b/packages/core/src/agents/local-invocation.ts index a75fa8a11aa..e0f442f39c4 100644 --- a/packages/core/src/agents/local-invocation.ts +++ b/packages/core/src/agents/local-invocation.ts @@ -8,16 +8,18 @@ import type { Config } from '../config/config.js'; import { LocalAgentExecutor } from './local-executor.js'; import type { AnsiOutput } from '../utils/terminalSerializer.js'; import { BaseToolInvocation, type ToolResult } from '../tools/tools.js'; -import { ToolErrorType } from '../tools/tool-error.js'; import type { LocalAgentDefinition, AgentInputs, SubagentActivityEvent, + SubagentProgress, + SubagentActivityItem, } from './types.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; const INPUT_PREVIEW_MAX_LENGTH = 50; const DESCRIPTION_MAX_LENGTH = 200; +const MAX_RECENT_ACTIVITY = 3; /** * Represents a validated, executable instance of a subagent tool. @@ -83,9 +85,17 @@ export class LocalSubagentInvocation extends BaseToolInvocation< signal: AbortSignal, updateOutput?: (output: string | AnsiOutput) => void, ): Promise { + let recentActivity: SubagentActivityItem[] = []; + try { if (updateOutput) { - updateOutput('Subagent starting...\n'); + // Send initial state + const initialProgress: SubagentProgress = { + isSubagentProgress: true, + agentName: this.definition.name, + recentActivity: [], + }; + updateOutput(initialProgress as unknown as AnsiOutput); } // Create an activity callback to bridge the executor's events to the @@ -93,11 +103,79 @@ export class LocalSubagentInvocation extends BaseToolInvocation< const onActivity = (activity: SubagentActivityEvent): void => { if (!updateOutput) return; - if ( - activity.type === 'THOUGHT_CHUNK' && - typeof activity.data['text'] === 'string' - ) { - updateOutput(`🤖💭 ${activity.data['text']}`); + let updated = false; + + switch (activity.type) { + case 'THOUGHT_CHUNK': { + const text = String(activity.data['text']); + const lastItem = recentActivity[recentActivity.length - 1]; + if (lastItem && lastItem.type === 'thought') { + lastItem.content += text; + } else { + recentActivity.push({ + type: 'thought', + content: text, + status: 'running', + }); + } + updated = true; + break; + } + case 'TOOL_CALL_START': { + const name = String(activity.data['name']); + const args = JSON.stringify(activity.data['args']); + recentActivity.push({ + type: 'tool_call', + content: name, + args, + status: 'running', + }); + updated = true; + break; + } + case 'TOOL_CALL_END': { + const name = String(activity.data['name']); + // Find the last running tool call with this name + for (let i = recentActivity.length - 1; i >= 0; i--) { + if ( + recentActivity[i].type === 'tool_call' && + recentActivity[i].content === name && + recentActivity[i].status === 'running' + ) { + recentActivity[i].status = 'completed'; + updated = true; + break; + } + } + break; + } + case 'ERROR': { + const error = String(activity.data['error']); + recentActivity.push({ + type: 'thought', // Treat errors as thoughts for now, or add an error type + content: `Error: ${error}`, + status: 'error', + }); + updated = true; + break; + } + default: + break; + } + + if (updated) { + // Keep only the last N items + if (recentActivity.length > MAX_RECENT_ACTIVITY) { + recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY); + } + + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: this.definition.name, + recentActivity: [...recentActivity], // Copy to avoid mutation issues + }; + + updateOutput(progress as unknown as AnsiOutput); } }; @@ -131,13 +209,31 @@ ${output.result} const errorMessage = error instanceof Error ? error.message : String(error); + // Ensure the error is reflected in the recent activity for display + const lastActivity = recentActivity[recentActivity.length - 1]; + if (!lastActivity || lastActivity.status !== 'error') { + recentActivity.push({ + type: 'thought', + content: `Error: ${errorMessage}`, + status: 'error', + }); + // Maintain size limit + if (recentActivity.length > MAX_RECENT_ACTIVITY) { + recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY); + } + } + + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: this.definition.name, + recentActivity: [...recentActivity], + }; + return { llmContent: `Subagent '${this.definition.name}' failed. Error: ${errorMessage}`, - returnDisplay: `Subagent Failed: ${this.definition.name}\nError: ${errorMessage}`, - error: { - message: errorMessage, - type: ToolErrorType.EXECUTION_FAILED, - }, + returnDisplay: progress, + // We omit the 'error' property so that the UI renders our rich returnDisplay + // instead of the raw error message. The llmContent still informs the agent of the failure. }; } } diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index b9994d8b4a6..7f291138f64 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -71,6 +71,19 @@ export interface SubagentActivityEvent { data: Record; } +export interface SubagentActivityItem { + type: 'thought' | 'tool_call'; + content: string; + args?: string; + status: 'running' | 'completed' | 'error'; +} + +export interface SubagentProgress { + isSubagentProgress: true; + agentName: string; + recentActivity: SubagentActivityItem[]; +} + /** * The base definition for an agent. * @template TOutput The specific Zod schema for the agent's final output object. diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index b7319c8afd6..2ce860c4ecf 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -189,11 +189,16 @@ export class InvalidStreamError extends Error { readonly type: | 'NO_FINISH_REASON' | 'NO_RESPONSE_TEXT' - | 'MALFORMED_FUNCTION_CALL'; + | 'MALFORMED_FUNCTION_CALL' + | 'UNEXPECTED_TOOL_CALL'; constructor( message: string, - type: 'NO_FINISH_REASON' | 'NO_RESPONSE_TEXT' | 'MALFORMED_FUNCTION_CALL', + type: + | 'NO_FINISH_REASON' + | 'NO_RESPONSE_TEXT' + | 'MALFORMED_FUNCTION_CALL' + | 'UNEXPECTED_TOOL_CALL', ) { super(message); this.name = 'InvalidStreamError'; @@ -931,6 +936,12 @@ export class GeminiChat { 'MALFORMED_FUNCTION_CALL', ); } + if (finishReason === FinishReason.UNEXPECTED_TOOL_CALL) { + throw new InvalidStreamError( + 'Model stream ended with unexpected tool call.', + 'UNEXPECTED_TOOL_CALL', + ); + } if (!responseText) { throw new InvalidStreamError( 'Model stream ended with empty response text.', diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index d847b596e07..bd9ca423654 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -19,6 +19,7 @@ import { type Question, } from '../confirmation-bus/types.js'; import { type ApprovalMode } from '../policy/types.js'; +import type { SubagentProgress } from '../agents/types.js'; /** * Represents a validated and ready-to-execute tool call. @@ -688,7 +689,12 @@ export interface TodoList { todos: Todo[]; } -export type ToolResultDisplay = string | FileDiff | AnsiOutput | TodoList; +export type ToolResultDisplay = + | string + | FileDiff + | AnsiOutput + | TodoList + | SubagentProgress; export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; From 7e2590143a8cdb107162e3c5049d49ec1db923b1 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Mon, 26 Jan 2026 12:02:53 -0800 Subject: [PATCH 02/11] Fix ESC. --- .../messages/SubagentProgressDisplay.test.tsx | 15 ++++ .../messages/SubagentProgressDisplay.tsx | 22 +++++- .../core/src/agents/local-invocation.test.ts | 20 ++++++ packages/core/src/agents/local-invocation.ts | 72 ++++++++++++++----- packages/core/src/agents/types.ts | 1 + packages/core/src/scheduler/tool-executor.ts | 5 +- 6 files changed, 115 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx index c17c8cede95..95659373f63 100644 --- a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx @@ -128,4 +128,19 @@ describe('', () => { const frame = lastFrame(); expect(frame).toContain('💭 Thinking about life'); }); + + it('renders cancelled state correctly', () => { + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: 'TestAgent', + recentActivity: [], + state: 'cancelled', + }; + + const { lastFrame } = render( + , + ); + const frame = lastFrame(); + expect(frame).toContain('Subagent TestAgent was cancelled.'); + }); }); diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx index c6407ce3fc8..5b3a906ff5b 100644 --- a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx @@ -41,11 +41,26 @@ const formatToolArgs = (args?: string): string => { export const SubagentProgressDisplay: React.FC< SubagentProgressDisplayProps -> = ({ progress }) => ( +> = ({ progress }) => { + let headerText = `Subagent ${progress.agentName} is working...`; + let headerColor = theme.text.secondary; + + if (progress.state === 'cancelled') { + headerText = `Subagent ${progress.agentName} was cancelled.`; + headerColor = theme.status.warning; + } else if (progress.state === 'error') { + headerText = `Subagent ${progress.agentName} failed.`; + headerColor = theme.status.error; + } else if (progress.state === 'completed') { + headerText = `Subagent ${progress.agentName} completed.`; + headerColor = theme.status.success; + } + + return ( - - Subagent {progress.agentName} is working... + + {headerText} @@ -104,3 +119,4 @@ export const SubagentProgressDisplay: React.FC< ); +}; diff --git a/packages/core/src/agents/local-invocation.test.ts b/packages/core/src/agents/local-invocation.test.ts index 0feead2f21f..0b2889a2c98 100644 --- a/packages/core/src/agents/local-invocation.test.ts +++ b/packages/core/src/agents/local-invocation.test.ts @@ -341,5 +341,25 @@ describe('LocalSubagentInvocation', () => { expect(result.error).toBeUndefined(); expect(result.llmContent).toContain('Aborted'); }); + + it('should return cancelled state when execution returns ABORTED', async () => { + const mockOutput = { + result: 'Cancelled by user', + terminate_reason: AgentTerminateMode.ABORTED, + }; + mockExecutorInstance.run.mockResolvedValue(mockOutput); + + const result = await invocation.execute(signal, updateOutput); + + expect(result.llmContent).toEqual( + expect.stringContaining( + "Subagent 'MockAgent' was cancelled by the user.", + ), + ); + + const display = result.returnDisplay as SubagentProgress; + expect(display.isSubagentProgress).toBe(true); + expect(display.state).toBe('cancelled'); + }); }); }); diff --git a/packages/core/src/agents/local-invocation.ts b/packages/core/src/agents/local-invocation.ts index e0f442f39c4..20f9e849830 100644 --- a/packages/core/src/agents/local-invocation.ts +++ b/packages/core/src/agents/local-invocation.ts @@ -8,12 +8,13 @@ import type { Config } from '../config/config.js'; import { LocalAgentExecutor } from './local-executor.js'; import type { AnsiOutput } from '../utils/terminalSerializer.js'; import { BaseToolInvocation, type ToolResult } from '../tools/tools.js'; -import type { - LocalAgentDefinition, - AgentInputs, - SubagentActivityEvent, - SubagentProgress, - SubagentActivityItem, +import { + type LocalAgentDefinition, + type AgentInputs, + type SubagentActivityEvent, + type SubagentProgress, + type SubagentActivityItem, + AgentTerminateMode, } from './types.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; @@ -94,6 +95,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation< isSubagentProgress: true, agentName: this.definition.name, recentActivity: [], + state: 'running', }; updateOutput(initialProgress as unknown as AnsiOutput); } @@ -173,6 +175,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation< isSubagentProgress: true, agentName: this.definition.name, recentActivity: [...recentActivity], // Copy to avoid mutation issues + state: 'running', }; updateOutput(progress as unknown as AnsiOutput); @@ -187,6 +190,24 @@ export class LocalSubagentInvocation extends BaseToolInvocation< const output = await executor.run(this.params, signal); + if (output.terminate_reason === AgentTerminateMode.ABORTED) { + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: this.definition.name, + recentActivity: [...recentActivity], + state: 'cancelled', + }; + + if (updateOutput) { + updateOutput(progress as unknown as AnsiOutput); + } + + return { + llmContent: `Subagent '${this.definition.name}' was cancelled by the user.`, + returnDisplay: progress, + }; + } + const resultContent = `Subagent '${this.definition.name}' finished. Termination Reason: ${output.terminate_reason} Result: @@ -209,17 +230,31 @@ ${output.result} const errorMessage = error instanceof Error ? error.message : String(error); + const isAbort = + (error instanceof Error && error.name === 'AbortError') || + errorMessage.includes('Aborted'); + + // Mark any running items as error/cancelled + for (const item of recentActivity) { + if (item.status === 'running') { + item.status = 'error'; + } + } + // Ensure the error is reflected in the recent activity for display - const lastActivity = recentActivity[recentActivity.length - 1]; - if (!lastActivity || lastActivity.status !== 'error') { - recentActivity.push({ - type: 'thought', - content: `Error: ${errorMessage}`, - status: 'error', - }); - // Maintain size limit - if (recentActivity.length > MAX_RECENT_ACTIVITY) { - recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY); + // But only if it's NOT an abort, or if we want to show "Cancelled" as a thought + if (!isAbort) { + const lastActivity = recentActivity[recentActivity.length - 1]; + if (!lastActivity || lastActivity.status !== 'error') { + recentActivity.push({ + type: 'thought', + content: `Error: ${errorMessage}`, + status: 'error', + }); + // Maintain size limit + if (recentActivity.length > MAX_RECENT_ACTIVITY) { + recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY); + } } } @@ -227,8 +262,13 @@ ${output.result} isSubagentProgress: true, agentName: this.definition.name, recentActivity: [...recentActivity], + state: isAbort ? 'cancelled' : 'error', }; + if (updateOutput) { + updateOutput(progress as unknown as AnsiOutput); + } + return { llmContent: `Subagent '${this.definition.name}' failed. Error: ${errorMessage}`, returnDisplay: progress, diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index 7f291138f64..bbc062fa1c4 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -82,6 +82,7 @@ export interface SubagentProgress { isSubagentProgress: true; agentName: string; recentActivity: SubagentActivityItem[]; + state?: 'running' | 'completed' | 'error' | 'cancelled'; } /** diff --git a/packages/core/src/scheduler/tool-executor.ts b/packages/core/src/scheduler/tool-executor.ts index 9ae00b24a79..89ca71101b5 100644 --- a/packages/core/src/scheduler/tool-executor.ts +++ b/packages/core/src/scheduler/tool-executor.ts @@ -10,6 +10,7 @@ import type { ToolResult, Config, AnsiOutput, + ToolResultDisplay, } from '../index.js'; import { ToolErrorType, @@ -122,6 +123,7 @@ export class ToolExecutor { return this.createCancelledResult( call, 'User cancelled tool execution.', + toolResult.returnDisplay, ); } else if (toolResult.error === undefined) { return await this.createSuccessResult(call, toolResult); @@ -163,6 +165,7 @@ export class ToolExecutor { private createCancelledResult( call: ToolCall, reason: string, + resultDisplay?: ToolResultDisplay, ): CancelledToolCall { const errorMessage = `[Operation Cancelled] ${reason}`; const startTime = 'startTime' in call ? call.startTime : undefined; @@ -187,7 +190,7 @@ export class ToolExecutor { }, }, ], - resultDisplay: undefined, + resultDisplay, error: undefined, errorType: undefined, contentLength: errorMessage.length, From a3d47a89f4f6712ddfcb0ff83a7651dd4c0458c0 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Thu, 26 Feb 2026 16:06:26 -0800 Subject: [PATCH 03/11] Fixup types. --- .../agents/browser/browserAgentInvocation.ts | 9 ++++++--- packages/core/src/agents/local-invocation.ts | 17 ++++++++++------- packages/core/src/agents/subagent-tool.ts | 4 ++-- packages/core/src/core/coreToolHookTriggers.ts | 5 +++-- packages/core/src/scheduler/tool-executor.ts | 6 +++--- packages/core/src/scheduler/types.ts | 6 +++--- packages/core/src/tools/shell.ts | 3 ++- packages/core/src/tools/tools.ts | 10 ++++++---- 8 files changed, 35 insertions(+), 25 deletions(-) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index 0de9564c393..9df543300e5 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -16,8 +16,11 @@ import type { Config } from '../../config/config.js'; import { LocalAgentExecutor } from '../local-executor.js'; -import type { AnsiOutput } from '../../utils/terminalSerializer.js'; -import { BaseToolInvocation, type ToolResult } from '../../tools/tools.js'; +import { + BaseToolInvocation, + type ToolResult, + type ToolLiveOutput, +} from '../../tools/tools.js'; import { ToolErrorType } from '../../tools/tool-error.js'; import type { AgentInputs, SubagentActivityEvent } from '../types.js'; import type { MessageBus } from '../../confirmation-bus/message-bus.js'; @@ -82,7 +85,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation< */ async execute( signal: AbortSignal, - updateOutput?: (output: string | AnsiOutput) => void, + updateOutput?: (output: ToolLiveOutput) => void, ): Promise { let browserManager; diff --git a/packages/core/src/agents/local-invocation.ts b/packages/core/src/agents/local-invocation.ts index 20f9e849830..c4f02b16e9e 100644 --- a/packages/core/src/agents/local-invocation.ts +++ b/packages/core/src/agents/local-invocation.ts @@ -6,8 +6,11 @@ import type { Config } from '../config/config.js'; import { LocalAgentExecutor } from './local-executor.js'; -import type { AnsiOutput } from '../utils/terminalSerializer.js'; -import { BaseToolInvocation, type ToolResult } from '../tools/tools.js'; +import { + BaseToolInvocation, + type ToolResult, + type ToolLiveOutput, +} from '../tools/tools.js'; import { type LocalAgentDefinition, type AgentInputs, @@ -84,7 +87,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation< */ async execute( signal: AbortSignal, - updateOutput?: (output: string | AnsiOutput) => void, + updateOutput?: (output: ToolLiveOutput) => void, ): Promise { let recentActivity: SubagentActivityItem[] = []; @@ -97,7 +100,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation< recentActivity: [], state: 'running', }; - updateOutput(initialProgress as unknown as AnsiOutput); + updateOutput(initialProgress); } // Create an activity callback to bridge the executor's events to the @@ -178,7 +181,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation< state: 'running', }; - updateOutput(progress as unknown as AnsiOutput); + updateOutput(progress); } }; @@ -199,7 +202,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation< }; if (updateOutput) { - updateOutput(progress as unknown as AnsiOutput); + updateOutput(progress); } return { @@ -266,7 +269,7 @@ ${output.result} }; if (updateOutput) { - updateOutput(progress as unknown as AnsiOutput); + updateOutput(progress); } return { diff --git a/packages/core/src/agents/subagent-tool.ts b/packages/core/src/agents/subagent-tool.ts index f47b5066343..425d5c073b7 100644 --- a/packages/core/src/agents/subagent-tool.ts +++ b/packages/core/src/agents/subagent-tool.ts @@ -12,8 +12,8 @@ import { BaseToolInvocation, type ToolCallConfirmationDetails, isTool, + type ToolLiveOutput, } from '../tools/tools.js'; -import type { AnsiOutput } from '../utils/terminalSerializer.js'; import type { Config } from '../config/config.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import type { AgentDefinition, AgentInputs } from './types.js'; @@ -149,7 +149,7 @@ class SubAgentInvocation extends BaseToolInvocation { async execute( signal: AbortSignal, - updateOutput?: (output: string | AnsiOutput) => void, + updateOutput?: (output: ToolLiveOutput) => void, ): Promise { const validationError = SchemaValidator.validate( this.definition.inputConfig.inputSchema, diff --git a/packages/core/src/core/coreToolHookTriggers.ts b/packages/core/src/core/coreToolHookTriggers.ts index 9c83253903b..cbd90e8039d 100644 --- a/packages/core/src/core/coreToolHookTriggers.ts +++ b/packages/core/src/core/coreToolHookTriggers.ts @@ -10,10 +10,11 @@ import type { ToolResult, AnyDeclarativeTool, AnyToolInvocation, + ToolLiveOutput, } from '../tools/tools.js'; import { ToolErrorType } from '../tools/tool-error.js'; import { debugLogger } from '../utils/debugLogger.js'; -import type { AnsiOutput, ShellExecutionConfig } from '../index.js'; +import type { ShellExecutionConfig } from '../index.js'; import { ShellToolInvocation } from '../tools/shell.js'; import { DiscoveredMCPToolInvocation } from '../tools/mcp-tool.js'; @@ -71,7 +72,7 @@ export async function executeToolWithHooks( toolName: string, signal: AbortSignal, tool: AnyDeclarativeTool, - liveOutputCallback?: (outputChunk: string | AnsiOutput) => void, + liveOutputCallback?: (outputChunk: ToolLiveOutput) => void, shellExecutionConfig?: ShellExecutionConfig, setPidCallback?: (pid: number) => void, config?: Config, diff --git a/packages/core/src/scheduler/tool-executor.ts b/packages/core/src/scheduler/tool-executor.ts index 89ca71101b5..1e3a631b158 100644 --- a/packages/core/src/scheduler/tool-executor.ts +++ b/packages/core/src/scheduler/tool-executor.ts @@ -9,8 +9,8 @@ import type { ToolCallResponseInfo, ToolResult, Config, - AnsiOutput, ToolResultDisplay, + ToolLiveOutput, } from '../index.js'; import { ToolErrorType, @@ -39,7 +39,7 @@ import { CoreToolCallStatus } from './types.js'; export interface ToolExecutionContext { call: ToolCall; signal: AbortSignal; - outputUpdateHandler?: (callId: string, output: string | AnsiOutput) => void; + outputUpdateHandler?: (callId: string, output: ToolLiveOutput) => void; onUpdateToolCall: (updatedCall: ToolCall) => void; } @@ -62,7 +62,7 @@ export class ToolExecutor { // Setup live output handling const liveOutputCallback = tool.canUpdateOutput && outputUpdateHandler - ? (outputChunk: string | AnsiOutput) => { + ? (outputChunk: ToolLiveOutput) => { outputUpdateHandler(callId, outputChunk); } : undefined; diff --git a/packages/core/src/scheduler/types.ts b/packages/core/src/scheduler/types.ts index 7eaf07e94ee..9fedd48f410 100644 --- a/packages/core/src/scheduler/types.ts +++ b/packages/core/src/scheduler/types.ts @@ -11,8 +11,8 @@ import type { ToolCallConfirmationDetails, ToolConfirmationOutcome, ToolResultDisplay, + ToolLiveOutput, } from '../tools/tools.js'; -import type { AnsiOutput } from '../utils/terminalSerializer.js'; import type { ToolErrorType } from '../tools/tool-error.js'; import type { SerializableConfirmationDetails } from '../confirmation-bus/types.js'; import { type ApprovalMode } from '../policy/types.js'; @@ -125,7 +125,7 @@ export type ExecutingToolCall = { request: ToolCallRequestInfo; tool: AnyDeclarativeTool; invocation: AnyToolInvocation; - liveOutput?: string | AnsiOutput; + liveOutput?: ToolLiveOutput; progressMessage?: string; progressPercent?: number; progress?: number; @@ -197,7 +197,7 @@ export type ConfirmHandler = ( export type OutputUpdateHandler = ( toolCallId: string, - outputChunk: string | AnsiOutput, + outputChunk: ToolLiveOutput, ) => void; export type AllToolCallsCompleteHandler = ( diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 741272f555f..6afded3faa2 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -17,6 +17,7 @@ import type { ToolCallConfirmationDetails, ToolExecuteConfirmationDetails, PolicyUpdateOptions, + ToolLiveOutput, } from './tools.js'; import { BaseDeclarativeTool, @@ -149,7 +150,7 @@ export class ShellToolInvocation extends BaseToolInvocation< async execute( signal: AbortSignal, - updateOutput?: (output: string | AnsiOutput) => void, + updateOutput?: (output: ToolLiveOutput) => void, shellExecutionConfig?: ShellExecutionConfig, setPidCallback?: (pid: number) => void, ): Promise { diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index bd9ca423654..6757e7c1423 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -19,7 +19,7 @@ import { type Question, } from '../confirmation-bus/types.js'; import { type ApprovalMode } from '../policy/types.js'; -import type { SubagentProgress } from '../agents/types.js'; +import type { SubagentProgress } from 'src/agents/types.js'; /** * Represents a validated and ready-to-execute tool call. @@ -65,7 +65,7 @@ export interface ToolInvocation< */ execute( signal: AbortSignal, - updateOutput?: (output: string | AnsiOutput) => void, + updateOutput?: (output: ToolLiveOutput) => void, shellExecutionConfig?: ShellExecutionConfig, ): Promise; } @@ -277,7 +277,7 @@ export abstract class BaseToolInvocation< abstract execute( signal: AbortSignal, - updateOutput?: (output: string | AnsiOutput) => void, + updateOutput?: (output: ToolLiveOutput) => void, shellExecutionConfig?: ShellExecutionConfig, ): Promise; } @@ -423,7 +423,7 @@ export abstract class DeclarativeTool< async buildAndExecute( params: TParams, signal: AbortSignal, - updateOutput?: (output: string | AnsiOutput) => void, + updateOutput?: (output: ToolLiveOutput) => void, shellExecutionConfig?: ShellExecutionConfig, ): Promise { const invocation = this.build(params); @@ -689,6 +689,8 @@ export interface TodoList { todos: Todo[]; } +export type ToolLiveOutput = string | AnsiOutput | SubagentProgress; + export type ToolResultDisplay = | string | FileDiff From 4dd4019228ed460469ce93a8d4e4a6aced3a3baf Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Thu, 26 Feb 2026 16:19:45 -0800 Subject: [PATCH 04/11] Fix linter error. --- .../ui/components/messages/ToolResultDisplay.tsx | 14 +++++--------- packages/core/src/agents/types.ts | 9 +++++++++ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 0144643807f..1c29407e91a 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -11,7 +11,11 @@ import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js'; import { MaxSizedBox } from '../shared/MaxSizedBox.js'; import { theme } from '../../semantic-colors.js'; -import type { AnsiOutput, AnsiLine, SubagentProgress } from '@google/gemini-cli-core'; +import { + type AnsiOutput, + type AnsiLine, + isSubagentProgress, +} from '@google/gemini-cli-core'; import { useUIState } from '../../contexts/UIStateContext.js'; import { tryParseJSON } from '../../../utils/jsonoutput.js'; import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; @@ -40,14 +44,6 @@ interface FileDiffResult { fileName: string; } -function isSubagentProgress(obj: unknown): obj is SubagentProgress { - return ( - typeof obj === 'object' && - obj !== null && - (obj as SubagentProgress).isSubagentProgress === true - ); -} - export const ToolResultDisplay: React.FC = ({ resultDisplay, availableTerminalHeight, diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index bbc062fa1c4..fe622291984 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -85,6 +85,15 @@ export interface SubagentProgress { state?: 'running' | 'completed' | 'error' | 'cancelled'; } +export function isSubagentProgress(obj: unknown): obj is SubagentProgress { + return ( + typeof obj === 'object' && + obj !== null && + 'isSubagentProgress' in obj && + obj.isSubagentProgress === true + ); +} + /** * The base definition for an agent. * @template TOutput The specific Zod schema for the agent's final output object. From 5e4ddf96b74936e8c7b668336178aa026c1feba0 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Thu, 26 Feb 2026 16:27:46 -0800 Subject: [PATCH 05/11] UX tests. --- .../messages/SubagentProgressDisplay.test.tsx | 65 +++++++++---------- .../SubagentProgressDisplay.test.tsx.snap | 41 ++++++++++++ .../core/src/agents/local-invocation.test.ts | 14 +++- 3 files changed, 85 insertions(+), 35 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/SubagentProgressDisplay.test.tsx.snap diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx index 95659373f63..3a26eaa6693 100644 --- a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx @@ -4,10 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../../test-utils/render.js'; +import { render, cleanup } from '../../../test-utils/render.js'; import { SubagentProgressDisplay } from './SubagentProgressDisplay.js'; import type { SubagentProgress } from '@google/gemini-cli-core'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { Text } from 'ink'; vi.mock('ink-spinner', () => ({ @@ -15,7 +15,12 @@ vi.mock('ink-spinner', () => ({ })); describe('', () => { - it('renders correctly with description in args', () => { + afterEach(() => { + vi.restoreAllMocks(); + cleanup(); + }); + + it('renders correctly with description in args', async () => { const progress: SubagentProgress = { isSubagentProgress: true, agentName: 'TestAgent', @@ -29,17 +34,14 @@ describe('', () => { ], }; - const { lastFrame } = render( + const { lastFrame, waitUntilReady } = render( , ); - const frame = lastFrame(); - expect(frame).toContain('Subagent TestAgent is working...'); - expect(frame).toContain('run_shell_command'); - expect(frame).toContain('Say hello'); - expect(frame).not.toContain('{"command": "echo hello"'); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); }); - it('renders correctly with command fallback', () => { + it('renders correctly with command fallback', async () => { const progress: SubagentProgress = { isSubagentProgress: true, agentName: 'TestAgent', @@ -53,14 +55,14 @@ describe('', () => { ], }; - const { lastFrame } = render( + const { lastFrame, waitUntilReady } = render( , ); - const frame = lastFrame(); - expect(frame).toContain('echo hello'); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); }); - it('renders correctly with file_path', () => { + it('renders correctly with file_path', async () => { const progress: SubagentProgress = { isSubagentProgress: true, agentName: 'TestAgent', @@ -74,15 +76,14 @@ describe('', () => { ], }; - const { lastFrame } = render( + const { lastFrame, waitUntilReady } = render( , ); - const frame = lastFrame(); - expect(frame).toContain('/tmp/test.txt'); - expect(frame).not.toContain('"content": "foo"'); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); }); - it('truncates long args', () => { + it('truncates long args', async () => { const longDesc = 'This is a very long description that should definitely be truncated because it exceeds the limit of sixty characters.'; const progress: SubagentProgress = { @@ -98,18 +99,14 @@ describe('', () => { ], }; - const { lastFrame } = render( + const { lastFrame, waitUntilReady } = render( , ); - const frame = lastFrame(); - expect(frame).toContain('This is a very long description'); - expect(frame).toContain('...'); - // slice(0, 60) - // "This is a very long description that should definitely be tr" - expect(frame).not.toContain('sixty characters'); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); }); - it('renders thought bubbles correctly', () => { + it('renders thought bubbles correctly', async () => { const progress: SubagentProgress = { isSubagentProgress: true, agentName: 'TestAgent', @@ -122,14 +119,14 @@ describe('', () => { ], }; - const { lastFrame } = render( + const { lastFrame, waitUntilReady } = render( , ); - const frame = lastFrame(); - expect(frame).toContain('💭 Thinking about life'); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); }); - it('renders cancelled state correctly', () => { + it('renders cancelled state correctly', async () => { const progress: SubagentProgress = { isSubagentProgress: true, agentName: 'TestAgent', @@ -137,10 +134,10 @@ describe('', () => { state: 'cancelled', }; - const { lastFrame } = render( + const { lastFrame, waitUntilReady } = render( , ); - const frame = lastFrame(); - expect(frame).toContain('Subagent TestAgent was cancelled.'); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); }); }); 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 new file mode 100644 index 00000000000..8dd192e431a --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/SubagentProgressDisplay.test.tsx.snap @@ -0,0 +1,41 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders cancelled state correctly 1`] = ` +"Subagent TestAgent was cancelled. +" +`; + +exports[` > renders correctly with command fallback 1`] = ` +"Subagent TestAgent is working... + +⠋ run_shell_command echo hello +" +`; + +exports[` > renders correctly with description in args 1`] = ` +"Subagent TestAgent is working... + +⠋ run_shell_command Say hello +" +`; + +exports[` > renders correctly with file_path 1`] = ` +"Subagent TestAgent is working... + +✓ write_file /tmp/test.txt +" +`; + +exports[` > renders thought bubbles correctly 1`] = ` +"Subagent TestAgent is working... + +💭 Thinking about life +" +`; + +exports[` > truncates long args 1`] = ` +"Subagent TestAgent is working... + +⠋ run_shell_command This is a very long description that should definitely be tr... +" +`; diff --git a/packages/core/src/agents/local-invocation.test.ts b/packages/core/src/agents/local-invocation.test.ts index 0b2889a2c98..d12dd3567d7 100644 --- a/packages/core/src/agents/local-invocation.test.ts +++ b/packages/core/src/agents/local-invocation.test.ts @@ -4,7 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mocked, +} from 'vitest'; import type { LocalAgentDefinition, SubagentActivityEvent, @@ -71,6 +79,10 @@ describe('LocalSubagentInvocation', () => { ); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should pass the messageBus to the parent constructor', () => { const params = { task: 'Analyze data' }; const invocation = new LocalSubagentInvocation( From 037fef2b3cc818476416bdf9ec640bd333a52d9b Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Thu, 26 Feb 2026 16:33:17 -0800 Subject: [PATCH 06/11] Fix build. --- packages/core/src/tools/tools.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 6757e7c1423..ffd949c83aa 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -19,7 +19,7 @@ import { type Question, } from '../confirmation-bus/types.js'; import { type ApprovalMode } from '../policy/types.js'; -import type { SubagentProgress } from 'src/agents/types.js'; +import type { SubagentProgress } from '../agents/types.js'; /** * Represents a validated and ready-to-execute tool call. From 9b8c5fdfbd780fbb0d1c489af10162d36f73bf3e Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Thu, 26 Feb 2026 18:03:05 -0800 Subject: [PATCH 07/11] Fix icon and scrolling. --- .../cli/src/ui/components/MainContent.tsx | 3 +- .../messages/SubagentProgressDisplay.test.tsx | 26 ++++ .../messages/SubagentProgressDisplay.tsx | 137 +++++++++++------- .../components/messages/ToolGroupMessage.tsx | 1 + .../SubagentProgressDisplay.test.tsx.snap | 7 + packages/cli/src/ui/hooks/toolMapping.ts | 1 + packages/cli/src/ui/types.ts | 1 + packages/core/src/agents/local-executor.ts | 2 +- packages/core/src/agents/local-invocation.ts | 36 ++++- .../core/src/agents/subagent-tool.test.ts | 8 + packages/core/src/agents/types.ts | 3 +- packages/core/src/utils/tool-utils.test.ts | 11 ++ packages/core/src/utils/tool-utils.ts | 16 +- 13 files changed, 186 insertions(+), 66 deletions(-) diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index fbcc9626637..7386a246e7b 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -34,6 +34,7 @@ export const MainContent = () => { const confirmingTool = useConfirmingTool(); const showConfirmationQueue = confirmingTool !== null; + const confirmingToolCallId = confirmingTool?.tool.callId; const scrollableListRef = useRef>(null); @@ -41,7 +42,7 @@ export const MainContent = () => { if (showConfirmationQueue) { scrollableListRef.current?.scrollToEnd(); } - }, [showConfirmationQueue, confirmingTool]); + }, [showConfirmationQueue, confirmingToolCallId]); const { pendingHistoryItems, diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx index 3a26eaa6693..43f7b848702 100644 --- a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx @@ -26,6 +26,7 @@ describe('', () => { agentName: 'TestAgent', recentActivity: [ { + id: '1', type: 'tool_call', content: 'run_shell_command', args: '{"command": "echo hello", "description": "Say hello"}', @@ -47,6 +48,7 @@ describe('', () => { agentName: 'TestAgent', recentActivity: [ { + id: '2', type: 'tool_call', content: 'run_shell_command', args: '{"command": "echo hello"}', @@ -68,6 +70,7 @@ describe('', () => { agentName: 'TestAgent', recentActivity: [ { + id: '3', type: 'tool_call', content: 'write_file', args: '{"file_path": "/tmp/test.txt", "content": "foo"}', @@ -91,6 +94,7 @@ describe('', () => { agentName: 'TestAgent', recentActivity: [ { + id: '4', type: 'tool_call', content: 'run_shell_command', args: JSON.stringify({ description: longDesc }), @@ -112,6 +116,7 @@ describe('', () => { agentName: 'TestAgent', recentActivity: [ { + id: '5', type: 'thought', content: 'Thinking about life', status: 'running', @@ -140,4 +145,25 @@ describe('', () => { await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); + + it('renders "Request cancelled." with the info icon', async () => { + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: 'TestAgent', + recentActivity: [ + { + id: '6', + type: 'thought', + content: 'Request cancelled.', + status: 'error', + }, + ], + }; + + 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 5b3a906ff5b..57e72cfff26 100644 --- a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx @@ -22,16 +22,29 @@ export interface SubagentProgressDisplayProps { const formatToolArgs = (args?: string): string => { if (!args) return ''; try { - const parsed = JSON.parse(args); - if (typeof parsed.description === 'string' && parsed.description) { + const parsed: unknown = JSON.parse(args); + if (typeof parsed !== 'object' || parsed === null) { + return args; + } + + if ( + 'description' in parsed && + typeof parsed.description === 'string' && + parsed.description + ) { return parsed.description; } - if (typeof parsed.command === 'string') return parsed.command; - if (typeof parsed.file_path === 'string') return parsed.file_path; - if (typeof parsed.dir_path === 'string') return parsed.dir_path; - if (typeof parsed.query === 'string') return parsed.query; - if (typeof parsed.url === 'string') return parsed.url; - if (typeof parsed.target === 'string') return parsed.target; + if ('command' in parsed && typeof parsed.command === 'string') + return parsed.command; + if ('file_path' in parsed && typeof parsed.file_path === 'string') + return parsed.file_path; + if ('dir_path' in parsed && typeof parsed.dir_path === 'string') + return parsed.dir_path; + if ('query' in parsed && typeof parsed.query === 'string') + return parsed.query; + if ('url' in parsed && typeof parsed.url === 'string') return parsed.url; + if ('target' in parsed && typeof parsed.target === 'string') + return parsed.target; return args; } catch { @@ -64,58 +77,72 @@ export const SubagentProgressDisplay: React.FC< - {progress.recentActivity.map( - (item: SubagentActivityItem, index: number) => { - if (item.type === 'thought') { - return ( - - - 💭 - - - {item.content} - + {progress.recentActivity.map((item: SubagentActivityItem) => { + if (item.type === 'thought') { + const isCancellation = item.content === 'Request cancelled.'; + const icon = isCancellation ? 'ℹ ' : '💭'; + const color = isCancellation + ? theme.status.warning + : theme.text.secondary; + + return ( + + + {icon} + + + {item.content} + + ); + } else if (item.type === 'tool_call') { + const statusSymbol = + item.status === 'running' ? ( + + ) : item.status === 'completed' ? ( + {TOOL_STATUS.SUCCESS} + ) : item.status === 'cancelled' ? ( + + {TOOL_STATUS.CANCELED} + + ) : ( + {TOOL_STATUS.ERROR} ); - } else if (item.type === 'tool_call') { - const statusSymbol = - item.status === 'running' ? ( - - ) : item.status === 'completed' ? ( - - {TOOL_STATUS.SUCCESS} - - ) : ( - {TOOL_STATUS.ERROR} - ); - const formattedArgs = formatToolArgs(item.args); - const displayArgs = - formattedArgs.length > 60 - ? formattedArgs.slice(0, 60) + '...' - : formattedArgs; + const formattedArgs = formatToolArgs(item.args); + const displayArgs = + formattedArgs.length > 60 + ? formattedArgs.slice(0, 60) + '...' + : formattedArgs; - return ( - - {statusSymbol} - - - {item.content} - - {displayArgs && ( - - - {displayArgs} - - - )} - + return ( + + {statusSymbol} + + + {item.content} + + {displayArgs && ( + + + {displayArgs} + + + )} - ); - } - return null; - }, - )} + + ); + } + return null; + })} ); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 3c3dcf56d32..152a37097a9 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -61,6 +61,7 @@ export const ToolGroupMessage: React.FC = ({ status: t.status, approvalMode: t.approvalMode, hasResultDisplay: !!t.resultDisplay, + parentCallId: t.parentCallId, }), ), [allToolCalls], 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 8dd192e431a..96821648ed3 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,5 +1,12 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[` > renders "Request cancelled." with the info icon 1`] = ` +"Subagent TestAgent is working... + +ℹ Request cancelled. +" +`; + exports[` > renders cancelled state correctly 1`] = ` "Subagent TestAgent was cancelled. " diff --git a/packages/cli/src/ui/hooks/toolMapping.ts b/packages/cli/src/ui/hooks/toolMapping.ts index 5a9db194ffd..8d5efc20525 100644 --- a/packages/cli/src/ui/hooks/toolMapping.ts +++ b/packages/cli/src/ui/hooks/toolMapping.ts @@ -48,6 +48,7 @@ export function mapToDisplay( const baseDisplayProperties = { callId: call.request.callId, + parentCallId: call.request.parentCallId, name: displayName, description, renderOutputAsMarkdown, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index ee958fcfb5b..fdbc18b340c 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -98,6 +98,7 @@ export interface ToolCallEvent { export interface IndividualToolCallDisplay { callId: string; + parentCallId?: string; name: string; description: string; resultDisplay: ToolResultDisplay | undefined; diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index 513424ad32a..aafeab93359 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -1077,7 +1077,7 @@ export class LocalAgentExecutor { this.emitActivity('ERROR', { context: 'tool_call', name: toolName, - error: 'Tool call was cancelled.', + error: 'Request cancelled.', }); } diff --git a/packages/core/src/agents/local-invocation.ts b/packages/core/src/agents/local-invocation.ts index c4f02b16e9e..3d9d1c96e1c 100644 --- a/packages/core/src/agents/local-invocation.ts +++ b/packages/core/src/agents/local-invocation.ts @@ -19,6 +19,7 @@ import { type SubagentActivityItem, AgentTerminateMode, } from './types.js'; +import { randomUUID } from 'node:crypto'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; const INPUT_PREVIEW_MAX_LENGTH = 50; @@ -114,10 +115,15 @@ export class LocalSubagentInvocation extends BaseToolInvocation< case 'THOUGHT_CHUNK': { const text = String(activity.data['text']); const lastItem = recentActivity[recentActivity.length - 1]; - if (lastItem && lastItem.type === 'thought') { + if ( + lastItem && + lastItem.type === 'thought' && + lastItem.status === 'running' + ) { lastItem.content += text; } else { recentActivity.push({ + id: randomUUID(), type: 'thought', content: text, status: 'running', @@ -130,6 +136,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation< const name = String(activity.data['name']); const args = JSON.stringify(activity.data['args']); recentActivity.push({ + id: randomUUID(), type: 'tool_call', content: name, args, @@ -156,10 +163,30 @@ export class LocalSubagentInvocation extends BaseToolInvocation< } case 'ERROR': { const error = String(activity.data['error']); + const isCancellation = error === 'Request cancelled.'; + const toolName = activity.data['name'] + ? String(activity.data['name']) + : undefined; + + if (toolName && isCancellation) { + for (let i = recentActivity.length - 1; i >= 0; i--) { + if ( + recentActivity[i].type === 'tool_call' && + recentActivity[i].content === toolName && + recentActivity[i].status === 'running' + ) { + recentActivity[i].status = 'cancelled'; + updated = true; + break; + } + } + } + recentActivity.push({ + id: randomUUID(), type: 'thought', // Treat errors as thoughts for now, or add an error type - content: `Error: ${error}`, - status: 'error', + content: isCancellation ? error : `Error: ${error}`, + status: isCancellation ? 'cancelled' : 'error', }); updated = true; break; @@ -240,7 +267,7 @@ ${output.result} // Mark any running items as error/cancelled for (const item of recentActivity) { if (item.status === 'running') { - item.status = 'error'; + item.status = isAbort ? 'cancelled' : 'error'; } } @@ -250,6 +277,7 @@ ${output.result} const lastActivity = recentActivity[recentActivity.length - 1]; if (!lastActivity || lastActivity.status !== 'error') { recentActivity.push({ + id: randomUUID(), type: 'thought', content: `Error: ${errorMessage}`, status: 'error', diff --git a/packages/core/src/agents/subagent-tool.test.ts b/packages/core/src/agents/subagent-tool.test.ts index d6d6bdfd898..dcd26e8931f 100644 --- a/packages/core/src/agents/subagent-tool.test.ts +++ b/packages/core/src/agents/subagent-tool.test.ts @@ -94,6 +94,14 @@ describe('SubAgentInvocation', () => { ); }); + it('should return an empty description', () => { + const tool = new SubagentTool(testDefinition, mockConfig, mockMessageBus); + const params = {}; + // @ts-expect-error - accessing protected method for testing + const invocation = tool.createInvocation(params, mockMessageBus); + expect(invocation.getDescription()).toBe(''); + }); + it('should delegate shouldConfirmExecute to the inner sub-invocation (remote)', async () => { const tool = new SubagentTool( testRemoteDefinition, diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index fe622291984..416fbc04b68 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -72,10 +72,11 @@ export interface SubagentActivityEvent { } export interface SubagentActivityItem { + id: string; type: 'thought' | 'tool_call'; content: string; args?: string; - status: 'running' | 'completed' | 'error'; + status: 'running' | 'completed' | 'error' | 'cancelled'; } export interface SubagentProgress { diff --git a/packages/core/src/utils/tool-utils.test.ts b/packages/core/src/utils/tool-utils.test.ts index 225889d53ad..c007b37715f 100644 --- a/packages/core/src/utils/tool-utils.test.ts +++ b/packages/core/src/utils/tool-utils.test.ts @@ -98,6 +98,17 @@ describe('shouldHideToolCall', () => { ).toBe(!visible); }, ); + + it('hides tool calls with a parentCallId', () => { + expect( + shouldHideToolCall({ + displayName: 'any_tool', + status: CoreToolCallStatus.Success, + hasResultDisplay: true, + parentCallId: 'some-parent', + }), + ).toBe(true); + }); }); describe('getToolSuggestion', () => { diff --git a/packages/core/src/utils/tool-utils.ts b/packages/core/src/utils/tool-utils.ts index b8e60fe4cea..17ccbda8d67 100644 --- a/packages/core/src/utils/tool-utils.ts +++ b/packages/core/src/utils/tool-utils.ts @@ -28,20 +28,28 @@ export interface ShouldHideToolCallParams { approvalMode?: ApprovalMode; /** Whether the tool has produced a result for display. */ hasResultDisplay: boolean; + /** The ID of the parent tool call, if any. */ + parentCallId?: string; } /** * Determines if a tool call should be hidden from the standard tool history UI. * * We hide tools in several cases: - * 1. Ask User tools that are in progress, displayed via specialized UI. - * 2. Ask User tools that errored without result display, typically param + * 1. Tool calls that have a parent, as they are "internal" to another tool (e.g. subagent). + * 2. Ask User tools that are in progress, displayed via specialized UI. + * 3. Ask User tools that errored without result display, typically param * validation errors that the agent automatically recovers from. - * 3. WriteFile and Edit tools when in Plan Mode, redundant because the + * 4. WriteFile and Edit tools when in Plan Mode, redundant because the * resulting plans are displayed separately upon exiting plan mode. */ export function shouldHideToolCall(params: ShouldHideToolCallParams): boolean { - const { displayName, status, approvalMode, hasResultDisplay } = params; + const { displayName, status, approvalMode, hasResultDisplay, parentCallId } = + params; + + if (parentCallId) { + return true; + } switch (displayName) { case ASK_USER_DISPLAY_NAME: From 92d15d83594ce2c528139da0f8a26bff61be64df Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Thu, 26 Feb 2026 18:48:56 -0800 Subject: [PATCH 08/11] Ux change. --- .../messages/SubagentProgressDisplay.tsx | 14 ++++++----- .../SubagentProgressDisplay.test.tsx.snap | 24 +++++-------------- .../core/src/agents/subagent-tool.test.ts | 6 +++-- 3 files changed, 18 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx index 57e72cfff26..b2b08adf04f 100644 --- a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx @@ -55,7 +55,7 @@ const formatToolArgs = (args?: string): string => { export const SubagentProgressDisplay: React.FC< SubagentProgressDisplayProps > = ({ progress }) => { - let headerText = `Subagent ${progress.agentName} is working...`; + let headerText: string | undefined; let headerColor = theme.text.secondary; if (progress.state === 'cancelled') { @@ -71,11 +71,13 @@ export const SubagentProgressDisplay: React.FC< return ( - - - {headerText} - - + {headerText && ( + + + {headerText} + + + )} {progress.recentActivity.map((item: SubagentActivityItem) => { if (item.type === 'thought') { 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 96821648ed3..e9403704e93 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,9 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[` > renders "Request cancelled." with the info icon 1`] = ` -"Subagent TestAgent is working... - -ℹ Request cancelled. +"ℹ Request cancelled. " `; @@ -13,36 +11,26 @@ exports[` > renders cancelled state correctly 1`] = ` `; exports[` > renders correctly with command fallback 1`] = ` -"Subagent TestAgent is working... - -⠋ run_shell_command echo hello +"⠋ run_shell_command echo hello " `; exports[` > renders correctly with description in args 1`] = ` -"Subagent TestAgent is working... - -⠋ run_shell_command Say hello +"⠋ run_shell_command Say hello " `; exports[` > renders correctly with file_path 1`] = ` -"Subagent TestAgent is working... - -✓ write_file /tmp/test.txt +"✓ write_file /tmp/test.txt " `; exports[` > renders thought bubbles correctly 1`] = ` -"Subagent TestAgent is working... - -💭 Thinking about life +"💭 Thinking about life " `; exports[` > truncates long args 1`] = ` -"Subagent TestAgent is working... - -⠋ run_shell_command This is a very long description that should definitely be tr... +"⠋ run_shell_command This is a very long description that should definitely be tr... " `; diff --git a/packages/core/src/agents/subagent-tool.test.ts b/packages/core/src/agents/subagent-tool.test.ts index dcd26e8931f..f38b242cba0 100644 --- a/packages/core/src/agents/subagent-tool.test.ts +++ b/packages/core/src/agents/subagent-tool.test.ts @@ -94,12 +94,14 @@ describe('SubAgentInvocation', () => { ); }); - it('should return an empty description', () => { + it('should return the correct description', () => { const tool = new SubagentTool(testDefinition, mockConfig, mockMessageBus); const params = {}; // @ts-expect-error - accessing protected method for testing const invocation = tool.createInvocation(params, mockMessageBus); - expect(invocation.getDescription()).toBe(''); + expect(invocation.getDescription()).toBe( + "Delegating to agent 'LocalAgent'", + ); }); it('should delegate shouldConfirmExecute to the inner sub-invocation (remote)', async () => { From a43d43a156d5aab83a12b6f1445204e5f141bec4 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Thu, 26 Feb 2026 19:24:37 -0800 Subject: [PATCH 09/11] Bubble up aborts. --- packages/core/src/agents/local-executor.ts | 15 ++++++++++++++- .../core/src/agents/local-invocation.test.ts | 19 +++++-------------- packages/core/src/agents/local-invocation.ts | 11 +++++++---- packages/core/src/scheduler/tool-executor.ts | 7 ++++++- 4 files changed, 32 insertions(+), 20 deletions(-) diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index aafeab93359..34f1279a669 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -269,13 +269,22 @@ export class LocalAgentExecutor { }; } - const { nextMessage, submittedOutput, taskCompleted } = + const { nextMessage, submittedOutput, taskCompleted, aborted } = await this.processFunctionCalls( functionCalls, combinedSignal, promptId, onWaitingForConfirmation, ); + + if (aborted) { + return { + status: 'stop', + terminateReason: AgentTerminateMode.ABORTED, + finalResult: null, + }; + } + if (taskCompleted) { const finalResult = submittedOutput ?? 'Task completed successfully.'; return { @@ -857,6 +866,7 @@ export class LocalAgentExecutor { nextMessage: Content; submittedOutput: string | null; taskCompleted: boolean; + aborted: boolean; }> { const allowedToolNames = new Set(this.toolRegistry.getAllToolNames()); // Always allow the completion tool @@ -864,6 +874,7 @@ export class LocalAgentExecutor { let submittedOutput: string | null = null; let taskCompleted = false; + let aborted = false; // We'll separate complete_task from other tools const toolRequests: ToolCallRequestInfo[] = []; @@ -1079,6 +1090,7 @@ export class LocalAgentExecutor { name: toolName, error: 'Request cancelled.', }); + aborted = true; } // Add result to syncResults to preserve order later @@ -1111,6 +1123,7 @@ export class LocalAgentExecutor { nextMessage: { role: 'user', parts: toolResponseParts }, submittedOutput, taskCompleted, + aborted, }; } diff --git a/packages/core/src/agents/local-invocation.test.ts b/packages/core/src/agents/local-invocation.test.ts index d12dd3567d7..77509881afe 100644 --- a/packages/core/src/agents/local-invocation.test.ts +++ b/packages/core/src/agents/local-invocation.test.ts @@ -336,6 +336,7 @@ describe('LocalSubagentInvocation', () => { it('should handle abortion signal during execution', async () => { const abortError = new Error('Aborted'); + abortError.name = 'AbortError'; mockExecutorInstance.run.mockRejectedValue(abortError); const controller = new AbortController(); @@ -344,34 +345,24 @@ describe('LocalSubagentInvocation', () => { updateOutput, ); controller.abort(); - const result = await executePromise; + await expect(executePromise).rejects.toThrow('Aborted'); expect(mockExecutorInstance.run).toHaveBeenCalledWith( params, controller.signal, ); - expect(result.error).toBeUndefined(); - expect(result.llmContent).toContain('Aborted'); }); - it('should return cancelled state when execution returns ABORTED', async () => { + it('should throw an error and bubble cancellation when execution returns ABORTED', async () => { const mockOutput = { result: 'Cancelled by user', terminate_reason: AgentTerminateMode.ABORTED, }; mockExecutorInstance.run.mockResolvedValue(mockOutput); - const result = await invocation.execute(signal, updateOutput); - - expect(result.llmContent).toEqual( - expect.stringContaining( - "Subagent 'MockAgent' was cancelled by the user.", - ), + await expect(invocation.execute(signal, updateOutput)).rejects.toThrow( + 'Operation cancelled by user', ); - - const display = result.returnDisplay as SubagentProgress; - expect(display.isSubagentProgress).toBe(true); - expect(display.state).toBe('cancelled'); }); }); }); diff --git a/packages/core/src/agents/local-invocation.ts b/packages/core/src/agents/local-invocation.ts index 3d9d1c96e1c..62b90a818d8 100644 --- a/packages/core/src/agents/local-invocation.ts +++ b/packages/core/src/agents/local-invocation.ts @@ -232,10 +232,9 @@ export class LocalSubagentInvocation extends BaseToolInvocation< updateOutput(progress); } - return { - llmContent: `Subagent '${this.definition.name}' was cancelled by the user.`, - returnDisplay: progress, - }; + const cancelError = new Error('Operation cancelled by user'); + cancelError.name = 'AbortError'; + throw cancelError; } const resultContent = `Subagent '${this.definition.name}' finished. @@ -300,6 +299,10 @@ ${output.result} updateOutput(progress); } + if (isAbort) { + throw error; + } + return { llmContent: `Subagent '${this.definition.name}' failed. Error: ${errorMessage}`, returnDisplay: progress, diff --git a/packages/core/src/scheduler/tool-executor.ts b/packages/core/src/scheduler/tool-executor.ts index 1e3a631b158..574e674c95b 100644 --- a/packages/core/src/scheduler/tool-executor.ts +++ b/packages/core/src/scheduler/tool-executor.ts @@ -142,7 +142,12 @@ export class ToolExecutor { } } catch (executionError: unknown) { spanMetadata.error = executionError; - if (signal.aborted) { + const isAbortError = + executionError instanceof Error && + (executionError.name === 'AbortError' || + executionError.message.includes('Operation cancelled by user')); + + if (signal.aborted || isAbortError) { return this.createCancelledResult( call, 'User cancelled tool execution.', From 616acda69b1646d74984e55a11ed0aedea7f694d Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Thu, 26 Feb 2026 20:06:21 -0800 Subject: [PATCH 10/11] Use friendly names. --- packages/a2a-server/src/agent/task.ts | 7 ++++-- .../messages/SubagentProgressDisplay.test.tsx | 24 +++++++++++++++++++ .../messages/SubagentProgressDisplay.tsx | 4 ++-- .../SubagentProgressDisplay.test.tsx.snap | 5 ++++ packages/core/src/agents/local-executor.ts | 16 +++++++++++++ packages/core/src/agents/local-invocation.ts | 8 +++++++ packages/core/src/agents/types.ts | 2 ++ 7 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index c91ef727819..cf1e95a70f9 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -27,7 +27,8 @@ import { type ToolCallConfirmationDetails, type Config, type UserTierId, - type AnsiOutput, + type ToolLiveOutput, + isSubagentProgress, EDIT_TOOL_NAMES, processRestorableToolCalls, } from '@google/gemini-cli-core'; @@ -333,11 +334,13 @@ export class Task { private _schedulerOutputUpdate( toolCallId: string, - outputChunk: string | AnsiOutput, + outputChunk: ToolLiveOutput, ): void { let outputAsText: string; if (typeof outputChunk === 'string') { outputAsText = outputChunk; + } else if (isSubagentProgress(outputChunk)) { + outputAsText = JSON.stringify(outputChunk); } else { outputAsText = outputChunk .map((line) => line.map((token) => token.text).join('')) diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx index 43f7b848702..e8b67301adf 100644 --- a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx @@ -42,6 +42,30 @@ describe('', () => { expect(lastFrame()).toMatchSnapshot(); }); + it('renders correctly with displayName and description from item', async () => { + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: 'TestAgent', + recentActivity: [ + { + id: '1', + type: 'tool_call', + content: 'run_shell_command', + displayName: 'RunShellCommand', + description: 'Executing echo hello', + args: '{"command": "echo hello"}', + status: 'running', + }, + ], + }; + + const { lastFrame, waitUntilReady } = render( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + it('renders correctly with command fallback', async () => { const progress: SubagentProgress = { isSubagentProgress: true, diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx index b2b08adf04f..b34a904b3ef 100644 --- a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx @@ -111,7 +111,7 @@ export const SubagentProgressDisplay: React.FC< {TOOL_STATUS.ERROR} ); - const formattedArgs = formatToolArgs(item.args); + const formattedArgs = item.description || formatToolArgs(item.args); const displayArgs = formattedArgs.length > 60 ? formattedArgs.slice(0, 60) + '...' @@ -126,7 +126,7 @@ export const SubagentProgressDisplay: React.FC< color={theme.text.primary} strikethrough={item.status === 'cancelled'} > - {item.content} + {item.displayName || item.content} {displayArgs && ( 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 e9403704e93..8a4c5bd4c48 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 @@ -20,6 +20,11 @@ exports[` > renders correctly with description in arg " `; +exports[` > renders correctly with displayName and description from item 1`] = ` +"⠋ RunShellCommand Executing echo hello +" +`; + exports[` > renders correctly with file_path 1`] = ` "✓ write_file /tmp/test.txt " diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index 34f1279a669..47217213f72 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -889,8 +889,24 @@ export class LocalAgentExecutor { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const toolName = functionCall.name as string; + let displayName = toolName; + let description: string | undefined = undefined; + + try { + const tool = this.toolRegistry.getTool(toolName); + if (tool) { + displayName = tool.displayName ?? toolName; + const invocation = tool.build(args); + description = invocation.getDescription(); + } + } catch { + // Ignore errors during formatting for activity emission + } + this.emitActivity('TOOL_CALL_START', { name: toolName, + displayName, + description, args, }); diff --git a/packages/core/src/agents/local-invocation.ts b/packages/core/src/agents/local-invocation.ts index 62b90a818d8..4bd2bc171a2 100644 --- a/packages/core/src/agents/local-invocation.ts +++ b/packages/core/src/agents/local-invocation.ts @@ -134,11 +134,19 @@ export class LocalSubagentInvocation extends BaseToolInvocation< } case 'TOOL_CALL_START': { const name = String(activity.data['name']); + const displayName = activity.data['displayName'] + ? String(activity.data['displayName']) + : undefined; + const description = activity.data['description'] + ? String(activity.data['description']) + : undefined; const args = JSON.stringify(activity.data['args']); recentActivity.push({ id: randomUUID(), type: 'tool_call', content: name, + displayName, + description, args, status: 'running', }); diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index 416fbc04b68..d23ae769ab9 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -75,6 +75,8 @@ export interface SubagentActivityItem { id: string; type: 'thought' | 'tool_call'; content: string; + displayName?: string; + description?: string; args?: string; status: 'running' | 'completed' | 'error' | 'cancelled'; } From 837bd10c3cab8e605e1b97a5b6049721d865c43a Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Thu, 26 Feb 2026 20:23:21 -0800 Subject: [PATCH 11/11] Fix tests. --- packages/core/src/agents/local-executor.test.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/core/src/agents/local-executor.test.ts b/packages/core/src/agents/local-executor.test.ts index 8f7269b784a..df8755015c1 100644 --- a/packages/core/src/agents/local-executor.test.ts +++ b/packages/core/src/agents/local-executor.test.ts @@ -711,25 +711,28 @@ describe('LocalAgentExecutor', () => { expect.arrayContaining([ expect.objectContaining({ type: 'THOUGHT_CHUNK', - data: { text: 'T1: Listing' }, + data: expect.objectContaining({ text: 'T1: Listing' }), }), expect.objectContaining({ type: 'TOOL_CALL_END', - data: { name: LS_TOOL_NAME, output: 'file1.txt' }, + data: expect.objectContaining({ + name: LS_TOOL_NAME, + output: 'file1.txt', + }), }), expect.objectContaining({ type: 'TOOL_CALL_START', - data: { + data: expect.objectContaining({ name: TASK_COMPLETE_TOOL_NAME, args: { finalResult: 'Found file1.txt' }, - }, + }), }), expect.objectContaining({ type: 'TOOL_CALL_END', - data: { + data: expect.objectContaining({ name: TASK_COMPLETE_TOOL_NAME, output: expect.stringContaining('Output submitted'), - }, + }), }), ]), );