From 086337784ababf177b62680c805890b0df65e9d2 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Tue, 12 May 2026 13:44:28 -0400 Subject: [PATCH 01/16] refactor(core): introduce SubagentState enum for progress --- .../core/src/agents/local-invocation.test.ts | 17 +++---- packages/core/src/agents/local-invocation.ts | 44 +++++++++++-------- .../core/src/agents/remote-invocation.test.ts | 36 ++++++++++----- packages/core/src/agents/remote-invocation.ts | 11 ++--- .../src/agents/remote-subagent-protocol.ts | 5 ++- packages/core/src/agents/types.ts | 11 ++++- 6 files changed, 76 insertions(+), 48 deletions(-) diff --git a/packages/core/src/agents/local-invocation.test.ts b/packages/core/src/agents/local-invocation.test.ts index eaea2b9ffad..297b46592eb 100644 --- a/packages/core/src/agents/local-invocation.test.ts +++ b/packages/core/src/agents/local-invocation.test.ts @@ -21,6 +21,7 @@ import { type SubagentProgress, SubagentActivityErrorType, SUBAGENT_REJECTED_ERROR_PREFIX, + SubagentState, } from './types.js'; import { LocalSubagentInvocation } from './local-invocation.js'; import { LocalAgentExecutor } from './local-executor.js'; @@ -215,7 +216,7 @@ describe('LocalSubagentInvocation', () => { ]); const display = result.returnDisplay as SubagentProgress; expect(display.isSubagentProgress).toBe(true); - expect(display.state).toBe('completed'); + expect(display.state).toBe(SubagentState.COMPLETED); expect(display.result).toBe('Analysis complete.'); expect(display.terminateReason).toBe(AgentTerminateMode.GOAL); }); @@ -234,7 +235,7 @@ describe('LocalSubagentInvocation', () => { const display = result.returnDisplay as SubagentProgress; expect(display.isSubagentProgress).toBe(true); - expect(display.state).toBe('completed'); + expect(display.state).toBe(SubagentState.COMPLETED); expect(display.result).toBe('Partial progress...'); expect(display.terminateReason).toBe(AgentTerminateMode.TIMEOUT); }); @@ -340,7 +341,7 @@ describe('LocalSubagentInvocation', () => { expect.objectContaining({ type: 'thought', content: 'Error: Failed', - status: 'error', + status: SubagentState.ERROR, }), ); }); @@ -376,7 +377,7 @@ describe('LocalSubagentInvocation', () => { expect.objectContaining({ type: 'tool_call', content: 'ls', - status: 'error', + status: SubagentState.ERROR, }), ); }); @@ -418,7 +419,7 @@ describe('LocalSubagentInvocation', () => { expect.objectContaining({ type: 'tool_call', content: 'ls', - status: 'cancelled', + status: SubagentState.CANCELLED, }), ); }); @@ -443,7 +444,7 @@ describe('LocalSubagentInvocation', () => { expect(result.error).toBeUndefined(); const display = result.returnDisplay as SubagentProgress; expect(display.isSubagentProgress).toBe(true); - expect(display.state).toBe('completed'); + expect(display.state).toBe(SubagentState.COMPLETED); expect(display.result).toBe('Done'); }); @@ -466,7 +467,7 @@ describe('LocalSubagentInvocation', () => { expect.objectContaining({ type: 'thought', content: `Error: ${error.message}`, - status: 'error', + status: SubagentState.ERROR, }), ); }); @@ -488,7 +489,7 @@ describe('LocalSubagentInvocation', () => { expect(display.recentActivity).toContainEqual( expect.objectContaining({ content: `Error: ${creationError.message}`, - status: 'error', + status: SubagentState.ERROR, }), ); }); diff --git a/packages/core/src/agents/local-invocation.ts b/packages/core/src/agents/local-invocation.ts index 186f015979f..f4d3153d79c 100644 --- a/packages/core/src/agents/local-invocation.ts +++ b/packages/core/src/agents/local-invocation.ts @@ -23,6 +23,7 @@ import { SUBAGENT_REJECTED_ERROR_PREFIX, SUBAGENT_CANCELLED_ERROR_MESSAGE, isToolActivityError, + SubagentState, } from './types.js'; import { randomUUID } from 'node:crypto'; import type { z } from 'zod'; @@ -117,7 +118,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation< isSubagentProgress: true, agentName: this.definition.name, recentActivity: [], - state: 'running', + state: SubagentState.RUNNING, }; updateOutput(initialProgress); } @@ -137,7 +138,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation< if ( lastItem && lastItem.type === 'thought' && - lastItem.status === 'running' + lastItem.status === SubagentState.RUNNING ) { lastItem.content = sanitizeThoughtContent(text); } else { @@ -145,7 +146,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation< id: randomUUID(), type: 'thought', content: sanitizeThoughtContent(text), - status: 'running', + status: SubagentState.RUNNING, }); } updated = true; @@ -174,7 +175,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation< displayName, description, args, - status: 'running', + status: SubagentState.RUNNING, }); updated = true; @@ -193,9 +194,11 @@ export class LocalSubagentInvocation extends BaseToolInvocation< if ( recentActivity[i].type === 'tool_call' && recentActivity[i].content === name && - recentActivity[i].status === 'running' + recentActivity[i].status === SubagentState.RUNNING ) { - recentActivity[i].status = isError ? 'error' : 'completed'; + recentActivity[i].status = isError + ? SubagentState.ERROR + : SubagentState.COMPLETED; updated = true; this.publishActivity(recentActivity[i]); @@ -224,9 +227,9 @@ export class LocalSubagentInvocation extends BaseToolInvocation< if ( recentActivity[i].type === 'tool_call' && recentActivity[i].content === toolName && - recentActivity[i].status === 'running' + recentActivity[i].status === SubagentState.RUNNING ) { - recentActivity[i].status = 'cancelled'; + recentActivity[i].status = SubagentState.CANCELLED; updated = true; break; } @@ -237,9 +240,9 @@ export class LocalSubagentInvocation extends BaseToolInvocation< if ( recentActivity[i].type === 'tool_call' && recentActivity[i].content === toolName && - recentActivity[i].status === 'running' + recentActivity[i].status === SubagentState.RUNNING ) { - recentActivity[i].status = 'error'; + recentActivity[i].status = SubagentState.ERROR; updated = true; break; } @@ -253,7 +256,10 @@ export class LocalSubagentInvocation extends BaseToolInvocation< isCancellation || isRejection ? sanitizedError : `Error: ${sanitizedError}`, - status: isCancellation || isRejection ? 'cancelled' : 'error', + status: + isCancellation || isRejection + ? SubagentState.CANCELLED + : SubagentState.ERROR, }); updated = true; break; @@ -267,7 +273,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation< isSubagentProgress: true, agentName: this.definition.name, recentActivity: [...recentActivity], // Copy to avoid mutation issues - state: 'running', + state: SubagentState.RUNNING, }; updateOutput(progress); @@ -287,7 +293,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation< isSubagentProgress: true, agentName: this.definition.name, recentActivity: [...recentActivity], - state: 'cancelled', + state: SubagentState.CANCELLED, }; if (updateOutput) { @@ -303,7 +309,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation< isSubagentProgress: true, agentName: this.definition.name, recentActivity: [...recentActivity], - state: 'completed', + state: SubagentState.COMPLETED, result: output.result, terminateReason: output.terminate_reason, }; @@ -334,8 +340,8 @@ ${output.result}`; // Mark any running items as error/cancelled for (const item of recentActivity) { - if (item.status === 'running') { - item.status = isAbort ? 'cancelled' : 'error'; + if (item.status === SubagentState.RUNNING) { + item.status = isAbort ? SubagentState.CANCELLED : SubagentState.ERROR; } } @@ -343,12 +349,12 @@ ${output.result}`; // 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') { + if (!lastActivity || lastActivity.status !== SubagentState.ERROR) { recentActivity.push({ id: randomUUID(), type: 'thought', content: `Error: ${errorMessage}`, - status: 'error', + status: SubagentState.ERROR, }); // Maintain size limit // No limit on UI events sent via bus @@ -359,7 +365,7 @@ ${output.result}`; isSubagentProgress: true, agentName: this.definition.name, recentActivity: [...recentActivity], - state: isAbort ? 'cancelled' : 'error', + state: isAbort ? SubagentState.CANCELLED : SubagentState.ERROR, }; if (updateOutput) { diff --git a/packages/core/src/agents/remote-invocation.test.ts b/packages/core/src/agents/remote-invocation.test.ts index 0ec77741924..8b0afa88318 100644 --- a/packages/core/src/agents/remote-invocation.test.ts +++ b/packages/core/src/agents/remote-invocation.test.ts @@ -20,7 +20,11 @@ import { type A2AClientManager, } from './a2a-client-manager.js'; -import type { RemoteAgentDefinition, SubagentProgress } from './types.js'; +import { + type RemoteAgentDefinition, + type SubagentProgress, + SubagentState, +} from './types.js'; import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; import { A2AAuthProviderFactory } from './auth-provider/factory.js'; import type { A2AAuthProvider } from './auth-provider/types.js'; @@ -268,7 +272,9 @@ describe('RemoteAgentInvocation', () => { abortSignal: new AbortController().signal, }); - expect(result.returnDisplay).toMatchObject({ state: 'error' }); + expect(result.returnDisplay).toMatchObject({ + state: SubagentState.ERROR, + }); expect((result.returnDisplay as SubagentProgress).result).toContain( "Failed to create auth provider for agent 'test-agent'", ); @@ -461,7 +467,7 @@ describe('RemoteAgentInvocation', () => { expect(updateOutput).toHaveBeenCalledWith( expect.objectContaining({ isSubagentProgress: true, - state: 'running', + state: SubagentState.RUNNING, recentActivity: expect.arrayContaining([ expect.objectContaining({ content: 'Working...' }), ]), @@ -470,7 +476,7 @@ describe('RemoteAgentInvocation', () => { expect(updateOutput).toHaveBeenCalledWith( expect.objectContaining({ isSubagentProgress: true, - state: 'completed', + state: SubagentState.COMPLETED, result: 'HelloHello World', }), ); @@ -508,7 +514,9 @@ describe('RemoteAgentInvocation', () => { abortSignal: controller.signal, }); - expect(result.returnDisplay).toMatchObject({ state: 'error' }); + expect(result.returnDisplay).toMatchObject({ + state: SubagentState.ERROR, + }); }); it('should handle errors gracefully', async () => { @@ -533,7 +541,7 @@ describe('RemoteAgentInvocation', () => { }); expect(result.returnDisplay).toMatchObject({ - state: 'error', + state: SubagentState.ERROR, result: expect.stringContaining('Network error'), }); }); @@ -616,7 +624,7 @@ describe('RemoteAgentInvocation', () => { expect(updateOutput).toHaveBeenCalledWith( expect.objectContaining({ isSubagentProgress: true, - state: 'running', + state: SubagentState.RUNNING, recentActivity: expect.arrayContaining([ expect.objectContaining({ content: 'Working...' }), ]), @@ -625,7 +633,7 @@ describe('RemoteAgentInvocation', () => { expect(updateOutput).toHaveBeenCalledWith( expect.objectContaining({ isSubagentProgress: true, - state: 'completed', + state: SubagentState.COMPLETED, result: 'Thinking...Final Answer', }), ); @@ -693,7 +701,7 @@ describe('RemoteAgentInvocation', () => { expect(updateOutput).toHaveBeenCalledWith( expect.objectContaining({ isSubagentProgress: true, - state: 'running', + state: SubagentState.RUNNING, recentActivity: expect.arrayContaining([ expect.objectContaining({ content: 'Working...' }), ]), @@ -702,7 +710,7 @@ describe('RemoteAgentInvocation', () => { expect(updateOutput).toHaveBeenCalledWith( expect.objectContaining({ isSubagentProgress: true, - state: 'completed', + state: SubagentState.COMPLETED, result: 'Generating...\n\nArtifact (Result):\nPart 1 Part 2', }), ); @@ -760,7 +768,9 @@ describe('RemoteAgentInvocation', () => { abortSignal: new AbortController().signal, }); - expect(result.returnDisplay).toMatchObject({ state: 'error' }); + expect(result.returnDisplay).toMatchObject({ + state: SubagentState.ERROR, + }); expect((result.returnDisplay as SubagentProgress).result).toContain( a2aError.userMessage, ); @@ -782,7 +792,9 @@ describe('RemoteAgentInvocation', () => { abortSignal: new AbortController().signal, }); - expect(result.returnDisplay).toMatchObject({ state: 'error' }); + expect(result.returnDisplay).toMatchObject({ + state: SubagentState.ERROR, + }); expect((result.returnDisplay as SubagentProgress).result).toContain( 'Error calling remote agent: something unexpected', ); diff --git a/packages/core/src/agents/remote-invocation.ts b/packages/core/src/agents/remote-invocation.ts index e0869603fe3..1510849683b 100644 --- a/packages/core/src/agents/remote-invocation.ts +++ b/packages/core/src/agents/remote-invocation.ts @@ -17,6 +17,7 @@ import { type RemoteAgentDefinition, type AgentInputs, type SubagentProgress, + SubagentState, getAgentCardLoadOptions, getRemoteAgentTargetUrl, } from './types.js'; @@ -138,13 +139,13 @@ export class RemoteAgentInvocation extends BaseToolInvocation< updateOutput({ isSubagentProgress: true, agentName, - state: 'running', + state: SubagentState.RUNNING, recentActivity: [ { id: 'pending', type: 'thought', content: 'Working...', - status: 'running', + status: SubagentState.RUNNING, }, ], }); @@ -193,7 +194,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation< updateOutput({ isSubagentProgress: true, agentName, - state: 'running', + state: SubagentState.RUNNING, recentActivity: reassembler.toActivityItems(), result: reassembler.toString(), }); @@ -225,7 +226,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation< const finalProgress: SubagentProgress = { isSubagentProgress: true, agentName, - state: 'completed', + state: SubagentState.COMPLETED, result: finalOutput, recentActivity: reassembler.toActivityItems(), }; @@ -249,7 +250,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation< const errorProgress: SubagentProgress = { isSubagentProgress: true, agentName, - state: 'error', + state: SubagentState.ERROR, result: fullDisplay, recentActivity: reassembler.toActivityItems(), }; diff --git a/packages/core/src/agents/remote-subagent-protocol.ts b/packages/core/src/agents/remote-subagent-protocol.ts index 4179e5587b4..1231b0f068d 100644 --- a/packages/core/src/agents/remote-subagent-protocol.ts +++ b/packages/core/src/agents/remote-subagent-protocol.ts @@ -28,6 +28,7 @@ import { DEFAULT_QUERY_STRING, type RemoteAgentDefinition, type SubagentProgress, + SubagentState, getRemoteAgentTargetUrl, getAgentCardLoadOptions, } from './types.js'; @@ -233,7 +234,7 @@ class RemoteSubagentProtocol implements AgentProtocol { this._latestProgress = { isSubagentProgress: true, agentName: this._agentName, - state: 'running', + state: SubagentState.RUNNING, recentActivity: reassembler.toActivityItems(), result: currentText, }; @@ -259,7 +260,7 @@ class RemoteSubagentProtocol implements AgentProtocol { const finalProgress: SubagentProgress = { isSubagentProgress: true, agentName: this._agentName, - state: 'completed', + state: SubagentState.COMPLETED, result: finalOutput, recentActivity: reassembler.toActivityItems(), }; diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index bfca8b81d64..c9ef514890a 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -88,6 +88,13 @@ export interface SubagentActivityEvent { data: Record; } +export enum SubagentState { + RUNNING = 'running', + COMPLETED = 'completed', + ERROR = 'error', + CANCELLED = 'cancelled', +} + export interface SubagentActivityItem { id: string; type: 'thought' | 'tool_call'; @@ -95,14 +102,14 @@ export interface SubagentActivityItem { displayName?: string; description?: string; args?: string; - status: 'running' | 'completed' | 'error' | 'cancelled'; + status: SubagentState; } export interface SubagentProgress { isSubagentProgress: true; agentName: string; recentActivity: SubagentActivityItem[]; - state?: 'running' | 'completed' | 'error' | 'cancelled'; + state?: SubagentState; result?: string; terminateReason?: AgentTerminateMode; } From 435c483b3a7e4d182347ccae5e99fb42529f3ded Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Tue, 12 May 2026 13:45:17 -0400 Subject: [PATCH 02/16] refactor(core): update remaining files to use SubagentState enum --- packages/core/src/agents/a2aUtils.ts | 8 ++-- .../agents/browser/browserAgentInvocation.ts | 46 +++++++++++-------- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/packages/core/src/agents/a2aUtils.ts b/packages/core/src/agents/a2aUtils.ts index 2d146fc4204..876f6239114 100644 --- a/packages/core/src/agents/a2aUtils.ts +++ b/packages/core/src/agents/a2aUtils.ts @@ -16,7 +16,7 @@ import type { AgentInterface, } from '@a2a-js/sdk'; import type { SendMessageResult } from './a2a-client-manager.js'; -import type { SubagentActivityItem } from './types.js'; +import { type SubagentActivityItem, SubagentState } from './types.js'; export const AUTH_REQUIRED_MSG = `[Authorization Required] The agent has indicated it requires authorization to proceed. Please follow the agent's instructions.`; @@ -143,7 +143,7 @@ export class A2AResultReassembler { id: 'auth-required', type: 'thought', content: AUTH_REQUIRED_MSG, - status: 'running', + status: SubagentState.RUNNING, }); } @@ -152,7 +152,7 @@ export class A2AResultReassembler { id: `msg-${index}`, type: 'thought', content: msg.trim(), - status: 'completed', + status: SubagentState.COMPLETED, }); }); @@ -161,7 +161,7 @@ export class A2AResultReassembler { id: 'pending', type: 'thought', content: 'Working...', - status: 'running', + status: SubagentState.RUNNING, }); } diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index a59ffc25b56..a27a8d29edf 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -32,6 +32,7 @@ import { type SubagentActivityItem, AgentTerminateMode, isToolActivityError, + SubagentState, } from '../types.js'; import type { MessageBus } from '../../confirmation-bus/message-bus.js'; import { createBrowserAgentDefinition } from './browserAgentFactory.js'; @@ -123,7 +124,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation< isSubagentProgress: true, agentName: this.agentName, recentActivity: [], - state: 'running', + state: SubagentState.RUNNING, }; updateOutput(initialProgress); } @@ -137,7 +138,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation< id: randomUUID(), type: 'thought', content: sanitizedMsg, - status: 'completed', + status: SubagentState.COMPLETED, }); if (recentActivity.length > MAX_RECENT_ACTIVITY) { recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY); @@ -146,7 +147,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation< isSubagentProgress: true, agentName: this.agentName, recentActivity: [...recentActivity], - state: 'running', + state: SubagentState.RUNNING, } as SubagentProgress); } : undefined; @@ -175,7 +176,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation< if ( lastItem && lastItem.type === 'thought' && - lastItem.status === 'running' + lastItem.status === SubagentState.RUNNING ) { lastItem.content = sanitizeThoughtContent(text); } else { @@ -183,7 +184,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation< id: randomUUID(), type: 'thought', content: sanitizeThoughtContent(text), - status: 'running', + status: SubagentState.RUNNING, }); } updated = true; @@ -210,7 +211,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation< displayName, description, args, - status: 'running', + status: SubagentState.RUNNING, }); updated = true; break; @@ -227,9 +228,11 @@ export class BrowserAgentInvocation extends BaseToolInvocation< recentActivity[i].type === 'tool_call' && callId != null && recentActivity[i].id === callId && - recentActivity[i].status === 'running' + recentActivity[i].status === SubagentState.RUNNING ) { - recentActivity[i].status = isError ? 'error' : 'completed'; + recentActivity[i].status = isError + ? SubagentState.ERROR + : SubagentState.COMPLETED; updated = true; break; } @@ -242,7 +245,9 @@ export class BrowserAgentInvocation extends BaseToolInvocation< const callId = activity.data['callId'] ? String(activity.data['callId']) : undefined; - const newStatus = isCancellation ? 'cancelled' : 'error'; + const newStatus = isCancellation + ? SubagentState.CANCELLED + : SubagentState.ERROR; if (callId) { // Mark the specific tool as error/cancelled @@ -250,7 +255,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation< if ( recentActivity[i].type === 'tool_call' && recentActivity[i].id === callId && - recentActivity[i].status === 'running' + recentActivity[i].status === SubagentState.RUNNING ) { recentActivity[i].status = newStatus; updated = true; @@ -260,7 +265,10 @@ export class BrowserAgentInvocation extends BaseToolInvocation< } else { // No specific tool — mark ALL running tool_call items for (const item of recentActivity) { - if (item.type === 'tool_call' && item.status === 'running') { + if ( + item.type === 'tool_call' && + item.status === SubagentState.RUNNING + ) { item.status = newStatus; updated = true; } @@ -293,7 +301,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation< isSubagentProgress: true, agentName: this.agentName, recentActivity: [...recentActivity], - state: 'running', + state: SubagentState.RUNNING, }; updateOutput(progress); } @@ -330,13 +338,13 @@ ${output.result}`; // GOAL = agent completed its task normally. // ABORTED = user cancelled. // Others (ERROR, MAX_TURNS, ERROR_NO_COMPLETE_TASK_CALL) = error. - let progressState: SubagentProgress['state']; + let progressState: SubagentState; if (output.terminate_reason === AgentTerminateMode.ABORTED) { - progressState = 'cancelled'; + progressState = SubagentState.CANCELLED; } else if (output.terminate_reason === AgentTerminateMode.GOAL) { - progressState = 'completed'; + progressState = SubagentState.COMPLETED; } else { - progressState = 'error'; + progressState = SubagentState.ERROR; } const progress: SubagentProgress = { @@ -366,8 +374,8 @@ ${output.result}`; // Mark any running items as error/cancelled for (const item of recentActivity) { - if (item.status === 'running') { - item.status = isAbort ? 'cancelled' : 'error'; + if (item.status === SubagentState.RUNNING) { + item.status = isAbort ? SubagentState.CANCELLED : SubagentState.ERROR; } } @@ -375,7 +383,7 @@ ${output.result}`; isSubagentProgress: true, agentName: this.agentName, recentActivity: [...recentActivity], - state: isAbort ? 'cancelled' : 'error', + state: isAbort ? SubagentState.CANCELLED : SubagentState.ERROR, }; if (updateOutput) { From 206e11e49d983617f621cd1844118941cf5e73d9 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Tue, 12 May 2026 13:50:30 -0400 Subject: [PATCH 03/16] refactor(cli): update UI components and tests to use SubagentState enum --- .../messages/SubagentGroupDisplay.test.tsx | 14 ++++++++----- .../messages/SubagentGroupDisplay.tsx | 21 ++++++++++--------- .../messages/SubagentHistoryMessage.test.tsx | 7 ++++--- .../messages/SubagentProgressDisplay.test.tsx | 20 +++++++++--------- .../ToolGroupMessageRegression.test.tsx | 5 +++-- .../cli/src/ui/hooks/useToolScheduler.test.ts | 13 +++++++----- 6 files changed, 45 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx b/packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx index 484ca8a8ed5..1a3572a82ae 100644 --- a/packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx @@ -6,7 +6,11 @@ import { waitFor } from '../../../test-utils/async.js'; import { renderWithProviders } from '../../../test-utils/render.js'; import { SubagentGroupDisplay } from './SubagentGroupDisplay.js'; -import { Kind, CoreToolCallStatus } from '@google/gemini-cli-core'; +import { + Kind, + CoreToolCallStatus, + SubagentState, +} from '@google/gemini-cli-core'; import type { IndividualToolCallDisplay } from '../../types.js'; import { describe, it, expect, vi } from 'vitest'; import { Text } from 'ink'; @@ -27,12 +31,12 @@ describe('', () => { resultDisplay: { isSubagentProgress: true, agentName: 'api-monitor', - state: 'running', + state: SubagentState.RUNNING, recentActivity: [ { id: 'act-1', type: 'tool_call', - status: 'running', + status: SubagentState.RUNNING, content: '', displayName: 'Action Required', description: 'Verify server is running', @@ -50,13 +54,13 @@ describe('', () => { resultDisplay: { isSubagentProgress: true, agentName: 'db-manager', - state: 'completed', + state: SubagentState.COMPLETED, result: 'Database schema validated', recentActivity: [ { id: 'act-2', type: 'thought', - status: 'completed', + status: SubagentState.COMPLETED, content: 'Database schema validated', }, ], diff --git a/packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx b/packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx index b57160966b0..de170013dec 100644 --- a/packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx +++ b/packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx @@ -13,6 +13,7 @@ import { isSubagentProgress, checkExhaustive, type SubagentActivityItem, + SubagentState, } from '@google/gemini-cli-core'; import { SubagentProgressDisplay, @@ -66,13 +67,13 @@ export const SubagentGroupDisplay: React.FC = ({ const singleAgent = toolCalls[0].resultDisplay; if (isSubagentProgress(singleAgent)) { switch (singleAgent.state) { - case 'completed': + case SubagentState.COMPLETED: headerText = 'Agent Completed'; break; - case 'cancelled': + case SubagentState.CANCELLED: headerText = 'Agent Cancelled'; break; - case 'error': + case SubagentState.ERROR: headerText = 'Agent Error'; break; default: @@ -88,8 +89,8 @@ export const SubagentGroupDisplay: React.FC = ({ for (const tc of toolCalls) { const progress = tc.resultDisplay; if (isSubagentProgress(progress)) { - if (progress.state === 'completed') completedCount++; - else if (progress.state === 'running') runningCount++; + if (progress.state === SubagentState.COMPLETED) completedCount++; + else if (progress.state === SubagentState.RUNNING) runningCount++; } else { // It hasn't emitted progress yet, but it is "running" runningCount++; @@ -226,15 +227,15 @@ export const SubagentGroupDisplay: React.FC = ({ progress.state === 'completed' ? '' : formattedArgs; const renderStatusIcon = () => { - const state = progress.state ?? 'running'; + const state = progress.state ?? SubagentState.RUNNING; switch (state) { - case 'running': + case SubagentState.RUNNING: return !; - case 'completed': + case SubagentState.COMPLETED: return ; - case 'cancelled': + case SubagentState.CANCELLED: return ; - case 'error': + case SubagentState.ERROR: return ; default: return checkExhaustive(state); diff --git a/packages/cli/src/ui/components/messages/SubagentHistoryMessage.test.tsx b/packages/cli/src/ui/components/messages/SubagentHistoryMessage.test.tsx index 20a86cb5a90..9db757b240d 100644 --- a/packages/cli/src/ui/components/messages/SubagentHistoryMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/SubagentHistoryMessage.test.tsx @@ -8,6 +8,7 @@ import { describe, it, expect } from 'vitest'; import { renderWithProviders } from '../../../test-utils/render.js'; import { SubagentHistoryMessage } from './SubagentHistoryMessage.js'; import type { HistoryItemSubagent } from '../../types.js'; +import { SubagentState } from '@google/gemini-cli-core'; describe('SubagentHistoryMessage', () => { const mockItem: HistoryItemSubagent = { @@ -18,19 +19,19 @@ describe('SubagentHistoryMessage', () => { id: '1', type: 'thought', content: 'Thinking about the problem', - status: 'completed', + status: SubagentState.COMPLETED, }, { id: '2', type: 'tool_call', content: 'Calling search_web', - status: 'running', + status: SubagentState.RUNNING, }, { id: '3', type: 'tool_call', content: 'Calling read_file fail', - status: 'error', + status: SubagentState.ERROR, }, ], }; diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx index fcafa4ed281..d1f2d70f0e3 100644 --- a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx @@ -6,7 +6,7 @@ import { render, cleanup } from '../../../test-utils/render.js'; import { SubagentProgressDisplay } from './SubagentProgressDisplay.js'; -import type { SubagentProgress } from '@google/gemini-cli-core'; +import { type SubagentProgress, SubagentState } from '@google/gemini-cli-core'; import { describe, it, expect, vi, afterEach } from 'vitest'; describe('', () => { @@ -25,7 +25,7 @@ describe('', () => { type: 'tool_call', content: 'run_shell_command', args: '{"command": "echo hello", "description": "Say hello"}', - status: 'running', + status: SubagentState.RUNNING, }, ], }; @@ -48,7 +48,7 @@ describe('', () => { displayName: 'RunShellCommand', description: 'Executing echo hello', args: '{"command": "echo hello"}', - status: 'running', + status: SubagentState.RUNNING, }, ], }; @@ -69,7 +69,7 @@ describe('', () => { type: 'tool_call', content: 'run_shell_command', args: '{"command": "echo hello"}', - status: 'running', + status: SubagentState.RUNNING, }, ], }; @@ -90,7 +90,7 @@ describe('', () => { type: 'tool_call', content: 'write_file', args: '{"file_path": "/tmp/test.txt", "content": "foo"}', - status: 'completed', + status: SubagentState.COMPLETED, }, ], }; @@ -113,7 +113,7 @@ describe('', () => { type: 'tool_call', content: 'run_shell_command', args: JSON.stringify({ description: longDesc }), - status: 'running', + status: SubagentState.RUNNING, }, ], }; @@ -133,7 +133,7 @@ describe('', () => { id: '5', type: 'thought', content: 'Thinking about life', - status: 'running', + status: SubagentState.RUNNING, }, ], }; @@ -149,7 +149,7 @@ describe('', () => { isSubagentProgress: true, agentName: 'TestAgent', recentActivity: [], - state: 'cancelled', + state: SubagentState.CANCELLED, }; const { lastFrame } = await render( @@ -167,7 +167,7 @@ describe('', () => { id: '6', type: 'thought', content: 'Request cancelled.', - status: 'error', + status: SubagentState.ERROR, }, ], }; @@ -188,7 +188,7 @@ describe('', () => { type: 'tool_call', content: 'run_shell_command', args: '{"command": "echo hello"}', - status: 'error', + status: SubagentState.ERROR, }, ], }; diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessageRegression.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessageRegression.test.tsx index 96239fb720b..5206145c9ee 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessageRegression.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessageRegression.test.tsx @@ -13,6 +13,7 @@ import { ApprovalMode, WRITE_FILE_DISPLAY_NAME, Kind, + SubagentState, } from '@google/gemini-cli-core'; import os from 'node:os'; import { createMockSettings } from '../../../test-utils/settings.js'; @@ -76,7 +77,7 @@ describe('ToolGroupMessage Regression Tests', () => { resultDisplay: { isSubagentProgress: true, agentName: 'TestAgent', - state: 'running', + state: SubagentState.RUNNING, recentActivity: [], }, }), @@ -112,7 +113,7 @@ describe('ToolGroupMessage Regression Tests', () => { resultDisplay: { isSubagentProgress: true, agentName: 'TestAgent', - state: 'completed', + state: SubagentState.COMPLETED, recentActivity: [], }, }), diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index efb9b8a6fd9..e9665ec63bf 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -21,6 +21,7 @@ import { ROOT_SCHEDULER_ID, CoreToolCallStatus, type WaitingToolCall, + SubagentState, } from '@google/gemini-cli-core'; import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js'; @@ -630,7 +631,7 @@ describe('useToolScheduler', () => { id: '1', type: 'thought', content: 'Thinking...', - status: 'running', + status: SubagentState.RUNNING, }, }); }); @@ -648,7 +649,7 @@ describe('useToolScheduler', () => { id: '2', type: 'tool_call', content: 'Calling tool', - status: 'completed', + status: SubagentState.COMPLETED, }, }); }); @@ -697,7 +698,7 @@ describe('useToolScheduler', () => { id: '1', type: 'thought', content: 'Thinking...', - status: 'running', + status: SubagentState.RUNNING, }, }); }); @@ -716,7 +717,7 @@ describe('useToolScheduler', () => { id: '1', type: 'thought', content: 'Thinking... Done!', - status: 'completed', + status: SubagentState.COMPLETED, }, }); }); @@ -726,6 +727,8 @@ describe('useToolScheduler', () => { expect(result.current[0][0].subagentHistory![0].content).toBe( 'Thinking... Done!', ); - expect(result.current[0][0].subagentHistory![0].status).toBe('completed'); + expect(result.current[0][0].subagentHistory![0].status).toBe( + SubagentState.COMPLETED, + ); }); }); From 9c026f3408ff96cb395b16d742c1f713011487d8 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Tue, 12 May 2026 14:28:33 -0400 Subject: [PATCH 04/16] refactor(cli): complete SubagentState enum migration in SubagentGroupDisplay --- .../cli/src/ui/components/messages/SubagentGroupDisplay.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx b/packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx index de170013dec..02ff8d461bc 100644 --- a/packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx +++ b/packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx @@ -201,7 +201,7 @@ export const SubagentGroupDisplay: React.FC = ({ let content = 'Starting...'; let formattedArgs: string | undefined; - if (progress.state === 'completed') { + if (progress.state === SubagentState.COMPLETED) { if ( progress.terminateReason && progress.terminateReason !== 'GOAL' @@ -224,7 +224,7 @@ export const SubagentGroupDisplay: React.FC = ({ } const displayArgs = - progress.state === 'completed' ? '' : formattedArgs; + progress.state === SubagentState.COMPLETED ? '' : formattedArgs; const renderStatusIcon = () => { const state = progress.state ?? SubagentState.RUNNING; From 405b02e4b0026b65b80ae04d6e815979c8cd453c Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Tue, 12 May 2026 14:45:11 -0400 Subject: [PATCH 05/16] refactor(core,cli): fix missed enum usages in SubagentProgressDisplay and tests --- .../messages/SubagentProgressDisplay.tsx | 27 ++++++++++--------- .../core/src/agents/remote-invocation.test.ts | 4 ++- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx index 995c404d9d1..b46756c5d3f 100644 --- a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx @@ -9,9 +9,10 @@ import { Box, Text } from 'ink'; import { theme } from '../../semantic-colors.js'; import Spinner from 'ink-spinner'; import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; -import type { - SubagentProgress, - SubagentActivityItem, +import { + type SubagentProgress, + type SubagentActivityItem, + SubagentState, } from '@google/gemini-cli-core'; import { TOOL_STATUS } from '../../constants.js'; import { STATUS_INDICATOR_WIDTH } from './ToolShared.js'; @@ -62,13 +63,13 @@ export const SubagentProgressDisplay: React.FC< let headerText: string | undefined; let headerColor = theme.text.secondary; - if (progress.state === 'cancelled') { + if (progress.state === SubagentState.CANCELLED) { headerText = `Subagent ${progress.agentName} was cancelled.`; headerColor = theme.status.warning; - } else if (progress.state === 'error') { + } else if (progress.state === SubagentState.ERROR) { headerText = `Subagent ${progress.agentName} failed.`; headerColor = theme.status.error; - } else if (progress.state === 'completed') { + } else if (progress.state === SubagentState.COMPLETED) { headerText = `Subagent ${progress.agentName} completed.`; headerColor = theme.status.success; } else { @@ -107,13 +108,13 @@ export const SubagentProgressDisplay: React.FC< ); } else if (item.type === 'tool_call') { const statusSymbol = - item.status === 'running' ? ( + item.status === SubagentState.RUNNING ? ( - ) : item.status === 'completed' ? ( + ) : item.status === SubagentState.COMPLETED ? ( {TOOL_STATUS.SUCCESS} - ) : item.status === 'cancelled' ? ( + ) : item.status === SubagentState.CANCELLED ? ( {TOOL_STATUS.CANCELED} @@ -135,7 +136,7 @@ export const SubagentProgressDisplay: React.FC< {item.displayName || item.content} @@ -144,7 +145,9 @@ export const SubagentProgressDisplay: React.FC< {displayArgs} @@ -170,7 +173,7 @@ export const SubagentProgressDisplay: React.FC< )} diff --git a/packages/core/src/agents/remote-invocation.test.ts b/packages/core/src/agents/remote-invocation.test.ts index 8b0afa88318..c2b89f49dfe 100644 --- a/packages/core/src/agents/remote-invocation.test.ts +++ b/packages/core/src/agents/remote-invocation.test.ts @@ -825,7 +825,9 @@ describe('RemoteAgentInvocation', () => { abortSignal: new AbortController().signal, }); - expect(result.returnDisplay).toMatchObject({ state: 'error' }); + expect(result.returnDisplay).toMatchObject({ + state: SubagentState.ERROR, + }); // Should contain both the partial output and the error message expect(result.returnDisplay).toMatchObject({ result: expect.stringContaining('Partial response'), From dc3c7994d10ed1c39566489c0f006ddc6a8191df Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Thu, 7 May 2026 14:09:21 -0400 Subject: [PATCH 06/16] refactor: address review comments for local protocol --- packages/core/src/agent/content-utils.test.ts | 16 ++++------------ packages/core/src/agent/content-utils.ts | 13 +++++++------ .../core/src/agent/legacy-agent-session.test.ts | 7 +++---- packages/core/src/agent/legacy-agent-session.ts | 6 ++---- 4 files changed, 16 insertions(+), 26 deletions(-) diff --git a/packages/core/src/agent/content-utils.test.ts b/packages/core/src/agent/content-utils.test.ts index acf8a4a3294..346b0e2a56f 100644 --- a/packages/core/src/agent/content-utils.test.ts +++ b/packages/core/src/agent/content-utils.test.ts @@ -187,22 +187,14 @@ describe('contentPartsToGeminiParts', () => { ]); }); - it('serializes unknown ContentPart variants', () => { + it('throws on unknown ContentPart variants', () => { // Force an unknown variant past the type system const content = [ { type: 'custom_widget', payload: 123 }, ] as unknown as ContentPart[]; - - const warnSpy = vi.spyOn(debugLogger, 'warn'); - const result = contentPartsToGeminiParts(content); - - expect(warnSpy).toHaveBeenCalled(); - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - text: JSON.stringify({ type: 'custom_widget', payload: 123 }), - }); - - warnSpy.mockRestore(); + expect(() => contentPartsToGeminiParts(content)).toThrow( + 'Unhandled ContentPart type: {"type":"custom_widget","payload":123}', + ); }); }); diff --git a/packages/core/src/agent/content-utils.ts b/packages/core/src/agent/content-utils.ts index 42b0b7fec71..1c716ffd114 100644 --- a/packages/core/src/agent/content-utils.ts +++ b/packages/core/src/agent/content-utils.ts @@ -93,13 +93,14 @@ export function contentPartsToGeminiParts(content: ContentPart[]): Part[] { // References are converted to text for the model result.push({ text: part.text }); break; - default: - debugLogger.warn( - `Unhandled ContentPart type: ${JSON.stringify(part)} fallback to serialization`, - ); - // Serialize unknown ContentPart variants instead of dropping them - result.push({ text: JSON.stringify(part) }); + default: { + ((x: never) => { + throw new Error(`Unhandled ContentPart type: ${JSON.stringify(x)}`); + })(part); break; + } + break; + } } } return result; diff --git a/packages/core/src/agent/legacy-agent-session.test.ts b/packages/core/src/agent/legacy-agent-session.test.ts index 525548e292d..1be4b023bd2 100644 --- a/packages/core/src/agent/legacy-agent-session.test.ts +++ b/packages/core/src/agent/legacy-agent-session.test.ts @@ -241,11 +241,10 @@ describe('LegacyAgentSession', () => { ); }); - it('throws for non-message payloads', async () => { + it('returns null streamId for non-message payloads', async () => { const session = new LegacyAgentSession(deps); - await expect(session.send({ update: { title: 'test' } })).rejects.toThrow( - 'only supports message sends', - ); + const result = await session.send({ update: { title: 'test' } }); + expect(result.streamId).toBeNull(); }); it('throws if send is called while a stream is active', async () => { diff --git a/packages/core/src/agent/legacy-agent-session.ts b/packages/core/src/agent/legacy-agent-session.ts index e8d5e56ef5c..182256323fc 100644 --- a/packages/core/src/agent/legacy-agent-session.ts +++ b/packages/core/src/agent/legacy-agent-session.ts @@ -105,12 +105,10 @@ export class LegacyAgentProtocol implements AgentProtocol { }; } - async send(payload: AgentSend): Promise<{ streamId: string }> { + async send(payload: AgentSend): Promise<{ streamId: string | null }> { const message = 'message' in payload ? payload.message : undefined; if (!message) { - throw new Error( - 'LegacyAgentSession.send() only supports message sends for the moment.', - ); + return { streamId: null }; } if (this._activeStreamId) { From aa1d1c65246b9ead814aaf44763faa7724e3725c Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Thu, 7 May 2026 14:32:22 -0400 Subject: [PATCH 07/16] refactor: address review follow-ups for local protocol --- packages/core/src/agent/content-utils.test.ts | 10 ++++++---- packages/core/src/agent/content-utils.ts | 2 -- packages/core/src/agent/legacy-agent-session.test.ts | 7 ++++--- packages/core/src/agent/legacy-agent-session.ts | 6 ++++-- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/core/src/agent/content-utils.test.ts b/packages/core/src/agent/content-utils.test.ts index 346b0e2a56f..53fe867c599 100644 --- a/packages/core/src/agent/content-utils.test.ts +++ b/packages/core/src/agent/content-utils.test.ts @@ -187,14 +187,16 @@ describe('contentPartsToGeminiParts', () => { ]); }); - it('throws on unknown ContentPart variants', () => { + it('serializes unknown ContentPart variants', () => { // Force an unknown variant past the type system const content = [ { type: 'custom_widget', payload: 123 }, ] as unknown as ContentPart[]; - expect(() => contentPartsToGeminiParts(content)).toThrow( - 'Unhandled ContentPart type: {"type":"custom_widget","payload":123}', - ); + const result = contentPartsToGeminiParts(content); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + text: JSON.stringify({ type: 'custom_widget', payload: 123 }), + }); }); }); diff --git a/packages/core/src/agent/content-utils.ts b/packages/core/src/agent/content-utils.ts index 1c716ffd114..50d054ff0e7 100644 --- a/packages/core/src/agent/content-utils.ts +++ b/packages/core/src/agent/content-utils.ts @@ -99,8 +99,6 @@ export function contentPartsToGeminiParts(content: ContentPart[]): Part[] { })(part); break; } - break; - } } } return result; diff --git a/packages/core/src/agent/legacy-agent-session.test.ts b/packages/core/src/agent/legacy-agent-session.test.ts index 1be4b023bd2..525548e292d 100644 --- a/packages/core/src/agent/legacy-agent-session.test.ts +++ b/packages/core/src/agent/legacy-agent-session.test.ts @@ -241,10 +241,11 @@ describe('LegacyAgentSession', () => { ); }); - it('returns null streamId for non-message payloads', async () => { + it('throws for non-message payloads', async () => { const session = new LegacyAgentSession(deps); - const result = await session.send({ update: { title: 'test' } }); - expect(result.streamId).toBeNull(); + await expect(session.send({ update: { title: 'test' } })).rejects.toThrow( + 'only supports message sends', + ); }); it('throws if send is called while a stream is active', async () => { diff --git a/packages/core/src/agent/legacy-agent-session.ts b/packages/core/src/agent/legacy-agent-session.ts index 182256323fc..e8d5e56ef5c 100644 --- a/packages/core/src/agent/legacy-agent-session.ts +++ b/packages/core/src/agent/legacy-agent-session.ts @@ -105,10 +105,12 @@ export class LegacyAgentProtocol implements AgentProtocol { }; } - async send(payload: AgentSend): Promise<{ streamId: string | null }> { + async send(payload: AgentSend): Promise<{ streamId: string }> { const message = 'message' in payload ? payload.message : undefined; if (!message) { - return { streamId: null }; + throw new Error( + 'LegacyAgentSession.send() only supports message sends for the moment.', + ); } if (this._activeStreamId) { From a10284350fa509a8ed58a46b97331a96ed67d74b Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Mon, 13 Apr 2026 22:24:53 -0400 Subject: [PATCH 08/16] =?UTF-8?q?feat(core):=20add=20LocalSessionInvocatio?= =?UTF-8?q?n=20=E2=80=94=20session-based=20local=20subagent=20invocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New invocation class that delegates to LocalSubagentSession instead of directly using LocalAgentExecutor. Existing LocalSubagentInvocation is untouched — this will be wired in behind a feature flag in a later PR. --- .../agents/local-session-invocation.test.ts | 815 ++++++++++++++++++ .../src/agents/local-session-invocation.ts | 411 +++++++++ 2 files changed, 1226 insertions(+) create mode 100644 packages/core/src/agents/local-session-invocation.test.ts create mode 100644 packages/core/src/agents/local-session-invocation.ts diff --git a/packages/core/src/agents/local-session-invocation.test.ts b/packages/core/src/agents/local-session-invocation.test.ts new file mode 100644 index 00000000000..9294aae3795 --- /dev/null +++ b/packages/core/src/agents/local-session-invocation.test.ts @@ -0,0 +1,815 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { + SubagentProgress , + AgentTerminateMode, + type LocalAgentDefinition, + type AgentInputs, + type SubagentActivityEvent, + SubagentActivityErrorType, + SUBAGENT_REJECTED_ERROR_PREFIX, + SUBAGENT_CANCELLED_ERROR_MESSAGE } from './types.js'; +import { LocalSessionInvocation } from './local-session-invocation.js'; +import { LocalSubagentSession } from './local-subagent-protocol.js'; +import { makeFakeConfig } from '../test-utils/config.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; +import { MessageBusType } from '../confirmation-bus/types.js'; +import type { AgentLoopContext } from '../config/agent-loop-context.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; + +vi.mock('./local-subagent-protocol.js'); + +const MockLocalSubagentSession = vi.mocked(LocalSubagentSession); + +let capturedActivityCallback: + | ((activity: SubagentActivityEvent) => void) + | undefined; + +const testDefinition: LocalAgentDefinition = { + kind: 'local', + name: 'MockAgent', + description: 'A mock agent for testing.', + inputConfig: { + inputSchema: { + type: 'object', + properties: { task: { type: 'string' } }, + }, + }, + modelConfig: { model: 'test-model', generateContentConfig: {} }, + runConfig: { maxTimeMinutes: 1 }, + promptConfig: { systemPrompt: 'test' }, +}; + +function setupMockSession(config: { + output?: { result: string; terminate_reason: AgentTerminateMode }; + error?: Error; +}) { + const mockSession = { + send: vi.fn().mockResolvedValue({ streamId: 'stream-1' }), + getResult: config.error + ? vi.fn().mockRejectedValue(config.error) + : vi.fn().mockResolvedValue( + config.output ?? { + result: 'done', + terminate_reason: AgentTerminateMode.GOAL, + }, + ), + abort: vi.fn(), + subscribe: vi.fn().mockReturnValue(vi.fn()), + }; + MockLocalSubagentSession.mockImplementation( + ( + _def: LocalAgentDefinition, + _ctx: AgentLoopContext, + _bus: MessageBus, + rawCallback?: (activity: SubagentActivityEvent) => void, + ) => { + capturedActivityCallback = rawCallback; + return mockSession as unknown as LocalSubagentSession; + }, + ); + return mockSession; +} + +describe('LocalSessionInvocation', () => { + let mockContext: AgentLoopContext; + let mockMessageBus: MessageBus; + + beforeEach(() => { + vi.clearAllMocks(); + capturedActivityCallback = undefined; + mockContext = makeFakeConfig() as unknown as AgentLoopContext; + mockMessageBus = createMockMessageBus(); + }); + + it('should pass the messageBus to the parent constructor', () => { + setupMockSession({}); + const params = { task: 'Analyze data' }; + const invocation = new LocalSessionInvocation( + testDefinition, + mockContext, + params, + mockMessageBus, + ); + expect( + (invocation as unknown as { messageBus: MessageBus }).messageBus, + ).toBe(mockMessageBus); + }); + + describe('getDescription', () => { + it('should format the description with inputs and truncate long values and overall length', () => { + setupMockSession({}); + const params = { task: 'Analyze data', priority: 5 }; + const invocation = new LocalSessionInvocation( + testDefinition, + mockContext, + params, + mockMessageBus, + ); + const description = invocation.getDescription(); + expect(description).toBe( + "Running subagent 'MockAgent' with inputs: { task: Analyze data, priority: 5 }", + ); + }); + + it('should truncate long input values', () => { + setupMockSession({}); + const longTask = 'A'.repeat(100); + const params = { task: longTask }; + const invocation = new LocalSessionInvocation( + testDefinition, + mockContext, + params, + mockMessageBus, + ); + const description = invocation.getDescription(); + expect(description).toBe( + `Running subagent 'MockAgent' with inputs: { task: ${'A'.repeat(50)} }`, + ); + }); + + it('should truncate the overall description if it exceeds the limit', () => { + setupMockSession({}); + const longNameDef: LocalAgentDefinition = { + ...testDefinition, + name: 'VeryLongAgentNameThatTakesUpSpace', + }; + const params: AgentInputs = {}; + for (let i = 0; i < 20; i++) { + params[`input${i}`] = `value${i}`; + } + const invocation = new LocalSessionInvocation( + longNameDef, + mockContext, + params, + mockMessageBus, + ); + const description = invocation.getDescription(); + expect(description.length).toBe(200); + }); + }); + + describe('execute', () => { + it('should create session and run successfully', async () => { + const mockOutput = { + result: 'Analysis complete.', + terminate_reason: AgentTerminateMode.GOAL, + }; + const mockSession = setupMockSession({ output: mockOutput }); + const params = { query: 'Execute task' }; + const signal = new AbortController().signal; + const updateOutput = vi.fn(); + const invocation = new LocalSessionInvocation( + testDefinition, + mockContext, + params, + mockMessageBus, + ); + + const result = await invocation.execute({ + abortSignal: signal, + updateOutput, + }); + + expect(MockLocalSubagentSession).toHaveBeenCalledWith( + testDefinition, + mockContext, + mockMessageBus, + expect.any(Function), + ); + expect(mockSession.send).toHaveBeenCalledWith({ + message: { content: [{ type: 'text', text: 'Execute task' }] }, + }); + expect(result.llmContent).toEqual([ + { + text: expect.stringContaining( + "Subagent 'MockAgent' finished.\nTermination Reason: GOAL\nResult:\nAnalysis complete.", + ), + }, + ]); + const display = result.returnDisplay as SubagentProgress; + expect(display.isSubagentProgress).toBe(true); + expect(display.state).toBe('completed'); + expect(display.result).toBe('Analysis complete.'); + expect(display.terminateReason).toBe(AgentTerminateMode.GOAL); + }); + + it('should stream THOUGHT_CHUNK activity', async () => { + const mockSession = setupMockSession({}); + const params = { query: 'think' }; + const signal = new AbortController().signal; + const updateOutput = vi.fn(); + const invocation = new LocalSessionInvocation( + testDefinition, + mockContext, + params, + mockMessageBus, + ); + + const executePromise = invocation.execute({ + abortSignal: signal, + updateOutput, + }); + + // Wait for send to be called so the activity callback is wired + await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled()); + + // Emit a thought chunk via captured callback + capturedActivityCallback!({ + isSubagentActivityEvent: true, + agentName: 'MockAgent', + type: 'THOUGHT_CHUNK', + data: { text: 'Analyzing...' }, + }); + + await executePromise; + + // Find an updateOutput call containing the thought + const progressCalls = updateOutput.mock.calls.map( + (c) => c[0] as SubagentProgress, + ); + const hasThought = progressCalls.some( + (p) => + p.recentActivity && + p.recentActivity.some( + (a) => a.type === 'thought' && a.content === 'Analyzing...', + ), + ); + expect(hasThought).toBe(true); + }); + + it('should stream TOOL_CALL_START and TOOL_CALL_END', async () => { + const mockSession = setupMockSession({}); + const params = { query: 'run tool' }; + const signal = new AbortController().signal; + const updateOutput = vi.fn(); + const invocation = new LocalSessionInvocation( + testDefinition, + mockContext, + params, + mockMessageBus, + ); + + const executePromise = invocation.execute({ + abortSignal: signal, + updateOutput, + }); + + await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled()); + + capturedActivityCallback!({ + isSubagentActivityEvent: true, + agentName: 'MockAgent', + type: 'TOOL_CALL_START', + data: { name: 'ls', args: {} }, + }); + capturedActivityCallback!({ + isSubagentActivityEvent: true, + agentName: 'MockAgent', + type: 'TOOL_CALL_END', + data: { name: 'ls', data: {} }, + }); + + await executePromise; + + const progressCalls = updateOutput.mock.calls.map( + (c) => c[0] as SubagentProgress, + ); + + // After TOOL_CALL_START, the immediate updateOutput call should show running + const runningCalls = progressCalls.filter((p) => p.state === 'running'); + // The first running call with a tool_call should show 'running' + const firstToolCall = runningCalls.find((p) => + p.recentActivity?.some( + (a) => a.type === 'tool_call' && a.content === 'ls', + ), + ); + expect(firstToolCall).toBeDefined(); + + // After TOOL_CALL_END, the tool should be completed + const hasCompleted = progressCalls.some((p) => + p.recentActivity?.some( + (a) => + a.type === 'tool_call' && + a.content === 'ls' && + a.status === 'completed', + ), + ); + expect(hasCompleted).toBe(true); + }); + + it('should handle ERROR activity', async () => { + const mockSession = setupMockSession({}); + const params = { query: 'fail' }; + const signal = new AbortController().signal; + const updateOutput = vi.fn(); + const invocation = new LocalSessionInvocation( + testDefinition, + mockContext, + params, + mockMessageBus, + ); + + const executePromise = invocation.execute({ + abortSignal: signal, + updateOutput, + }); + + await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled()); + + capturedActivityCallback!({ + isSubagentActivityEvent: true, + agentName: 'MockAgent', + type: 'ERROR', + data: { error: 'Something broke' }, + }); + + await executePromise; + + const progressCalls = updateOutput.mock.calls.map( + (c) => c[0] as SubagentProgress, + ); + const hasError = progressCalls.some((p) => + p.recentActivity?.some( + (a) => + a.type === 'thought' && + a.content === 'Error: Something broke' && + a.status === 'error', + ), + ); + expect(hasError).toBe(true); + }); + + it('should handle cancelled errors', async () => { + const mockSession = setupMockSession({}); + const params = { query: 'cancel' }; + const signal = new AbortController().signal; + const updateOutput = vi.fn(); + const invocation = new LocalSessionInvocation( + testDefinition, + mockContext, + params, + mockMessageBus, + ); + + const executePromise = invocation.execute({ + abortSignal: signal, + updateOutput, + }); + + await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled()); + + capturedActivityCallback!({ + isSubagentActivityEvent: true, + agentName: 'MockAgent', + type: 'ERROR', + data: { + error: SUBAGENT_CANCELLED_ERROR_MESSAGE, + errorType: SubagentActivityErrorType.CANCELLED, + }, + }); + + await executePromise; + + const progressCalls = updateOutput.mock.calls.map( + (c) => c[0] as SubagentProgress, + ); + const hasCancelled = progressCalls.some((p) => + p.recentActivity?.some( + (a) => a.type === 'thought' && a.status === 'cancelled', + ), + ); + expect(hasCancelled).toBe(true); + }); + + it('should handle rejected errors', async () => { + const mockSession = setupMockSession({}); + const params = { query: 'reject' }; + const signal = new AbortController().signal; + const updateOutput = vi.fn(); + const invocation = new LocalSessionInvocation( + testDefinition, + mockContext, + params, + mockMessageBus, + ); + + const executePromise = invocation.execute({ + abortSignal: signal, + updateOutput, + }); + + await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled()); + + capturedActivityCallback!({ + isSubagentActivityEvent: true, + agentName: 'MockAgent', + type: 'TOOL_CALL_START', + data: { name: 'dangerous_tool', args: {} }, + }); + capturedActivityCallback!({ + isSubagentActivityEvent: true, + agentName: 'MockAgent', + type: 'ERROR', + data: { + name: 'dangerous_tool', + error: `${SUBAGENT_REJECTED_ERROR_PREFIX} Rethink approach.`, + errorType: SubagentActivityErrorType.REJECTED, + }, + }); + + await executePromise; + + const progressCalls = updateOutput.mock.calls.map( + (c) => c[0] as SubagentProgress, + ); + // Tool call should be marked cancelled + const hasToolCancelled = progressCalls.some((p) => + p.recentActivity?.some( + (a) => + a.type === 'tool_call' && + a.content === 'dangerous_tool' && + a.status === 'cancelled', + ), + ); + expect(hasToolCancelled).toBe(true); + }); + + it('should trim recentActivity to MAX_RECENT_ACTIVITY', async () => { + const mockSession = setupMockSession({}); + const params = { query: 'trim' }; + const signal = new AbortController().signal; + const updateOutput = vi.fn(); + const invocation = new LocalSessionInvocation( + testDefinition, + mockContext, + params, + mockMessageBus, + ); + + const executePromise = invocation.execute({ + abortSignal: signal, + updateOutput, + }); + + await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled()); + + // Emit 4+ activities to exceed MAX_RECENT_ACTIVITY (3) + capturedActivityCallback!({ + isSubagentActivityEvent: true, + agentName: 'MockAgent', + type: 'TOOL_CALL_START', + data: { name: 'tool1', args: {} }, + }); + capturedActivityCallback!({ + isSubagentActivityEvent: true, + agentName: 'MockAgent', + type: 'TOOL_CALL_START', + data: { name: 'tool2', args: {} }, + }); + capturedActivityCallback!({ + isSubagentActivityEvent: true, + agentName: 'MockAgent', + type: 'TOOL_CALL_START', + data: { name: 'tool3', args: {} }, + }); + capturedActivityCallback!({ + isSubagentActivityEvent: true, + agentName: 'MockAgent', + type: 'TOOL_CALL_START', + data: { name: 'tool4', args: {} }, + }); + + await executePromise; + + // After the 4th activity, the last updateOutput call before completion + // should have only 3 items in recentActivity + const progressCalls = updateOutput.mock.calls.map( + (c) => c[0] as SubagentProgress, + ); + // Find the call right after the 4th activity (before completion) + const afterFourthActivity = progressCalls.filter( + (p) => p.state === 'running' && p.recentActivity.length > 0, + ); + const lastRunning = afterFourthActivity[afterFourthActivity.length - 1]; + expect(lastRunning.recentActivity.length).toBeLessThanOrEqual(3); + // Should contain tool4 (the latest) + expect( + lastRunning.recentActivity.some((a) => a.content === 'tool4'), + ).toBe(true); + // Should NOT contain tool1 (trimmed away) + expect( + lastRunning.recentActivity.some((a) => a.content === 'tool1'), + ).toBe(false); + }); + + it('should handle executor errors', async () => { + const error = new Error('Model failed during execution.'); + setupMockSession({ error }); + const params = { query: 'fail hard' }; + const signal = new AbortController().signal; + const updateOutput = vi.fn(); + const invocation = new LocalSessionInvocation( + testDefinition, + mockContext, + params, + mockMessageBus, + ); + + const result = await invocation.execute({ + abortSignal: signal, + updateOutput, + }); + + expect(result.llmContent).toBe( + `Subagent 'MockAgent' failed. Error: ${error.message}`, + ); + const display = result.returnDisplay as SubagentProgress; + expect(display.isSubagentProgress).toBe(true); + expect(display.state).toBe('error'); + expect(display.recentActivity).toContainEqual( + expect.objectContaining({ + type: 'thought', + content: `Error: ${error.message}`, + status: 'error', + }), + ); + }); + + it('should handle abort', async () => { + const mockOutput = { + result: '', + terminate_reason: AgentTerminateMode.ABORTED, + }; + setupMockSession({ output: mockOutput }); + const params = { query: 'abort me' }; + const signal = new AbortController().signal; + const updateOutput = vi.fn(); + const invocation = new LocalSessionInvocation( + testDefinition, + mockContext, + params, + mockMessageBus, + ); + + await expect( + invocation.execute({ abortSignal: signal, updateOutput }), + ).rejects.toThrow('Operation cancelled by user'); + + // Verify cancelled state was published + const progressCalls = updateOutput.mock.calls.map( + (c) => c[0] as SubagentProgress, + ); + const hasCancelledState = progressCalls.some( + (p) => p.state === 'cancelled', + ); + expect(hasCancelledState).toBe(true); + }); + + it('should wire abort signal to session.abort', async () => { + const mockSession = setupMockSession({}); + const params = { query: 'abort wire' }; + const controller = new AbortController(); + const updateOutput = vi.fn(); + const invocation = new LocalSessionInvocation( + testDefinition, + mockContext, + params, + mockMessageBus, + ); + + const executePromise = invocation.execute({ + abortSignal: controller.signal, + updateOutput, + }); + + // Trigger abort + controller.abort(); + + // The execute should complete (getResult returned GOAL by default) + await executePromise.catch(() => { + /* abort may throw */ + }); + + expect(mockSession.abort).toHaveBeenCalled(); + }); + + it('should send non-query params as config update before query', async () => { + const mockSession = setupMockSession({}); + const params = { query: 'Do something', extra_config: 'value123' }; + const signal = new AbortController().signal; + const updateOutput = vi.fn(); + const invocation = new LocalSessionInvocation( + testDefinition, + mockContext, + params, + mockMessageBus, + ); + + await invocation.execute({ abortSignal: signal, updateOutput }); + + // First send: config update with non-query params + expect(mockSession.send).toHaveBeenCalledWith({ + update: { config: { extra_config: 'value123' } }, + }); + // Second send: message with query + expect(mockSession.send).toHaveBeenCalledWith({ + message: { content: [{ type: 'text', text: 'Do something' }] }, + }); + // Config update should come before message + const sendCalls = mockSession.send.mock.calls; + const configIdx = sendCalls.findIndex((c) => c[0]?.update?.config); + const messageIdx = sendCalls.findIndex((c) => c[0]?.message); + expect(configIdx).toBeLessThan(messageIdx); + }); + + it('should publish SUBAGENT_ACTIVITY on messageBus', async () => { + const mockSession = setupMockSession({}); + const params = { query: 'publish test' }; + const signal = new AbortController().signal; + const updateOutput = vi.fn(); + const invocation = new LocalSessionInvocation( + testDefinition, + mockContext, + params, + mockMessageBus, + ); + + const executePromise = invocation.execute({ + abortSignal: signal, + updateOutput, + }); + + await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled()); + + capturedActivityCallback!({ + isSubagentActivityEvent: true, + agentName: 'MockAgent', + type: 'THOUGHT_CHUNK', + data: { text: 'Thinking...' }, + }); + + await executePromise; + + expect(mockMessageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.SUBAGENT_ACTIVITY, + subagentName: 'MockAgent', + activity: expect.objectContaining({ + type: 'thought', + content: 'Thinking...', + }), + }), + ); + }); + + it('should clean up abort listener in finally', async () => { + setupMockSession({}); + const params = { query: 'cleanup' }; + const controller = new AbortController(); + const removeEventListenerSpy = vi.spyOn( + controller.signal, + 'removeEventListener', + ); + const updateOutput = vi.fn(); + const invocation = new LocalSessionInvocation( + testDefinition, + mockContext, + params, + mockMessageBus, + ); + + await invocation.execute({ + abortSignal: controller.signal, + updateOutput, + }); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'abort', + expect.any(Function), + ); + }); + + it('should unsubscribe parent observer in finally', async () => { + const unsubscribeFn = vi.fn(); + const mockSession = setupMockSession({}); + mockSession.subscribe.mockReturnValue(unsubscribeFn); + + const params = { query: 'unsub test' }; + const signal = new AbortController().signal; + const updateOutput = vi.fn(); + const onAgentEvent = vi.fn(); + const invocation = new LocalSessionInvocation( + testDefinition, + mockContext, + params, + mockMessageBus, + { onAgentEvent }, + ); + + await invocation.execute({ abortSignal: signal, updateOutput }); + + expect(mockSession.subscribe).toHaveBeenCalledWith(onAgentEvent); + expect(unsubscribeFn).toHaveBeenCalled(); + }); + + it('should handle TOOL_CALL_END with error data', async () => { + const mockSession = setupMockSession({}); + const params = { query: 'tool error' }; + const signal = new AbortController().signal; + const updateOutput = vi.fn(); + const invocation = new LocalSessionInvocation( + testDefinition, + mockContext, + params, + mockMessageBus, + ); + + const executePromise = invocation.execute({ + abortSignal: signal, + updateOutput, + }); + + await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled()); + + capturedActivityCallback!({ + isSubagentActivityEvent: true, + agentName: 'MockAgent', + type: 'TOOL_CALL_START', + data: { name: 'failing_tool', args: {} }, + }); + capturedActivityCallback!({ + isSubagentActivityEvent: true, + agentName: 'MockAgent', + type: 'TOOL_CALL_END', + data: { name: 'failing_tool', data: { isError: true } }, + }); + + await executePromise; + + const progressCalls = updateOutput.mock.calls.map( + (c) => c[0] as SubagentProgress, + ); + const hasToolError = progressCalls.some((p) => + p.recentActivity?.some( + (a) => + a.type === 'tool_call' && + a.content === 'failing_tool' && + a.status === 'error', + ), + ); + expect(hasToolError).toBe(true); + }); + + it('should mark running items as cancelled on abort', async () => { + const abortError = new Error('Aborted'); + abortError.name = 'AbortError'; + const mockSession = setupMockSession({ error: abortError }); + const params = { query: 'mark cancelled' }; + const signal = new AbortController().signal; + const updateOutput = vi.fn(); + const invocation = new LocalSessionInvocation( + testDefinition, + mockContext, + params, + mockMessageBus, + ); + + const executePromise = invocation.execute({ + abortSignal: signal, + updateOutput, + }); + + await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled()); + + // Emit a running tool call before the abort + capturedActivityCallback!({ + isSubagentActivityEvent: true, + agentName: 'MockAgent', + type: 'TOOL_CALL_START', + data: { name: 'running_tool', args: {} }, + }); + + await expect(executePromise).rejects.toThrow('Aborted'); + + const progressCalls = updateOutput.mock.calls.map( + (c) => c[0] as SubagentProgress, + ); + // The final progress should show the tool as cancelled + const lastProgress = progressCalls[progressCalls.length - 1]; + expect(lastProgress.state).toBe('cancelled'); + expect(lastProgress.recentActivity).toContainEqual( + expect.objectContaining({ + type: 'tool_call', + content: 'running_tool', + status: 'cancelled', + }), + ); + }); + }); +}); diff --git a/packages/core/src/agents/local-session-invocation.ts b/packages/core/src/agents/local-session-invocation.ts new file mode 100644 index 00000000000..a686e25ad95 --- /dev/null +++ b/packages/core/src/agents/local-session-invocation.ts @@ -0,0 +1,411 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type AgentLoopContext } from '../config/agent-loop-context.js'; +import { MessageBusType } from '../confirmation-bus/types.js'; +import { + BaseToolInvocation, + type ToolResult, + type ExecuteOptions, +} from '../tools/tools.js'; +import { + type LocalAgentDefinition, + type AgentInputs, + type SubagentActivityEvent, + type SubagentProgress, + type SubagentActivityItem, + AgentTerminateMode, + SubagentActivityErrorType, + SUBAGENT_REJECTED_ERROR_PREFIX, + SUBAGENT_CANCELLED_ERROR_MESSAGE, + isToolActivityError, +} from './types.js'; +import { randomUUID } from 'node:crypto'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import { + sanitizeThoughtContent, + sanitizeToolArgs, + sanitizeErrorMessage, +} from '../utils/agent-sanitization-utils.js'; +import { LocalSubagentSession } from './local-subagent-protocol.js'; +import type { AgentEvent } from '../agent/types.js'; + +const INPUT_PREVIEW_MAX_LENGTH = 50; +const DESCRIPTION_MAX_LENGTH = 200; +const MAX_RECENT_ACTIVITY = 3; + +/** Optional configuration for subagent invocations. */ +export interface SubagentInvocationOptions { + toolName?: string; + toolDisplayName?: string; + onAgentEvent?: (event: AgentEvent) => void; +} + +/** + * Session-based local subagent invocation. + * + * This class orchestrates the execution of a defined agent by: + * 1. Using {@link LocalSubagentSession} as the execution engine. + * 2. Bridging the agent's streaming activity (e.g., thoughts) to the tool's + * live output stream via the session's rawActivityCallback. + * 3. Formatting the final result into a {@link ToolResult}. + */ +export class LocalSessionInvocation extends BaseToolInvocation< + AgentInputs, + ToolResult +> { + private readonly _onAgentEvent?: (event: AgentEvent) => void; + + /** + * @param definition The definition object that configures the agent. + * @param context The agent loop context. + * @param params The validated input parameters for the agent. + * @param messageBus Message bus for policy enforcement. + * @param options Optional overrides for tool name, display name, and event callback. + */ + constructor( + private readonly definition: LocalAgentDefinition, + private readonly context: AgentLoopContext, + params: AgentInputs, + messageBus: MessageBus, + options?: SubagentInvocationOptions, + ) { + super( + params, + messageBus, + options?.toolName ?? definition.name, + options?.toolDisplayName ?? definition.displayName, + ); + this._onAgentEvent = options?.onAgentEvent; + } + + /** + * Returns a concise, human-readable description of the invocation. + * Used for logging and display purposes. + */ + getDescription(): string { + const inputSummary = Object.entries(this.params) + .map( + ([key, value]) => + `${key}: ${String(value).slice(0, INPUT_PREVIEW_MAX_LENGTH)}`, + ) + .join(', '); + + const description = `Running subagent '${this.definition.name}' with inputs: { ${inputSummary} }`; + return description.slice(0, DESCRIPTION_MAX_LENGTH); + } + + private publishActivity(activity: SubagentActivityItem): void { + void this.messageBus.publish({ + type: MessageBusType.SUBAGENT_ACTIVITY, + subagentName: this.definition.displayName ?? this.definition.name, + activity, + }); + } + + /** + * Executes the subagent. + * + * @param options Options for tool execution including signal and output updates. + * @returns A `Promise` that resolves with the final `ToolResult`. + */ + async execute(options: ExecuteOptions): Promise { + const { abortSignal: signal, updateOutput } = options; + let recentActivity: SubagentActivityItem[] = []; + + // Raw SubagentActivityEvent handler — preserves all existing progress display logic. + // Passed as rawActivityCallback to LocalSubagentSession so the protocol can call it + // before translating to AgentEvents. + const onActivity = (activity: SubagentActivityEvent): void => { + if (!updateOutput) return; + + 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 = sanitizeThoughtContent(text); + } else { + recentActivity.push({ + id: randomUUID(), + type: 'thought', + content: sanitizeThoughtContent(text), + status: 'running', + }); + } + updated = true; + + const latestThought = recentActivity[recentActivity.length - 1]; + if (latestThought) { + this.publishActivity(latestThought); + } + break; + } + case 'TOOL_CALL_START': { + const name = String(activity.data['name']); + const displayName = activity.data['displayName'] + ? sanitizeErrorMessage(String(activity.data['displayName'])) + : undefined; + const description = activity.data['description'] + ? sanitizeErrorMessage(String(activity.data['description'])) + : undefined; + const args = JSON.stringify(sanitizeToolArgs(activity.data['args'])); + recentActivity.push({ + id: randomUUID(), + type: 'tool_call', + content: name, + displayName, + description, + args, + status: 'running', + }); + updated = true; + + const latestTool = recentActivity[recentActivity.length - 1]; + if (latestTool) { + this.publishActivity(latestTool); + } + break; + } + case 'TOOL_CALL_END': { + const name = String(activity.data['name']); + const data = activity.data['data']; + const isError = isToolActivityError(data); + + 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 = isError ? 'error' : 'completed'; + updated = true; + + this.publishActivity(recentActivity[i]); + break; + } + } + break; + } + case 'ERROR': { + const error = String(activity.data['error']); + const errorType = activity.data['errorType']; + const sanitizedError = sanitizeErrorMessage(error); + const isCancellation = + errorType === SubagentActivityErrorType.CANCELLED || + error === SUBAGENT_CANCELLED_ERROR_MESSAGE; + const isRejection = + errorType === SubagentActivityErrorType.REJECTED || + error.startsWith(SUBAGENT_REJECTED_ERROR_PREFIX); + + const toolName = activity.data['name'] + ? String(activity.data['name']) + : undefined; + + if (toolName && (isCancellation || isRejection)) { + 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; + } + } + } else if (toolName) { + // Mark non-rejection/non-cancellation errors as 'error' + 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 = 'error'; + updated = true; + break; + } + } + } + + recentActivity.push({ + id: randomUUID(), + type: 'thought', + content: + isCancellation || isRejection + ? sanitizedError + : `Error: ${sanitizedError}`, + status: isCancellation || isRejection ? '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); + } + }; + + // Create session with the raw activity callback for rich progress display + const session = new LocalSubagentSession( + this.definition, + this.context, + this.messageBus, + onActivity, + ); + + // Subscribe for parent session observability + let unsubscribeParent: (() => void) | undefined; + if (this._onAgentEvent) { + unsubscribeParent = session.subscribe(this._onAgentEvent); + } + + // Wire external abort signal to session abort + const abortListener = () => void session.abort(); + signal.addEventListener('abort', abortListener, { once: true }); + + try { + if (updateOutput) { + const initialProgress: SubagentProgress = { + isSubagentProgress: true, + agentName: this.definition.name, + recentActivity: [], + state: 'running', + }; + updateOutput(initialProgress); + } + + // Buffer non-query params, then send query as message to start execution + const query = String(this.params['query'] ?? ''); + const otherParams = { ...this.params } as Record; + delete otherParams['query']; + if (Object.keys(otherParams).length > 0) { + await session.send({ update: { config: otherParams } }); + } + await session.send({ + message: { content: [{ type: 'text', text: query }] }, + }); + + const output = await session.getResult(); + + 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 progress: SubagentProgress = { + isSubagentProgress: true, + agentName: this.definition.name, + recentActivity: [...recentActivity], + state: 'completed', + result: output.result, + terminateReason: output.terminate_reason, + }; + + if (updateOutput) { + updateOutput(progress); + } + + const resultContent = `Subagent '${this.definition.name}' finished. +Termination Reason: ${output.terminate_reason} +Result: +${output.result}`; + + return { + llmContent: [{ text: resultContent }], + returnDisplay: progress, + }; + } catch (error) { + 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 + if (!isAbort) { + const lastActivity = recentActivity[recentActivity.length - 1]; + if (!lastActivity || lastActivity.status !== 'error') { + recentActivity.push({ + id: randomUUID(), + type: 'thought', + content: `Error: ${errorMessage}`, + status: 'error', + }); + 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: progress, + }; + } finally { + signal.removeEventListener('abort', abortListener); + unsubscribeParent?.(); + } + } +} From 666d1d2d52e65d1cdb4206e60f71d68e87423db3 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Thu, 7 May 2026 15:18:47 -0400 Subject: [PATCH 09/16] fix(core): fix imports in local-session-invocation test --- .../core/src/agents/local-session-invocation.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/core/src/agents/local-session-invocation.test.ts b/packages/core/src/agents/local-session-invocation.test.ts index 9294aae3795..ce5967ddcf7 100644 --- a/packages/core/src/agents/local-session-invocation.test.ts +++ b/packages/core/src/agents/local-session-invocation.test.ts @@ -5,15 +5,16 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { - SubagentProgress , +import { AgentTerminateMode, + SubagentActivityErrorType, + SUBAGENT_REJECTED_ERROR_PREFIX, + SUBAGENT_CANCELLED_ERROR_MESSAGE, + type SubagentProgress, type LocalAgentDefinition, type AgentInputs, type SubagentActivityEvent, - SubagentActivityErrorType, - SUBAGENT_REJECTED_ERROR_PREFIX, - SUBAGENT_CANCELLED_ERROR_MESSAGE } from './types.js'; +} from './types.js'; import { LocalSessionInvocation } from './local-session-invocation.js'; import { LocalSubagentSession } from './local-subagent-protocol.js'; import { makeFakeConfig } from '../test-utils/config.js'; From 7bf9d0152fcc27c27aa9035d7846238127932c88 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Tue, 12 May 2026 00:06:26 -0400 Subject: [PATCH 10/16] Add safe string handling for activity data properties Use typeof check and trim to avoid displaying 'undefined' for missing properties in THOUGHT_CHUNK, TOOL_CALL_START, TOOL_CALL_END, and ERROR events. TAG=agy CONV=d4b1d79a-226d-4301-8d60-bdb5d4701569 --- .../core/src/agents/local-session-invocation.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/core/src/agents/local-session-invocation.ts b/packages/core/src/agents/local-session-invocation.ts index a686e25ad95..08e8f345937 100644 --- a/packages/core/src/agents/local-session-invocation.ts +++ b/packages/core/src/agents/local-session-invocation.ts @@ -126,7 +126,8 @@ export class LocalSessionInvocation extends BaseToolInvocation< switch (activity.type) { case 'THOUGHT_CHUNK': { - const text = String(activity.data['text']); + const rawText = activity.data['text']; + const text = typeof rawText === 'string' ? rawText.trim() : ''; const lastItem = recentActivity[recentActivity.length - 1]; if ( @@ -152,9 +153,10 @@ export class LocalSessionInvocation extends BaseToolInvocation< break; } case 'TOOL_CALL_START': { - const name = String(activity.data['name']); + const rawName = activity.data['name']; + const name = typeof rawName === 'string' ? rawName.trim() : ''; const displayName = activity.data['displayName'] - ? sanitizeErrorMessage(String(activity.data['displayName'])) + ? sanitizeErrorMessage(String(activity.data['displayName']).trim()) : undefined; const description = activity.data['description'] ? sanitizeErrorMessage(String(activity.data['description'])) @@ -178,7 +180,8 @@ export class LocalSessionInvocation extends BaseToolInvocation< break; } case 'TOOL_CALL_END': { - const name = String(activity.data['name']); + const rawName = activity.data['name']; + const name = typeof rawName === 'string' ? rawName.trim() : ''; const data = activity.data['data']; const isError = isToolActivityError(data); @@ -198,7 +201,8 @@ export class LocalSessionInvocation extends BaseToolInvocation< break; } case 'ERROR': { - const error = String(activity.data['error']); + const rawError = activity.data['error']; + const error = typeof rawError === 'string' ? rawError.trim() : ''; const errorType = activity.data['errorType']; const sanitizedError = sanitizeErrorMessage(error); const isCancellation = From e5b39b319ea324ae3fe9e8bf9f7d65787f9050be Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Tue, 12 May 2026 15:03:17 -0400 Subject: [PATCH 11/16] fix(core): restore fallback serialization in content-utils to avoid throwing --- packages/core/src/agent/content-utils.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/core/src/agent/content-utils.ts b/packages/core/src/agent/content-utils.ts index 50d054ff0e7..42b0b7fec71 100644 --- a/packages/core/src/agent/content-utils.ts +++ b/packages/core/src/agent/content-utils.ts @@ -93,12 +93,13 @@ export function contentPartsToGeminiParts(content: ContentPart[]): Part[] { // References are converted to text for the model result.push({ text: part.text }); break; - default: { - ((x: never) => { - throw new Error(`Unhandled ContentPart type: ${JSON.stringify(x)}`); - })(part); + default: + debugLogger.warn( + `Unhandled ContentPart type: ${JSON.stringify(part)} fallback to serialization`, + ); + // Serialize unknown ContentPart variants instead of dropping them + result.push({ text: JSON.stringify(part) }); break; - } } } return result; From 0b3b5abca35f53834b80c470ff20127cc20b931d Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Tue, 12 May 2026 15:20:33 -0400 Subject: [PATCH 12/16] test(core): verify warning log in content-utils fallback test --- packages/core/src/agent/content-utils.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/core/src/agent/content-utils.test.ts b/packages/core/src/agent/content-utils.test.ts index 53fe867c599..0ed681c0786 100644 --- a/packages/core/src/agent/content-utils.test.ts +++ b/packages/core/src/agent/content-utils.test.ts @@ -192,11 +192,16 @@ describe('contentPartsToGeminiParts', () => { const content = [ { type: 'custom_widget', payload: 123 }, ] as unknown as ContentPart[]; + const warnSpy = vi.spyOn(debugLogger, 'warn'); const result = contentPartsToGeminiParts(content); + + expect(warnSpy).toHaveBeenCalled(); expect(result).toHaveLength(1); expect(result[0]).toEqual({ text: JSON.stringify({ type: 'custom_widget', payload: 123 }), }); + + warnSpy.mockRestore(); }); }); From 34d41c3e8df64ed18a04ffd3a1d200fa302a1c9f Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Tue, 12 May 2026 15:36:36 -0400 Subject: [PATCH 13/16] refactor(core): use SubagentState enum in LocalSessionInvocation --- .../src/agents/local-session-invocation.ts | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/packages/core/src/agents/local-session-invocation.ts b/packages/core/src/agents/local-session-invocation.ts index 08e8f345937..0e49bf62409 100644 --- a/packages/core/src/agents/local-session-invocation.ts +++ b/packages/core/src/agents/local-session-invocation.ts @@ -22,6 +22,7 @@ import { SUBAGENT_REJECTED_ERROR_PREFIX, SUBAGENT_CANCELLED_ERROR_MESSAGE, isToolActivityError, + SubagentState, } from './types.js'; import { randomUUID } from 'node:crypto'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; @@ -141,7 +142,7 @@ export class LocalSessionInvocation extends BaseToolInvocation< id: randomUUID(), type: 'thought', content: sanitizeThoughtContent(text), - status: 'running', + status: SubagentState.RUNNING, }); } updated = true; @@ -169,7 +170,7 @@ export class LocalSessionInvocation extends BaseToolInvocation< displayName, description, args, - status: 'running', + status: SubagentState.RUNNING, }); updated = true; @@ -189,9 +190,11 @@ export class LocalSessionInvocation extends BaseToolInvocation< if ( recentActivity[i].type === 'tool_call' && recentActivity[i].content === name && - recentActivity[i].status === 'running' + recentActivity[i].status === SubagentState.RUNNING ) { - recentActivity[i].status = isError ? 'error' : 'completed'; + recentActivity[i].status = isError + ? SubagentState.ERROR + : SubagentState.COMPLETED; updated = true; this.publishActivity(recentActivity[i]); @@ -221,9 +224,9 @@ export class LocalSessionInvocation extends BaseToolInvocation< if ( recentActivity[i].type === 'tool_call' && recentActivity[i].content === toolName && - recentActivity[i].status === 'running' + recentActivity[i].status === SubagentState.RUNNING ) { - recentActivity[i].status = 'cancelled'; + recentActivity[i].status = SubagentState.CANCELLED; updated = true; break; } @@ -234,9 +237,9 @@ export class LocalSessionInvocation extends BaseToolInvocation< if ( recentActivity[i].type === 'tool_call' && recentActivity[i].content === toolName && - recentActivity[i].status === 'running' + recentActivity[i].status === SubagentState.RUNNING ) { - recentActivity[i].status = 'error'; + recentActivity[i].status = SubagentState.ERROR; updated = true; break; } @@ -250,7 +253,10 @@ export class LocalSessionInvocation extends BaseToolInvocation< isCancellation || isRejection ? sanitizedError : `Error: ${sanitizedError}`, - status: isCancellation || isRejection ? 'cancelled' : 'error', + status: + isCancellation || isRejection + ? SubagentState.CANCELLED + : SubagentState.ERROR, }); updated = true; break; @@ -269,7 +275,7 @@ export class LocalSessionInvocation extends BaseToolInvocation< isSubagentProgress: true, agentName: this.definition.name, recentActivity: [...recentActivity], // Copy to avoid mutation issues - state: 'running', + state: SubagentState.RUNNING, }; updateOutput(progress); @@ -300,7 +306,7 @@ export class LocalSessionInvocation extends BaseToolInvocation< isSubagentProgress: true, agentName: this.definition.name, recentActivity: [], - state: 'running', + state: SubagentState.RUNNING, }; updateOutput(initialProgress); } @@ -323,7 +329,7 @@ export class LocalSessionInvocation extends BaseToolInvocation< isSubagentProgress: true, agentName: this.definition.name, recentActivity: [...recentActivity], - state: 'cancelled', + state: SubagentState.CANCELLED, }; if (updateOutput) { @@ -339,7 +345,7 @@ export class LocalSessionInvocation extends BaseToolInvocation< isSubagentProgress: true, agentName: this.definition.name, recentActivity: [...recentActivity], - state: 'completed', + state: SubagentState.COMPLETED, result: output.result, terminateReason: output.terminate_reason, }; @@ -367,20 +373,20 @@ ${output.result}`; // Mark any running items as error/cancelled for (const item of recentActivity) { - if (item.status === 'running') { - item.status = isAbort ? 'cancelled' : 'error'; + if (item.status === SubagentState.RUNNING) { + item.status = isAbort ? SubagentState.CANCELLED : SubagentState.ERROR; } } // Ensure the error is reflected in the recent activity for display if (!isAbort) { const lastActivity = recentActivity[recentActivity.length - 1]; - if (!lastActivity || lastActivity.status !== 'error') { + if (!lastActivity || lastActivity.status !== SubagentState.ERROR) { recentActivity.push({ id: randomUUID(), type: 'thought', content: `Error: ${errorMessage}`, - status: 'error', + status: SubagentState.ERROR, }); if (recentActivity.length > MAX_RECENT_ACTIVITY) { recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY); @@ -392,7 +398,7 @@ ${output.result}`; isSubagentProgress: true, agentName: this.definition.name, recentActivity: [...recentActivity], - state: isAbort ? 'cancelled' : 'error', + state: isAbort ? SubagentState.CANCELLED : SubagentState.ERROR, }; if (updateOutput) { From 3229726a4e10f53139b4bab367552200af6b8ea3 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Wed, 13 May 2026 10:42:21 -0400 Subject: [PATCH 14/16] chore(core): add TODOs about parallel tool calls and callId in local session invocation --- packages/core/src/agents/local-session-invocation.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/core/src/agents/local-session-invocation.ts b/packages/core/src/agents/local-session-invocation.ts index 0e49bf62409..240b42de634 100644 --- a/packages/core/src/agents/local-session-invocation.ts +++ b/packages/core/src/agents/local-session-invocation.ts @@ -163,6 +163,7 @@ export class LocalSessionInvocation extends BaseToolInvocation< ? sanitizeErrorMessage(String(activity.data['description'])) : undefined; const args = JSON.stringify(sanitizeToolArgs(activity.data['args'])); + // TODO: Use unique callId when available instead of randomUUID to support parallel calls reliably. recentActivity.push({ id: randomUUID(), type: 'tool_call', @@ -186,6 +187,8 @@ export class LocalSessionInvocation extends BaseToolInvocation< const data = activity.data['data']; const isError = isToolActivityError(data); + // TODO: Matching tool calls by name is unreliable when parallel tool calls are active. + // Use unique callId when available to find the correct TOOL_CALL_START item. for (let i = recentActivity.length - 1; i >= 0; i--) { if ( recentActivity[i].type === 'tool_call' && @@ -219,6 +222,8 @@ export class LocalSessionInvocation extends BaseToolInvocation< ? String(activity.data['name']) : undefined; + // TODO: Matching tool calls by name is unreliable when parallel tool calls are active. + // Use unique callId when available to find the correct TOOL_CALL_START item. if (toolName && (isCancellation || isRejection)) { for (let i = recentActivity.length - 1; i >= 0; i--) { if ( From 31126979a1d35d8284b874633f571d957c63a9e8 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Mon, 18 May 2026 10:18:58 -0400 Subject: [PATCH 15/16] fix(core): address review comments on local session invocation --- packages/core/src/agents/local-session-invocation.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/src/agents/local-session-invocation.ts b/packages/core/src/agents/local-session-invocation.ts index 240b42de634..535bdb41169 100644 --- a/packages/core/src/agents/local-session-invocation.ts +++ b/packages/core/src/agents/local-session-invocation.ts @@ -31,6 +31,7 @@ import { sanitizeToolArgs, sanitizeErrorMessage, } from '../utils/agent-sanitization-utils.js'; +import { checkExhaustive } from '../utils/checks.js'; import { LocalSubagentSession } from './local-subagent-protocol.js'; import type { AgentEvent } from '../agent/types.js'; @@ -128,13 +129,13 @@ export class LocalSessionInvocation extends BaseToolInvocation< switch (activity.type) { case 'THOUGHT_CHUNK': { const rawText = activity.data['text']; - const text = typeof rawText === 'string' ? rawText.trim() : ''; + const text = typeof rawText === 'string' ? rawText : ''; const lastItem = recentActivity[recentActivity.length - 1]; if ( lastItem && lastItem.type === 'thought' && - lastItem.status === 'running' + lastItem.status === SubagentState.RUNNING ) { lastItem.content = sanitizeThoughtContent(text); } else { @@ -267,6 +268,7 @@ export class LocalSessionInvocation extends BaseToolInvocation< break; } default: + checkExhaustive(activity.type); break; } From af3aadda5816e22845a3af0de1ce6a5cfaa834ca Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Mon, 18 May 2026 14:42:48 -0400 Subject: [PATCH 16/16] fix(core): address jacob's review comments on local-invocation (license year, description truncation, callId parallel tracking) --- packages/core/src/agent/content-utils.test.ts | 1 + .../core/src/agents/local-invocation.test.ts | 10 +-- packages/core/src/agents/local-invocation.ts | 79 ++++++++--------- .../agents/local-session-invocation.test.ts | 23 ++--- .../src/agents/local-session-invocation.ts | 84 ++++++++----------- 5 files changed, 89 insertions(+), 108 deletions(-) diff --git a/packages/core/src/agent/content-utils.test.ts b/packages/core/src/agent/content-utils.test.ts index 0ed681c0786..acf8a4a3294 100644 --- a/packages/core/src/agent/content-utils.test.ts +++ b/packages/core/src/agent/content-utils.test.ts @@ -192,6 +192,7 @@ describe('contentPartsToGeminiParts', () => { const content = [ { type: 'custom_widget', payload: 123 }, ] as unknown as ContentPart[]; + const warnSpy = vi.spyOn(debugLogger, 'warn'); const result = contentPartsToGeminiParts(content); diff --git a/packages/core/src/agents/local-invocation.test.ts b/packages/core/src/agents/local-invocation.test.ts index 297b46592eb..37fefa2648a 100644 --- a/packages/core/src/agents/local-invocation.test.ts +++ b/packages/core/src/agents/local-invocation.test.ts @@ -121,7 +121,7 @@ describe('LocalSubagentInvocation', () => { ); }); - it('should truncate long input values', () => { + it('should not truncate long input values', () => { const longTask = 'A'.repeat(100); const params = { task: longTask }; const invocation = new LocalSubagentInvocation( @@ -131,13 +131,12 @@ describe('LocalSubagentInvocation', () => { mockMessageBus, ); const description = invocation.getDescription(); - // Default INPUT_PREVIEW_MAX_LENGTH is 50 expect(description).toBe( - `Running subagent 'MockAgent' with inputs: { task: ${'A'.repeat(50)} }`, + `Running subagent 'MockAgent' with inputs: { task: ${'A'.repeat(100)} }`, ); }); - it('should truncate the overall description if it exceeds the limit', () => { + it('should not truncate the overall description', () => { // Create a definition and inputs that result in a very long description const longNameDef: LocalAgentDefinition = { ...testDefinition, @@ -154,8 +153,7 @@ describe('LocalSubagentInvocation', () => { mockMessageBus, ); const description = invocation.getDescription(); - // Default DESCRIPTION_MAX_LENGTH is 200 - expect(description.length).toBe(200); + expect(description.length).toBeGreaterThan(300); expect( description.startsWith( "Running subagent 'VeryLongAgentNameThatTakesUpSpace'", diff --git a/packages/core/src/agents/local-invocation.ts b/packages/core/src/agents/local-invocation.ts index f4d3153d79c..5ed7a27c043 100644 --- a/packages/core/src/agents/local-invocation.ts +++ b/packages/core/src/agents/local-invocation.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -35,9 +35,6 @@ import { } from '../utils/agent-sanitization-utils.js'; import { debugLogger } from '../utils/debugLogger.js'; -const INPUT_PREVIEW_MAX_LENGTH = 50; -const DESCRIPTION_MAX_LENGTH = 200; - /** * Represents a validated, executable instance of a subagent tool. * @@ -80,14 +77,10 @@ export class LocalSubagentInvocation extends BaseToolInvocation< */ getDescription(): string { const inputSummary = Object.entries(this.params) - .map( - ([key, value]) => - `${key}: ${String(value).slice(0, INPUT_PREVIEW_MAX_LENGTH)}`, - ) + .map(([key, value]) => `${key}: ${String(value)}`) .join(', '); - const description = `Running subagent '${this.definition.name}' with inputs: { ${inputSummary} }`; - return description.slice(0, DESCRIPTION_MAX_LENGTH); + return `Running subagent '${this.definition.name}' with inputs: { ${inputSummary} }`; } private publishActivity(activity: SubagentActivityItem): void { @@ -168,8 +161,11 @@ export class LocalSubagentInvocation extends BaseToolInvocation< const args = JSON.stringify( sanitizeToolArgs(activity.data['args']), ); + const callId = activity.data['callId'] + ? String(activity.data['callId']) + : randomUUID(); recentActivity.push({ - id: randomUUID(), + id: callId, type: 'tool_call', content: name, displayName, @@ -186,23 +182,28 @@ export class LocalSubagentInvocation extends BaseToolInvocation< break; } case 'TOOL_CALL_END': { - const name = String(activity.data['name']); const data = activity.data['data']; const isError = isToolActivityError(data); - for (let i = recentActivity.length - 1; i >= 0; i--) { - if ( - recentActivity[i].type === 'tool_call' && - recentActivity[i].content === name && - recentActivity[i].status === SubagentState.RUNNING - ) { - recentActivity[i].status = isError - ? SubagentState.ERROR - : SubagentState.COMPLETED; - updated = true; - - this.publishActivity(recentActivity[i]); - break; + const callId = activity.data['id'] + ? String(activity.data['id']) + : undefined; + + if (callId) { + for (let i = recentActivity.length - 1; i >= 0; i--) { + if ( + recentActivity[i].type === 'tool_call' && + recentActivity[i].id === callId && + recentActivity[i].status === SubagentState.RUNNING + ) { + recentActivity[i].status = isError + ? SubagentState.ERROR + : SubagentState.COMPLETED; + updated = true; + + this.publishActivity(recentActivity[i]); + break; + } } } break; @@ -218,31 +219,23 @@ export class LocalSubagentInvocation extends BaseToolInvocation< errorType === SubagentActivityErrorType.REJECTED || error.startsWith(SUBAGENT_REJECTED_ERROR_PREFIX); - const toolName = activity.data['name'] - ? String(activity.data['name']) + const callId = activity.data['callId'] + ? String(activity.data['callId']) : undefined; - if (toolName && (isCancellation || isRejection)) { - for (let i = recentActivity.length - 1; i >= 0; i--) { - if ( - recentActivity[i].type === 'tool_call' && - recentActivity[i].content === toolName && - recentActivity[i].status === SubagentState.RUNNING - ) { - recentActivity[i].status = SubagentState.CANCELLED; - updated = true; - break; - } - } - } else if (toolName) { - // Mark non-rejection/non-cancellation errors as 'error' + if (callId) { + const targetStatus = + isCancellation || isRejection + ? SubagentState.CANCELLED + : SubagentState.ERROR; + for (let i = recentActivity.length - 1; i >= 0; i--) { if ( recentActivity[i].type === 'tool_call' && - recentActivity[i].content === toolName && + recentActivity[i].id === callId && recentActivity[i].status === SubagentState.RUNNING ) { - recentActivity[i].status = SubagentState.ERROR; + recentActivity[i].status = targetStatus; updated = true; break; } diff --git a/packages/core/src/agents/local-session-invocation.test.ts b/packages/core/src/agents/local-session-invocation.test.ts index ce5967ddcf7..72a511ba7d5 100644 --- a/packages/core/src/agents/local-session-invocation.test.ts +++ b/packages/core/src/agents/local-session-invocation.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -103,7 +103,7 @@ describe('LocalSessionInvocation', () => { }); describe('getDescription', () => { - it('should format the description with inputs and truncate long values and overall length', () => { + it('should format the description with inputs', () => { setupMockSession({}); const params = { task: 'Analyze data', priority: 5 }; const invocation = new LocalSessionInvocation( @@ -118,7 +118,7 @@ describe('LocalSessionInvocation', () => { ); }); - it('should truncate long input values', () => { + it('should not truncate long input values', () => { setupMockSession({}); const longTask = 'A'.repeat(100); const params = { task: longTask }; @@ -130,11 +130,11 @@ describe('LocalSessionInvocation', () => { ); const description = invocation.getDescription(); expect(description).toBe( - `Running subagent 'MockAgent' with inputs: { task: ${'A'.repeat(50)} }`, + `Running subagent 'MockAgent' with inputs: { task: ${'A'.repeat(100)} }`, ); }); - it('should truncate the overall description if it exceeds the limit', () => { + it('should not truncate the overall description', () => { setupMockSession({}); const longNameDef: LocalAgentDefinition = { ...testDefinition, @@ -151,7 +151,7 @@ describe('LocalSessionInvocation', () => { mockMessageBus, ); const description = invocation.getDescription(); - expect(description.length).toBe(200); + expect(description.length).toBeGreaterThan(300); }); }); @@ -267,13 +267,13 @@ describe('LocalSessionInvocation', () => { isSubagentActivityEvent: true, agentName: 'MockAgent', type: 'TOOL_CALL_START', - data: { name: 'ls', args: {} }, + data: { name: 'ls', args: {}, callId: 'call-123' }, }); capturedActivityCallback!({ isSubagentActivityEvent: true, agentName: 'MockAgent', type: 'TOOL_CALL_END', - data: { name: 'ls', data: {} }, + data: { name: 'ls', data: {}, id: 'call-123' }, }); await executePromise; @@ -411,7 +411,7 @@ describe('LocalSessionInvocation', () => { isSubagentActivityEvent: true, agentName: 'MockAgent', type: 'TOOL_CALL_START', - data: { name: 'dangerous_tool', args: {} }, + data: { name: 'dangerous_tool', args: {}, callId: 'call-rej' }, }); capturedActivityCallback!({ isSubagentActivityEvent: true, @@ -421,6 +421,7 @@ describe('LocalSessionInvocation', () => { name: 'dangerous_tool', error: `${SUBAGENT_REJECTED_ERROR_PREFIX} Rethink approach.`, errorType: SubagentActivityErrorType.REJECTED, + callId: 'call-rej', }, }); @@ -742,13 +743,13 @@ describe('LocalSessionInvocation', () => { isSubagentActivityEvent: true, agentName: 'MockAgent', type: 'TOOL_CALL_START', - data: { name: 'failing_tool', args: {} }, + data: { name: 'failing_tool', args: {}, callId: 'call-err' }, }); capturedActivityCallback!({ isSubagentActivityEvent: true, agentName: 'MockAgent', type: 'TOOL_CALL_END', - data: { name: 'failing_tool', data: { isError: true } }, + data: { name: 'failing_tool', data: { isError: true }, id: 'call-err' }, }); await executePromise; diff --git a/packages/core/src/agents/local-session-invocation.ts b/packages/core/src/agents/local-session-invocation.ts index 535bdb41169..6b356349e7e 100644 --- a/packages/core/src/agents/local-session-invocation.ts +++ b/packages/core/src/agents/local-session-invocation.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -35,8 +35,6 @@ import { checkExhaustive } from '../utils/checks.js'; import { LocalSubagentSession } from './local-subagent-protocol.js'; import type { AgentEvent } from '../agent/types.js'; -const INPUT_PREVIEW_MAX_LENGTH = 50; -const DESCRIPTION_MAX_LENGTH = 200; const MAX_RECENT_ACTIVITY = 3; /** Optional configuration for subagent invocations. */ @@ -90,14 +88,10 @@ export class LocalSessionInvocation extends BaseToolInvocation< */ getDescription(): string { const inputSummary = Object.entries(this.params) - .map( - ([key, value]) => - `${key}: ${String(value).slice(0, INPUT_PREVIEW_MAX_LENGTH)}`, - ) + .map(([key, value]) => `${key}: ${String(value)}`) .join(', '); - const description = `Running subagent '${this.definition.name}' with inputs: { ${inputSummary} }`; - return description.slice(0, DESCRIPTION_MAX_LENGTH); + return `Running subagent '${this.definition.name}' with inputs: { ${inputSummary} }`; } private publishActivity(activity: SubagentActivityItem): void { @@ -164,9 +158,11 @@ export class LocalSessionInvocation extends BaseToolInvocation< ? sanitizeErrorMessage(String(activity.data['description'])) : undefined; const args = JSON.stringify(sanitizeToolArgs(activity.data['args'])); - // TODO: Use unique callId when available instead of randomUUID to support parallel calls reliably. + const callId = activity.data['callId'] + ? String(activity.data['callId']) + : randomUUID(); recentActivity.push({ - id: randomUUID(), + id: callId, type: 'tool_call', content: name, displayName, @@ -183,26 +179,28 @@ export class LocalSessionInvocation extends BaseToolInvocation< break; } case 'TOOL_CALL_END': { - const rawName = activity.data['name']; - const name = typeof rawName === 'string' ? rawName.trim() : ''; const data = activity.data['data']; const isError = isToolActivityError(data); - // TODO: Matching tool calls by name is unreliable when parallel tool calls are active. - // Use unique callId when available to find the correct TOOL_CALL_START item. - for (let i = recentActivity.length - 1; i >= 0; i--) { - if ( - recentActivity[i].type === 'tool_call' && - recentActivity[i].content === name && - recentActivity[i].status === SubagentState.RUNNING - ) { - recentActivity[i].status = isError - ? SubagentState.ERROR - : SubagentState.COMPLETED; - updated = true; - - this.publishActivity(recentActivity[i]); - break; + const callId = activity.data['id'] + ? String(activity.data['id']) + : undefined; + + if (callId) { + for (let i = recentActivity.length - 1; i >= 0; i--) { + if ( + recentActivity[i].type === 'tool_call' && + recentActivity[i].id === callId && + recentActivity[i].status === SubagentState.RUNNING + ) { + recentActivity[i].status = isError + ? SubagentState.ERROR + : SubagentState.COMPLETED; + updated = true; + + this.publishActivity(recentActivity[i]); + break; + } } } break; @@ -219,33 +217,23 @@ export class LocalSessionInvocation extends BaseToolInvocation< errorType === SubagentActivityErrorType.REJECTED || error.startsWith(SUBAGENT_REJECTED_ERROR_PREFIX); - const toolName = activity.data['name'] - ? String(activity.data['name']) + const callId = activity.data['callId'] + ? String(activity.data['callId']) : undefined; - // TODO: Matching tool calls by name is unreliable when parallel tool calls are active. - // Use unique callId when available to find the correct TOOL_CALL_START item. - if (toolName && (isCancellation || isRejection)) { - for (let i = recentActivity.length - 1; i >= 0; i--) { - if ( - recentActivity[i].type === 'tool_call' && - recentActivity[i].content === toolName && - recentActivity[i].status === SubagentState.RUNNING - ) { - recentActivity[i].status = SubagentState.CANCELLED; - updated = true; - break; - } - } - } else if (toolName) { - // Mark non-rejection/non-cancellation errors as 'error' + if (callId) { + const targetStatus = + isCancellation || isRejection + ? SubagentState.CANCELLED + : SubagentState.ERROR; + for (let i = recentActivity.length - 1; i >= 0; i--) { if ( recentActivity[i].type === 'tool_call' && - recentActivity[i].content === toolName && + recentActivity[i].id === callId && recentActivity[i].status === SubagentState.RUNNING ) { - recentActivity[i].status = SubagentState.ERROR; + recentActivity[i].status = targetStatus; updated = true; break; }