From ebd3bbb9d2567e595b62867b6ee303b19ef0b961 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Mon, 26 Jan 2026 12:02:53 -0800 Subject: [PATCH] Fix ESC. --- .../messages/SubagentProgressDisplay.test.tsx | 15 ++++ .../messages/SubagentProgressDisplay.tsx | 22 +++++- .../cli/src/ui/hooks/useReactToolScheduler.ts | 36 ++++++++++ .../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 +- 7 files changed, 151 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 c17c8cede9..95659373f6 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 c6407ce3fc..5b3a906ff5 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/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts index 08952a5ac7..642a646c34 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts @@ -18,6 +18,7 @@ import type { ValidatingToolCall, WaitingToolCall, CancelledToolCall, + AnsiOutput, } from '@google/gemini-cli-core'; import { CoreToolScheduler } from '@google/gemini-cli-core'; import { useCallback, useState, useMemo, useEffect, useRef } from 'react'; @@ -137,6 +138,41 @@ export function useReactToolScheduler( pid: coreTc.pid, }; } else { + // If the tool was cancelled and has no resultDisplay (generic cancellation), + // try to preserve the liveOutput from the executing state. + // This allows rich displays (like subagent progress) to show their cancelled state. + if ( + coreTc.status === 'cancelled' && + !coreTc.response.resultDisplay && + existingTrackedCall && + 'liveOutput' in existingTrackedCall && + existingTrackedCall.liveOutput + ) { + let resultDisplay = existingTrackedCall.liveOutput; + // If it's a subagent progress, ensure the state reflects cancellation + if ( + typeof resultDisplay === 'object' && + resultDisplay !== null && + 'isSubagentProgress' in resultDisplay && + (resultDisplay as { isSubagentProgress: boolean }) + .isSubagentProgress + ) { + resultDisplay = { + ...resultDisplay, + state: 'cancelled', + } as unknown as AnsiOutput; // Cast to satisfy type system + } + + return { + ...coreTc, + responseSubmittedToGemini, + response: { + ...coreTc.response, + resultDisplay, + }, + }; + } + return { ...coreTc, responseSubmittedToGemini, diff --git a/packages/core/src/agents/local-invocation.test.ts b/packages/core/src/agents/local-invocation.test.ts index 05f8921d90..66419f1b35 100644 --- a/packages/core/src/agents/local-invocation.test.ts +++ b/packages/core/src/agents/local-invocation.test.ts @@ -337,5 +337,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 e0f442f39c..20f9e84983 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 844a9ede8d..ca915deeb2 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -71,6 +71,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 7920171676..2236a55d4c 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, @@ -119,6 +120,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); @@ -154,6 +156,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; @@ -178,7 +181,7 @@ export class ToolExecutor { }, }, ], - resultDisplay: undefined, + resultDisplay, error: undefined, errorType: undefined, contentLength: errorMessage.length,