This commit is contained in:
Christian Gunderman
2026-01-26 12:02:53 -08:00
parent 0e4804731a
commit ebd3bbb9d2
7 changed files with 151 additions and 20 deletions

View File

@@ -128,4 +128,19 @@ describe('<SubagentProgressDisplay />', () => {
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(
<SubagentProgressDisplay progress={progress} />,
);
const frame = lastFrame();
expect(frame).toContain('Subagent TestAgent was cancelled.');
});
});

View File

@@ -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 (
<Box flexDirection="column" paddingY={0}>
<Box marginBottom={1}>
<Text color={theme.text.secondary} italic>
Subagent {progress.agentName} is working...
<Text color={headerColor} italic>
{headerText}
</Text>
</Box>
<Box flexDirection="column" marginLeft={0} gap={0}>
@@ -104,3 +119,4 @@ export const SubagentProgressDisplay: React.FC<
</Box>
</Box>
);
};

View File

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

View File

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

View File

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

View File

@@ -71,6 +71,7 @@ export interface SubagentProgress {
isSubagentProgress: true;
agentName: string;
recentActivity: SubagentActivityItem[];
state?: 'running' | 'completed' | 'error' | 'cancelled';
}
/**

View File

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