Subagent activity UX. (#17570)

This commit is contained in:
Christian Gunderman
2026-03-02 21:04:31 +00:00
committed by GitHub
parent ce5a2d0760
commit 7ca3a33f8b
25 changed files with 827 additions and 88 deletions
@@ -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<ToolResult> {
let browserManager;
@@ -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'),
},
}),
}),
]),
);
+31 -2
View File
@@ -269,13 +269,22 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
};
}
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<TOutput extends z.ZodTypeAny> {
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<TOutput extends z.ZodTypeAny> {
let submittedOutput: string | null = null;
let taskCompleted = false;
let aborted = false;
// We'll separate complete_task from other tools
const toolRequests: ToolCallRequestInfo[] = [];
@@ -878,8 +889,24 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
// 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,
});
@@ -1077,8 +1104,9 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
this.emitActivity('ERROR', {
context: 'tool_call',
name: toolName,
error: 'Tool call was cancelled.',
error: 'Request cancelled.',
});
aborted = true;
}
// Add result to syncResults to preserve order later
@@ -1111,6 +1139,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
nextMessage: { role: 'user', parts: toolResponseParts },
submittedOutput,
taskCompleted,
aborted,
};
}
@@ -4,17 +4,25 @@
* 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,
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 +37,7 @@ let mockConfig: Config;
const testDefinition: LocalAgentDefinition<z.ZodUnknown> = {
kind: 'local',
name: 'MockAgent',
displayName: 'Mock Agent',
description: 'A mock agent.',
inputConfig: {
inputSchema: {
@@ -70,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(
@@ -173,7 +186,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 +229,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 +248,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 +262,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 +300,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,19 +322,21 @@ 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');
abortError.name = 'AbortError';
mockExecutorInstance.run.mockRejectedValue(abortError);
const controller = new AbortController();
@@ -312,14 +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?.message).toBe('Aborted');
expect(result.error?.type).toBe(ToolErrorType.EXECUTION_FAILED);
});
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);
await expect(invocation.execute(signal, updateOutput)).rejects.toThrow(
'Operation cancelled by user',
);
});
});
});
+197 -19
View File
@@ -6,18 +6,25 @@
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,
import {
BaseToolInvocation,
type ToolResult,
type ToolLiveOutput,
} from '../tools/tools.js';
import {
type LocalAgentDefinition,
type AgentInputs,
type SubagentActivityEvent,
type SubagentProgress,
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;
const DESCRIPTION_MAX_LENGTH = 200;
const MAX_RECENT_ACTIVITY = 3;
/**
* Represents a validated, executable instance of a subagent tool.
@@ -81,11 +88,20 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
*/
async execute(
signal: AbortSignal,
updateOutput?: (output: string | AnsiOutput) => void,
updateOutput?: (output: ToolLiveOutput) => void,
): Promise<ToolResult> {
let recentActivity: SubagentActivityItem[] = [];
try {
if (updateOutput) {
updateOutput('Subagent starting...\n');
// Send initial state
const initialProgress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [],
state: 'running',
};
updateOutput(initialProgress);
}
// Create an activity callback to bridge the executor's events to the
@@ -93,11 +109,114 @@ 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.status === 'running'
) {
lastItem.content += text;
} else {
recentActivity.push({
id: randomUUID(),
type: 'thought',
content: text,
status: 'running',
});
}
updated = true;
break;
}
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',
});
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']);
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: isCancellation ? error : `Error: ${error}`,
status: isCancellation ? 'cancelled' : '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
state: 'running',
};
updateOutput(progress);
}
};
@@ -109,6 +228,23 @@ 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);
}
const cancelError = new Error('Operation cancelled by user');
cancelError.name = 'AbortError';
throw cancelError;
}
const resultContent = `Subagent '${this.definition.name}' finished.
Termination Reason: ${output.terminate_reason}
Result:
@@ -131,13 +267,55 @@ ${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 = isAbort ? 'cancelled' : 'error';
}
}
// Ensure the error is reflected in the recent activity for display
// 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({
id: randomUUID(),
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],
state: isAbort ? 'cancelled' : 'error',
};
if (updateOutput) {
updateOutput(progress);
}
if (isAbort) {
throw error;
}
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.
};
}
}
@@ -120,6 +120,16 @@ describe('SubAgentInvocation', () => {
);
});
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(
"Delegating to agent 'LocalAgent'",
);
});
it('should delegate shouldConfirmExecute to the inner sub-invocation (remote)', async () => {
const tool = new SubagentTool(
testRemoteDefinition,
+2 -2
View File
@@ -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';
@@ -155,7 +155,7 @@ class SubAgentInvocation extends BaseToolInvocation<AgentInputs, ToolResult> {
async execute(
signal: AbortSignal,
updateOutput?: (output: string | AnsiOutput) => void,
updateOutput?: (output: ToolLiveOutput) => void,
): Promise<ToolResult> {
const validationError = SchemaValidator.validate(
this.definition.inputConfig.inputSchema,
+26
View File
@@ -71,6 +71,32 @@ export interface SubagentActivityEvent {
data: Record<string, unknown>;
}
export interface SubagentActivityItem {
id: string;
type: 'thought' | 'tool_call';
content: string;
displayName?: string;
description?: string;
args?: string;
status: 'running' | 'completed' | 'error' | 'cancelled';
}
export interface SubagentProgress {
isSubagentProgress: true;
agentName: string;
recentActivity: SubagentActivityItem[];
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.
@@ -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,
+13 -2
View File
@@ -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';
@@ -935,6 +940,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.',
+13 -5
View File
@@ -9,7 +9,8 @@ import type {
ToolCallResponseInfo,
ToolResult,
Config,
AnsiOutput,
ToolResultDisplay,
ToolLiveOutput,
} from '../index.js';
import {
ToolErrorType,
@@ -45,7 +46,7 @@ import {
export interface ToolExecutionContext {
call: ToolCall;
signal: AbortSignal;
outputUpdateHandler?: (callId: string, output: string | AnsiOutput) => void;
outputUpdateHandler?: (callId: string, output: ToolLiveOutput) => void;
onUpdateToolCall: (updatedCall: ToolCall) => void;
}
@@ -68,7 +69,7 @@ export class ToolExecutor {
// Setup live output handling
const liveOutputCallback =
tool.canUpdateOutput && outputUpdateHandler
? (outputChunk: string | AnsiOutput) => {
? (outputChunk: ToolLiveOutput) => {
outputUpdateHandler(callId, outputChunk);
}
: undefined;
@@ -134,6 +135,7 @@ export class ToolExecutor {
completedToolCall = this.createCancelledResult(
call,
'User cancelled tool execution.',
toolResult.returnDisplay,
);
} else if (toolResult.error === undefined) {
completedToolCall = await this.createSuccessResult(
@@ -155,7 +157,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) {
completedToolCall = this.createCancelledResult(
call,
'User cancelled tool execution.',
@@ -182,6 +189,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;
@@ -206,7 +214,7 @@ export class ToolExecutor {
},
},
],
resultDisplay: undefined,
resultDisplay,
error: undefined,
errorType: undefined,
contentLength: errorMessage.length,
+3 -3
View File
@@ -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 = (
+2 -1
View File
@@ -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<ToolResult> {
+12 -4
View File
@@ -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.
@@ -64,7 +65,7 @@ export interface ToolInvocation<
*/
execute(
signal: AbortSignal,
updateOutput?: (output: string | AnsiOutput) => void,
updateOutput?: (output: ToolLiveOutput) => void,
shellExecutionConfig?: ShellExecutionConfig,
): Promise<TResult>;
}
@@ -276,7 +277,7 @@ export abstract class BaseToolInvocation<
abstract execute(
signal: AbortSignal,
updateOutput?: (output: string | AnsiOutput) => void,
updateOutput?: (output: ToolLiveOutput) => void,
shellExecutionConfig?: ShellExecutionConfig,
): Promise<TResult>;
}
@@ -422,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<TResult> {
const invocation = this.build(params);
@@ -688,7 +689,14 @@ export interface TodoList {
todos: Todo[];
}
export type ToolResultDisplay = string | FileDiff | AnsiOutput | TodoList;
export type ToolLiveOutput = string | AnsiOutput | SubagentProgress;
export type ToolResultDisplay =
| string
| FileDiff
| AnsiOutput
| TodoList
| SubagentProgress;
export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
@@ -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', () => {
+12 -4
View File
@@ -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: