From e7333ab655919e6fade4c1ae1d864589fe9eabf1 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Thu, 7 May 2026 14:09:21 -0400 Subject: [PATCH 01/14] refactor: address review comments for local protocol --- packages/core/src/agent/content-utils.test.ts | 16 ++++------------ .../core/src/agent/legacy-agent-session.test.ts | 7 +++---- packages/core/src/agent/legacy-agent-session.ts | 6 ++---- 3 files changed, 9 insertions(+), 20 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/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 5ca4b877b2929260d0c99455ec787704537463b4 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Thu, 7 May 2026 14:32:22 -0400 Subject: [PATCH 02/14] refactor: address review follow-ups for local protocol --- packages/core/src/agent/content-utils.test.ts | 10 ++++++---- packages/core/src/agent/legacy-agent-session.test.ts | 7 ++++--- packages/core/src/agent/legacy-agent-session.ts | 6 ++++-- 3 files changed, 14 insertions(+), 9 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/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 553cc12a5bb2504f4a93e8514b6c23884a540abe Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Tue, 12 May 2026 15:20:33 -0400 Subject: [PATCH 03/14] 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 c7538e326d95733073cd11b901836756de9ad040 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Mon, 18 May 2026 14:42:48 -0400 Subject: [PATCH 04/14] 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 + 1 file changed, 1 insertion(+) 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); From 63280f7ed26d6d40c582a9553e992d945457b389 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Thu, 7 May 2026 14:09:21 -0400 Subject: [PATCH 05/14] refactor: address review comments for local protocol --- packages/core/src/agent/content-utils.test.ts | 2 +- packages/core/src/agent/legacy-agent-session.test.ts | 7 +++---- packages/core/src/agent/legacy-agent-session.ts | 6 ++---- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/core/src/agent/content-utils.test.ts b/packages/core/src/agent/content-utils.test.ts index acf8a4a3294..1f9cb12d6cf 100644 --- a/packages/core/src/agent/content-utils.test.ts +++ b/packages/core/src/agent/content-utils.test.ts @@ -187,7 +187,7 @@ 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 }, 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 4c6d284862bf5c9cebdc73b933e7295c86e5b008 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Thu, 7 May 2026 14:32:22 -0400 Subject: [PATCH 06/14] refactor: address review follow-ups for local protocol --- packages/core/src/agent/content-utils.test.ts | 2 +- packages/core/src/agent/legacy-agent-session.test.ts | 7 ++++--- packages/core/src/agent/legacy-agent-session.ts | 6 ++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/core/src/agent/content-utils.test.ts b/packages/core/src/agent/content-utils.test.ts index 1f9cb12d6cf..acf8a4a3294 100644 --- a/packages/core/src/agent/content-utils.test.ts +++ b/packages/core/src/agent/content-utils.test.ts @@ -187,7 +187,7 @@ 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 }, 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 2d28b9d5713c00a2279003265639d3a6471b7033 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Mon, 13 Apr 2026 22:14:30 -0400 Subject: [PATCH 07/14] =?UTF-8?q?feat(core):=20add=20RemoteSessionInvocati?= =?UTF-8?q?on=20=E2=80=94=20session-based=20remote=20agent=20invocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New invocation class that delegates to RemoteSubagentSession instead of directly managing A2A client streaming. Existing RemoteAgentInvocation is untouched — this will be wired in behind a feature flag in a later PR. Key behaviors: - Static sessionState map persists A2A contextId/taskId across invocations - Subscribes to session message events for live SubagentProgress updates - Detects post-getResult abort and surfaces proper error state - Includes partial output in error display via getLatestProgress() - Properly cleans up abort listeners and subscriptions in finally block Also adds initialState param and getSessionState() to RemoteSubagentProtocol/RemoteSubagentSession for cross-invocation state persistence. --- .../agents/remote-session-invocation.test.ts | 568 ++++++++++++++++++ .../src/agents/remote-session-invocation.ts | 241 ++++++++ .../src/agents/remote-subagent-protocol.ts | 23 + 3 files changed, 832 insertions(+) create mode 100644 packages/core/src/agents/remote-session-invocation.test.ts create mode 100644 packages/core/src/agents/remote-session-invocation.ts diff --git a/packages/core/src/agents/remote-session-invocation.test.ts b/packages/core/src/agents/remote-session-invocation.test.ts new file mode 100644 index 00000000000..f096d72a89c --- /dev/null +++ b/packages/core/src/agents/remote-session-invocation.test.ts @@ -0,0 +1,568 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { RemoteSessionInvocation } from './remote-session-invocation.js'; +import { RemoteSubagentSession } from './remote-subagent-protocol.js'; +import type { RemoteAgentDefinition, SubagentProgress } from './types.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; +import type { AgentLoopContext } from '../config/agent-loop-context.js'; +import type { Config } from '../config/config.js'; +import type { ToolResult } from '../tools/tools.js'; +import type { AgentEvent } from '../agent/types.js'; + +vi.mock('./remote-subagent-protocol.js'); + +const mockDefinition: RemoteAgentDefinition = { + name: 'test-agent', + kind: 'remote', + agentCardUrl: 'http://test-agent/card', + displayName: 'Test Agent', + description: 'A test agent', + inputConfig: { inputSchema: { type: 'object' } }, +}; + +const mockMessageBus = createMockMessageBus(); + +interface MockSessionSetupOptions { + result?: ToolResult; + error?: Error; + progress?: SubagentProgress; + sessionState?: { contextId?: string; taskId?: string }; +} + +function setupMockSession(options: MockSessionSetupOptions = {}) { + const { + result = { + llmContent: [{ text: 'done' }], + returnDisplay: { + isSubagentProgress: true, + agentName: 'Test Agent', + state: 'completed', + result: 'done', + recentActivity: [], + } satisfies SubagentProgress, + }, + error, + progress, + sessionState = {}, + } = options; + + const subscriberCallbacks: Array<(event: AgentEvent) => void> = []; + + const mockSession = { + send: vi.fn().mockResolvedValue({ streamId: 'stream-1' }), + getResult: error + ? vi.fn().mockRejectedValue(error) + : vi.fn().mockResolvedValue(result), + getLatestProgress: vi.fn().mockReturnValue(progress), + getSessionState: vi.fn().mockReturnValue(sessionState), + subscribe: vi.fn((cb: (event: AgentEvent) => void) => { + subscriberCallbacks.push(cb); + return vi.fn(); // unsubscribe + }), + abort: vi.fn(), + }; + + vi.mocked(RemoteSubagentSession).mockImplementation( + () => mockSession as unknown as RemoteSubagentSession, + ); + + return { + mockSession, + subscriberCallbacks, + /** Fire a message event through all subscribed callbacks. */ + emitEvent(event: AgentEvent) { + for (const cb of subscriberCallbacks) { + cb(event); + } + }, + }; +} + +describe('RemoteSessionInvocation', () => { + let mockContext: AgentLoopContext; + + beforeEach(() => { + vi.clearAllMocks(); + + const mockConfig = { + getA2AClientManager: vi.fn().mockReturnValue({}), + injectionService: { + getLatestInjectionIndex: vi.fn().mockReturnValue(0), + }, + } as unknown as Config; + + mockContext = { config: mockConfig } as unknown as AgentLoopContext; + + // Clear the static sessionState map between tests + ( + RemoteSessionInvocation as unknown as { + sessionState?: Map; + } + ).sessionState?.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // --------------------------------------------------------------------------- + // Constructor Validation + // --------------------------------------------------------------------------- + + describe('Constructor Validation', () => { + it('accepts valid input with string query', () => { + expect(() => { + new RemoteSessionInvocation( + mockDefinition, + mockContext, + { query: 'hello' }, + mockMessageBus, + ); + }).not.toThrow(); + }); + + it('accepts missing query (defaults to "Get Started!")', () => { + expect(() => { + new RemoteSessionInvocation( + mockDefinition, + mockContext, + {}, + mockMessageBus, + ); + }).not.toThrow(); + }); + + it('throws if query is not a string', () => { + expect(() => { + new RemoteSessionInvocation( + mockDefinition, + mockContext, + { query: 123 }, + mockMessageBus, + ); + }).toThrow("requires a string 'query' input"); + }); + + it('throws if A2AClientManager is not available', () => { + const noA2AConfig = { + getA2AClientManager: vi.fn().mockReturnValue(undefined), + injectionService: { + getLatestInjectionIndex: vi.fn().mockReturnValue(0), + }, + } as unknown as Config; + const noA2AContext = { + config: noA2AConfig, + } as unknown as AgentLoopContext; + + expect(() => { + new RemoteSessionInvocation( + mockDefinition, + noA2AContext, + { query: 'hi' }, + mockMessageBus, + ); + }).toThrow('A2AClientManager is not available'); + }); + }); + + // --------------------------------------------------------------------------- + // Execution Logic + // --------------------------------------------------------------------------- + + describe('Execution Logic', () => { + it('should create session and return result', async () => { + const completedProgress: SubagentProgress = { + isSubagentProgress: true, + agentName: 'Test Agent', + state: 'completed', + result: 'Agent output', + recentActivity: [], + }; + const expectedResult: ToolResult = { + llmContent: [{ text: 'Agent output' }], + returnDisplay: completedProgress, + }; + + setupMockSession({ + result: expectedResult, + progress: completedProgress, + }); + + const invocation = new RemoteSessionInvocation( + mockDefinition, + mockContext, + { query: 'do stuff' }, + mockMessageBus, + ); + + const result = await invocation.execute({ + abortSignal: new AbortController().signal, + }); + + expect(RemoteSubagentSession).toHaveBeenCalledOnce(); + expect(result).toBe(expectedResult); + }); + + it('should pass initial state from static map to session', async () => { + const priorState = { contextId: 'ctx-42', taskId: 'task-42' }; + + // Seed the static map before constructing the invocation + ( + RemoteSessionInvocation as unknown as { + sessionState: Map; + } + ).sessionState.set('test-agent', priorState); + + setupMockSession(); + + const invocation = new RemoteSessionInvocation( + mockDefinition, + mockContext, + { query: 'hi' }, + mockMessageBus, + ); + await invocation.execute({ + abortSignal: new AbortController().signal, + }); + + // Verify the session constructor received the prior state + expect(RemoteSubagentSession).toHaveBeenCalledWith( + mockDefinition, + mockContext, + mockMessageBus, + priorState, + ); + }); + + it('should persist session state in finally block', async () => { + const newState = { contextId: 'ctx-new', taskId: 'task-new' }; + setupMockSession({ sessionState: newState }); + + const invocation = new RemoteSessionInvocation( + mockDefinition, + mockContext, + { query: 'hi' }, + mockMessageBus, + ); + await invocation.execute({ + abortSignal: new AbortController().signal, + }); + + // Verify the state was persisted in the static map + const storedState = ( + RemoteSessionInvocation as unknown as { + sessionState: Map; + } + ).sessionState.get('test-agent'); + expect(storedState).toEqual(newState); + }); + + it('should persist session state across invocations', async () => { + // First invocation returns state + const firstState = { contextId: 'ctx-1', taskId: 'task-1' }; + setupMockSession({ sessionState: firstState }); + + const invocation1 = new RemoteSessionInvocation( + mockDefinition, + mockContext, + { query: 'first' }, + mockMessageBus, + ); + await invocation1.execute({ + abortSignal: new AbortController().signal, + }); + + // Second invocation — the mock constructor should receive firstState + const secondState = { contextId: 'ctx-2', taskId: 'task-2' }; + setupMockSession({ sessionState: secondState }); + + const invocation2 = new RemoteSessionInvocation( + mockDefinition, + mockContext, + { query: 'second' }, + mockMessageBus, + ); + await invocation2.execute({ + abortSignal: new AbortController().signal, + }); + + // The second invocation should have received the first's state + const secondCallArgs = vi.mocked(RemoteSubagentSession).mock.calls[1]; + expect(secondCallArgs[3]).toEqual(firstState); + }); + + it('should subscribe for progress updates', async () => { + const completedProgress: SubagentProgress = { + isSubagentProgress: true, + agentName: 'Test Agent', + state: 'running', + result: 'partial', + recentActivity: [], + }; + const { mockSession, emitEvent } = setupMockSession({ + progress: completedProgress, + }); + + const updateOutput = vi.fn(); + const invocation = new RemoteSessionInvocation( + mockDefinition, + mockContext, + { query: 'hi' }, + mockMessageBus, + ); + + // Override getResult to emit a message event mid-execution + mockSession.getResult.mockImplementation(async () => { + emitEvent({ + type: 'message', + id: 'e1', + timestamp: new Date().toISOString(), + streamId: 's1', + role: 'agent', + content: [{ type: 'text', text: 'hello' }], + }); + return { + llmContent: [{ text: 'done' }], + returnDisplay: completedProgress, + }; + }); + + await invocation.execute({ + abortSignal: new AbortController().signal, + updateOutput, + }); + + // subscribe should have been called (at least once for progress, possibly for parent) + expect(mockSession.subscribe).toHaveBeenCalled(); + // updateOutput should have been called with the progress from getLatestProgress + expect(updateOutput).toHaveBeenCalledWith( + expect.objectContaining({ + isSubagentProgress: true, + }), + ); + }); + + it('should handle abort gracefully', async () => { + const controller = new AbortController(); + + const { mockSession } = setupMockSession(); + + // When getResult resolves, the signal will already be aborted + mockSession.getResult.mockImplementation(async () => { + controller.abort(); + return { + llmContent: [{ text: '' }], + returnDisplay: '', + }; + }); + + const updateOutput = vi.fn(); + const invocation = new RemoteSessionInvocation( + mockDefinition, + mockContext, + { query: 'hi' }, + mockMessageBus, + ); + + const result = await invocation.execute({ + abortSignal: controller.signal, + updateOutput, + }); + + expect(result.returnDisplay).toMatchObject({ state: 'error' }); + expect(result.llmContent).toEqual([ + { text: 'Operation cancelled by user' }, + ]); + }); + }); + + // --------------------------------------------------------------------------- + // Error Handling + // --------------------------------------------------------------------------- + + describe('Error Handling', () => { + it('should handle execution errors gracefully', async () => { + setupMockSession({ error: new Error('Network failure') }); + + const updateOutput = vi.fn(); + const invocation = new RemoteSessionInvocation( + mockDefinition, + mockContext, + { query: 'hi' }, + mockMessageBus, + ); + + const result = await invocation.execute({ + abortSignal: new AbortController().signal, + updateOutput, + }); + + expect(result.returnDisplay).toMatchObject({ state: 'error' }); + expect((result.returnDisplay as SubagentProgress).result).toContain( + 'Network failure', + ); + // updateOutput should be called with error progress + expect(updateOutput).toHaveBeenCalledWith( + expect.objectContaining({ state: 'error' }), + ); + }); + + it('should include partial output in error display', async () => { + const partialProgress: SubagentProgress = { + isSubagentProgress: true, + agentName: 'Test Agent', + state: 'running', + result: 'Partial work so far', + recentActivity: [ + { + id: 'a1', + type: 'thought', + content: 'Thinking...', + status: 'running', + }, + ], + }; + + setupMockSession({ + error: new Error('mid-stream error'), + progress: partialProgress, + }); + + const invocation = new RemoteSessionInvocation( + mockDefinition, + mockContext, + { query: 'hi' }, + mockMessageBus, + ); + + const result = await invocation.execute({ + abortSignal: new AbortController().signal, + }); + + const display = result.returnDisplay as SubagentProgress; + // Should contain both the partial output and the error + expect(display.result).toContain('Partial work so far'); + expect(display.result).toContain('mid-stream error'); + // Should preserve partial activity + expect(display.recentActivity).toHaveLength(1); + expect(display.recentActivity[0].content).toBe('Thinking...'); + }); + + it('should clean up listeners in finally', async () => { + const { mockSession } = setupMockSession(); + + const controller = new AbortController(); + const removeEventListenerSpy = vi.spyOn( + controller.signal, + 'removeEventListener', + ); + + const onAgentEvent = vi.fn(); + const invocation = new RemoteSessionInvocation( + mockDefinition, + mockContext, + { query: 'hi' }, + mockMessageBus, + { onAgentEvent }, + ); + + await invocation.execute({ + abortSignal: controller.signal, + }); + + // removeEventListener should have been called for the abort listener + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'abort', + expect.any(Function), + ); + + // All unsubscribe functions returned by subscribe during execute should be called + const postExecuteUnsubscribes = mockSession.subscribe.mock.results.map( + (r) => r.value, + ); + for (const unsub of postExecuteUnsubscribes) { + expect(unsub).toHaveBeenCalled(); + } + }); + }); + + // --------------------------------------------------------------------------- + // SessionState Management + // --------------------------------------------------------------------------- + + describe('SessionState Management', () => { + it('should use definition.name as session state key', async () => { + const secondDefinition: RemoteAgentDefinition = { + ...mockDefinition, + name: 'other-agent', + displayName: 'Other Agent', + }; + + // First agent + setupMockSession({ + sessionState: { contextId: 'ctx-a' }, + }); + const inv1 = new RemoteSessionInvocation( + mockDefinition, + mockContext, + { query: 'hi' }, + mockMessageBus, + ); + await inv1.execute({ abortSignal: new AbortController().signal }); + + // Second agent + setupMockSession({ + sessionState: { contextId: 'ctx-b' }, + }); + const inv2 = new RemoteSessionInvocation( + secondDefinition, + mockContext, + { query: 'hi' }, + mockMessageBus, + ); + await inv2.execute({ abortSignal: new AbortController().signal }); + + const stateMap = ( + RemoteSessionInvocation as unknown as { + sessionState: Map; + } + ).sessionState; + + // Each agent should have its own entry + expect(stateMap.get('test-agent')).toEqual({ contextId: 'ctx-a' }); + expect(stateMap.get('other-agent')).toEqual({ contextId: 'ctx-b' }); + }); + + it('should persist state even on error', async () => { + const stateOnError = { contextId: 'ctx-err', taskId: 'task-err' }; + setupMockSession({ + error: new Error('boom'), + sessionState: stateOnError, + }); + + const invocation = new RemoteSessionInvocation( + mockDefinition, + mockContext, + { query: 'hi' }, + mockMessageBus, + ); + + await invocation.execute({ + abortSignal: new AbortController().signal, + }); + + const stateMap = ( + RemoteSessionInvocation as unknown as { + sessionState: Map; + } + ).sessionState; + + expect(stateMap.get('test-agent')).toEqual(stateOnError); + }); + }); +}); diff --git a/packages/core/src/agents/remote-session-invocation.ts b/packages/core/src/agents/remote-session-invocation.ts new file mode 100644 index 00000000000..bf9d557ea60 --- /dev/null +++ b/packages/core/src/agents/remote-session-invocation.ts @@ -0,0 +1,241 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BaseToolInvocation, + type ToolConfirmationOutcome, + type ToolResult, + type ToolCallConfirmationDetails, + type ExecuteOptions, +} from '../tools/tools.js'; +import { + DEFAULT_QUERY_STRING, + type RemoteAgentInputs, + type RemoteAgentDefinition, + type AgentInputs, + type SubagentProgress, +} from './types.js'; +import { type AgentLoopContext } from '../config/agent-loop-context.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import { A2AAgentError } from './a2a-errors.js'; +import { RemoteSubagentSession } from './remote-subagent-protocol.js'; +import type { AgentEvent } from '../agent/types.js'; + +/** Optional configuration for remote agent invocations. */ +export interface SubagentInvocationOptions { + toolName?: string; + toolDisplayName?: string; + onAgentEvent?: (event: AgentEvent) => void; +} + +/** + * Session-based remote agent invocation. + * + * This implementation delegates execution to {@link RemoteSubagentSession}, + * which wraps the A2A client streaming behind the AgentProtocol interface. + * + * Cross-invocation A2A session state (contextId/taskId) is persisted via a + * static map keyed by agent name, matching the original RemoteAgentInvocation + * behavior. + */ +export class RemoteSessionInvocation extends BaseToolInvocation< + RemoteAgentInputs, + ToolResult +> { + // Persist A2A conversation state across ephemeral invocation instances. + // Keyed by agent name — each remote agent maintains independent state. + private static readonly sessionState = new Map< + string, + { contextId?: string; taskId?: string } + >(); + + private readonly _onAgentEvent?: (event: AgentEvent) => void; + + constructor( + private readonly definition: RemoteAgentDefinition, + private readonly context: AgentLoopContext, + params: AgentInputs, + messageBus: MessageBus, + options?: SubagentInvocationOptions, + ) { + const query = params['query'] ?? DEFAULT_QUERY_STRING; + if (typeof query !== 'string') { + throw new Error( + `Remote agent '${definition.name}' requires a string 'query' input.`, + ); + } + // Safe to pass strict object to super + super( + { query }, + messageBus, + options?.toolName ?? definition.name, + options?.toolDisplayName ?? definition.displayName, + ); + this._onAgentEvent = options?.onAgentEvent; + + // Validate that A2AClientManager is available at construction time + if (!this.context.config.getA2AClientManager()) { + throw new Error( + `Failed to initialize RemoteSessionInvocation for '${definition.name}': A2AClientManager is not available.`, + ); + } + } + + getDescription(): string { + return `Calling remote agent ${this.definition.displayName ?? this.definition.name}`; + } + + protected override async getConfirmationDetails( + _abortSignal: AbortSignal, + ): Promise { + return { + type: 'info', + title: `Call Remote Agent: ${this.definition.displayName ?? this.definition.name}`, + prompt: `Calling remote agent: "${this.params.query}"`, + onConfirm: async (_outcome: ToolConfirmationOutcome) => { + // Policy updates are now handled centrally by the scheduler + }, + }; + } + + async execute(options: ExecuteOptions): Promise { + const { abortSignal: _signal, updateOutput } = options; + const agentName = this.definition.displayName ?? this.definition.name; + + // Seed session with prior A2A conversation state + const priorState = RemoteSessionInvocation.sessionState.get( + this.definition.name, + ); + const session = new RemoteSubagentSession( + this.definition, + this.context, + this.messageBus, + priorState, + ); + + // Wire external abort signal to session abort + const abortListener = () => void session.abort(); + _signal.addEventListener('abort', abortListener, { once: true }); + + // Subscribe for parent session observability + let unsubscribeParent: (() => void) | undefined; + if (this._onAgentEvent) { + unsubscribeParent = session.subscribe(this._onAgentEvent); + } + + // Subscribe to message events for live SubagentProgress updates + const unsubscribeProgress = session.subscribe((event: AgentEvent) => { + if (event.type === 'message' && updateOutput) { + const currentProgress = session.getLatestProgress(); + if (currentProgress) updateOutput(currentProgress); + } + }); + + try { + if (updateOutput) { + updateOutput({ + isSubagentProgress: true, + agentName, + state: 'running', + recentActivity: [ + { + id: 'pending', + type: 'thought', + content: 'Working...', + status: 'running', + }, + ], + }); + } + + await session.send({ + message: { content: [{ type: 'text', text: this.params.query }] }, + }); + + const result = await session.getResult(); + + // The protocol resolves aborts with an empty result rather than + // rejecting. Detect this and surface proper error state. + if (_signal.aborted) { + const partialProgress = session.getLatestProgress(); + const errorProgress: SubagentProgress = { + isSubagentProgress: true, + agentName, + state: 'error', + result: + typeof partialProgress?.result === 'string' + ? partialProgress.result + : '', + recentActivity: partialProgress?.recentActivity ?? [], + }; + if (updateOutput) updateOutput(errorProgress); + return { + llmContent: [{ text: 'Operation cancelled by user' }], + returnDisplay: errorProgress, + }; + } + + // Emit final completed progress + if (updateOutput) { + const finalProgress = session.getLatestProgress(); + if (finalProgress) updateOutput(finalProgress); + } + + return result; + } catch (error: unknown) { + const partialProgress = session.getLatestProgress(); + const partialOutput = + typeof partialProgress?.result === 'string' + ? partialProgress.result + : ''; + const errorMessage = this.formatExecutionError(error); + const fullDisplay = partialOutput + ? `${partialOutput}\n\n${errorMessage}` + : errorMessage; + + const errorProgress: SubagentProgress = { + isSubagentProgress: true, + agentName, + state: 'error', + result: fullDisplay, + recentActivity: partialProgress?.recentActivity ?? [], + }; + + if (updateOutput) { + updateOutput(errorProgress); + } + + return { + llmContent: [{ text: fullDisplay }], + returnDisplay: errorProgress, + }; + } finally { + // Persist A2A state for next invocation — even on abort/error + RemoteSessionInvocation.sessionState.set( + this.definition.name, + session.getSessionState(), + ); + _signal.removeEventListener('abort', abortListener); + unsubscribeProgress(); + unsubscribeParent?.(); + } + } + + /** + * Formats an execution error into a user-friendly message. + * Recognizes typed A2AAgentError subclasses and falls back to + * a generic message for unknown errors. + */ + private formatExecutionError(error: unknown): string { + if (error instanceof A2AAgentError) { + return error.userMessage; + } + + return `Error calling remote agent: ${ + error instanceof Error ? error.message : String(error) + }`; + } +} diff --git a/packages/core/src/agents/remote-subagent-protocol.ts b/packages/core/src/agents/remote-subagent-protocol.ts index 1231b0f068d..379d2948b9c 100644 --- a/packages/core/src/agents/remote-subagent-protocol.ts +++ b/packages/core/src/agents/remote-subagent-protocol.ts @@ -82,8 +82,21 @@ class RemoteSubagentProtocol implements AgentProtocol { private readonly context: AgentLoopContext, // Required for API parity across protocol constructors (local, remote, legacy) _messageBus: MessageBus, + initialState?: { contextId?: string; taskId?: string }, ) { this._agentName = definition.displayName ?? definition.name; + if (initialState) { + this.contextId = initialState.contextId; + this.taskId = initialState.taskId; + } + } + + /** + * Returns the current A2A conversation state. + * Used by the invocation layer to persist state across invocations. + */ + getSessionState(): { contextId?: string; taskId?: string } { + return { contextId: this.contextId, taskId: this.taskId }; } // --------------------------------------------------------------------------- @@ -394,11 +407,13 @@ export class RemoteSubagentSession extends AgentSession { definition: RemoteAgentDefinition, context: AgentLoopContext, messageBus: MessageBus, + initialState?: { contextId?: string; taskId?: string }, ) { const protocol = new RemoteSubagentProtocol( definition, context, messageBus, + initialState, ); super(protocol); this._remoteProtocol = protocol; @@ -420,6 +435,14 @@ export class RemoteSubagentSession extends AgentSession { return this._remoteProtocol.getLatestProgress(); } + /** + * Returns the current A2A conversation state (contextId/taskId). + * Used by the invocation layer to persist state across invocations. + */ + getSessionState(): { contextId?: string; taskId?: string } { + return this._remoteProtocol.getSessionState(); + } + /** * Convenience: start execution with a query string. * Equivalent to send({message: {content: [{type:'text', text: query}]}}). From f43c2ba24eba28bef406be24a36fc20b98d9e36a Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Tue, 12 May 2026 14:35:28 -0400 Subject: [PATCH 08/14] refactor(core): use SubagentState enum in RemoteSessionInvocation --- packages/core/src/agents/remote-session-invocation.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/core/src/agents/remote-session-invocation.ts b/packages/core/src/agents/remote-session-invocation.ts index bf9d557ea60..77765cfd7d3 100644 --- a/packages/core/src/agents/remote-session-invocation.ts +++ b/packages/core/src/agents/remote-session-invocation.ts @@ -17,6 +17,7 @@ import { type RemoteAgentDefinition, type AgentInputs, type SubagentProgress, + SubagentState, } from './types.js'; import { type AgentLoopContext } from '../config/agent-loop-context.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; @@ -139,13 +140,13 @@ export class RemoteSessionInvocation extends BaseToolInvocation< updateOutput({ isSubagentProgress: true, agentName, - state: 'running', + state: SubagentState.RUNNING, recentActivity: [ { id: 'pending', type: 'thought', content: 'Working...', - status: 'running', + status: SubagentState.RUNNING, }, ], }); @@ -164,7 +165,7 @@ export class RemoteSessionInvocation extends BaseToolInvocation< const errorProgress: SubagentProgress = { isSubagentProgress: true, agentName, - state: 'error', + state: SubagentState.ERROR, result: typeof partialProgress?.result === 'string' ? partialProgress.result @@ -199,7 +200,7 @@ export class RemoteSessionInvocation extends BaseToolInvocation< const errorProgress: SubagentProgress = { isSubagentProgress: true, agentName, - state: 'error', + state: SubagentState.ERROR, result: fullDisplay, recentActivity: partialProgress?.recentActivity ?? [], }; From deeb517f6acb51cd7c007fe5633d73702f8a1a15 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Tue, 12 May 2026 17:28:56 -0400 Subject: [PATCH 09/14] feat(core): use composite key for remote session state --- .../src/agents/remote-session-invocation.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/core/src/agents/remote-session-invocation.ts b/packages/core/src/agents/remote-session-invocation.ts index 77765cfd7d3..d15e39bb6ef 100644 --- a/packages/core/src/agents/remote-session-invocation.ts +++ b/packages/core/src/agents/remote-session-invocation.ts @@ -18,6 +18,7 @@ import { type AgentInputs, type SubagentProgress, SubagentState, + getRemoteAgentTargetUrl, } from './types.js'; import { type AgentLoopContext } from '../config/agent-loop-context.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; @@ -53,6 +54,15 @@ export class RemoteSessionInvocation extends BaseToolInvocation< { contextId?: string; taskId?: string } >(); + /** + * Builds a composite key for the sessionState map. + * Format: `name::targetUrl` (or just `name` if no URL can be derived). + */ + private static sessionKey(definition: RemoteAgentDefinition): string { + const url = getRemoteAgentTargetUrl(definition); + return url ? `${definition.name}::${url}` : definition.name; + } + private readonly _onAgentEvent?: (event: AgentEvent) => void; constructor( @@ -107,9 +117,8 @@ export class RemoteSessionInvocation extends BaseToolInvocation< const agentName = this.definition.displayName ?? this.definition.name; // Seed session with prior A2A conversation state - const priorState = RemoteSessionInvocation.sessionState.get( - this.definition.name, - ); + const stateKey = RemoteSessionInvocation.sessionKey(this.definition); + const priorState = RemoteSessionInvocation.sessionState.get(stateKey); const session = new RemoteSubagentSession( this.definition, this.context, @@ -216,7 +225,7 @@ export class RemoteSessionInvocation extends BaseToolInvocation< } finally { // Persist A2A state for next invocation — even on abort/error RemoteSessionInvocation.sessionState.set( - this.definition.name, + stateKey, session.getSessionState(), ); _signal.removeEventListener('abort', abortListener); From 230bd4b9dea38429739d10585c3b79c53821d79c Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Tue, 12 May 2026 17:31:25 -0400 Subject: [PATCH 10/14] docs(core): update comments for composite session key in RemoteSessionInvocation --- packages/core/src/agents/remote-session-invocation.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/core/src/agents/remote-session-invocation.ts b/packages/core/src/agents/remote-session-invocation.ts index d15e39bb6ef..94c40320c9b 100644 --- a/packages/core/src/agents/remote-session-invocation.ts +++ b/packages/core/src/agents/remote-session-invocation.ts @@ -40,15 +40,16 @@ export interface SubagentInvocationOptions { * which wraps the A2A client streaming behind the AgentProtocol interface. * * Cross-invocation A2A session state (contextId/taskId) is persisted via a - * static map keyed by agent name, matching the original RemoteAgentInvocation - * behavior. + * static map keyed by a composite of agent name and target URL. This ensures + * agents with the same name but different endpoints maintain independent state. */ export class RemoteSessionInvocation extends BaseToolInvocation< RemoteAgentInputs, ToolResult > { // Persist A2A conversation state across ephemeral invocation instances. - // Keyed by agent name — each remote agent maintains independent state. + // Keyed by composite of name + target URL so agents with the same name + // but different endpoints don't share state. private static readonly sessionState = new Map< string, { contextId?: string; taskId?: string } From c8a229f10a3f9d3d59dc727c0b41cc7ad5ae8842 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Tue, 12 May 2026 17:50:36 -0400 Subject: [PATCH 11/14] test(core): add tests for composite session key in RemoteSessionInvocation --- .../agents/remote-session-invocation.test.ts | 89 +++++++++++++++++-- 1 file changed, 82 insertions(+), 7 deletions(-) diff --git a/packages/core/src/agents/remote-session-invocation.test.ts b/packages/core/src/agents/remote-session-invocation.test.ts index f096d72a89c..1777e5e62f2 100644 --- a/packages/core/src/agents/remote-session-invocation.test.ts +++ b/packages/core/src/agents/remote-session-invocation.test.ts @@ -216,7 +216,7 @@ describe('RemoteSessionInvocation', () => { RemoteSessionInvocation as unknown as { sessionState: Map; } - ).sessionState.set('test-agent', priorState); + ).sessionState.set('test-agent::http://test-agent/card', priorState); setupMockSession(); @@ -258,7 +258,7 @@ describe('RemoteSessionInvocation', () => { RemoteSessionInvocation as unknown as { sessionState: Map; } - ).sessionState.get('test-agent'); + ).sessionState.get('test-agent::http://test-agent/card'); expect(storedState).toEqual(newState); }); @@ -496,11 +496,12 @@ describe('RemoteSessionInvocation', () => { // --------------------------------------------------------------------------- describe('SessionState Management', () => { - it('should use definition.name as session state key', async () => { + it('should use composite name::url as session state key', async () => { const secondDefinition: RemoteAgentDefinition = { ...mockDefinition, name: 'other-agent', displayName: 'Other Agent', + agentCardUrl: 'http://other-agent/card', }; // First agent @@ -533,9 +534,81 @@ describe('RemoteSessionInvocation', () => { } ).sessionState; - // Each agent should have its own entry - expect(stateMap.get('test-agent')).toEqual({ contextId: 'ctx-a' }); - expect(stateMap.get('other-agent')).toEqual({ contextId: 'ctx-b' }); + // Each agent should have its own entry keyed by name::url + expect(stateMap.get('test-agent::http://test-agent/card')).toEqual({ + contextId: 'ctx-a', + }); + expect(stateMap.get('other-agent::http://other-agent/card')).toEqual({ + contextId: 'ctx-b', + }); + }); + + it('should isolate same-name agents with different URLs', async () => { + const defA: RemoteAgentDefinition = { + ...mockDefinition, + agentCardUrl: 'http://host-a/card', + }; + const defB: RemoteAgentDefinition = { + ...mockDefinition, + agentCardUrl: 'http://host-b/card', + }; + + // Agent A + setupMockSession({ sessionState: { contextId: 'ctx-a' } }); + const invA = new RemoteSessionInvocation( + defA, + mockContext, + { query: 'hi' }, + mockMessageBus, + ); + await invA.execute({ abortSignal: new AbortController().signal }); + + // Agent B (same name, different URL) + setupMockSession({ sessionState: { contextId: 'ctx-b' } }); + const invB = new RemoteSessionInvocation( + defB, + mockContext, + { query: 'hi' }, + mockMessageBus, + ); + await invB.execute({ abortSignal: new AbortController().signal }); + + const stateMap = ( + RemoteSessionInvocation as unknown as { + sessionState: Map; + } + ).sessionState; + + expect(stateMap.get('test-agent::http://host-a/card')).toEqual({ + contextId: 'ctx-a', + }); + expect(stateMap.get('test-agent::http://host-b/card')).toEqual({ + contextId: 'ctx-b', + }); + }); + + it('should fall back to name-only key when URL is unavailable', async () => { + const noUrlDef: RemoteAgentDefinition = { + ...mockDefinition, + agentCardUrl: undefined, + }; + + setupMockSession({ sessionState: { contextId: 'ctx-no-url' } }); + const inv = new RemoteSessionInvocation( + noUrlDef, + mockContext, + { query: 'hi' }, + mockMessageBus, + ); + await inv.execute({ abortSignal: new AbortController().signal }); + + const stateMap = ( + RemoteSessionInvocation as unknown as { + sessionState: Map; + } + ).sessionState; + + expect(stateMap.get('test-agent')).toEqual({ contextId: 'ctx-no-url' }); }); it('should persist state even on error', async () => { @@ -562,7 +635,9 @@ describe('RemoteSessionInvocation', () => { } ).sessionState; - expect(stateMap.get('test-agent')).toEqual(stateOnError); + expect(stateMap.get('test-agent::http://test-agent/card')).toEqual( + stateOnError, + ); }); }); }); From 0db03ff8321039bc423b3ec9613da24777fc1b19 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Tue, 12 May 2026 23:49:49 -0400 Subject: [PATCH 12/14] fix(core): use SubagentState enum in remote session invocation tests --- .../src/agents/remote-session-invocation.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/core/src/agents/remote-session-invocation.test.ts b/packages/core/src/agents/remote-session-invocation.test.ts index 1777e5e62f2..d611dd7399c 100644 --- a/packages/core/src/agents/remote-session-invocation.test.ts +++ b/packages/core/src/agents/remote-session-invocation.test.ts @@ -7,7 +7,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { RemoteSessionInvocation } from './remote-session-invocation.js'; import { RemoteSubagentSession } from './remote-subagent-protocol.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 type { AgentLoopContext } from '../config/agent-loop-context.js'; import type { Config } from '../config/config.js'; @@ -41,7 +45,7 @@ function setupMockSession(options: MockSessionSetupOptions = {}) { returnDisplay: { isSubagentProgress: true, agentName: 'Test Agent', - state: 'completed', + state: SubagentState.COMPLETED, result: 'done', recentActivity: [], } satisfies SubagentProgress, @@ -179,7 +183,7 @@ describe('RemoteSessionInvocation', () => { const completedProgress: SubagentProgress = { isSubagentProgress: true, agentName: 'Test Agent', - state: 'completed', + state: SubagentState.COMPLETED, result: 'Agent output', recentActivity: [], }; @@ -300,7 +304,7 @@ describe('RemoteSessionInvocation', () => { const completedProgress: SubagentProgress = { isSubagentProgress: true, agentName: 'Test Agent', - state: 'running', + state: SubagentState.RUNNING, result: 'partial', recentActivity: [], }; @@ -416,14 +420,14 @@ describe('RemoteSessionInvocation', () => { const partialProgress: SubagentProgress = { isSubagentProgress: true, agentName: 'Test Agent', - state: 'running', + state: SubagentState.RUNNING, result: 'Partial work so far', recentActivity: [ { id: 'a1', type: 'thought', content: 'Thinking...', - status: 'running', + status: SubagentState.RUNNING, }, ], }; From 0851c02b721fbdeb3a844f913a0e9db4de8619f2 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Wed, 13 May 2026 13:45:49 -0400 Subject: [PATCH 13/14] fix(core): add optional chaining for optional abortSignal in RemoteSessionInvocation --- packages/core/src/agents/remote-session-invocation.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/agents/remote-session-invocation.ts b/packages/core/src/agents/remote-session-invocation.ts index 94c40320c9b..2434f6fc303 100644 --- a/packages/core/src/agents/remote-session-invocation.ts +++ b/packages/core/src/agents/remote-session-invocation.ts @@ -129,7 +129,7 @@ export class RemoteSessionInvocation extends BaseToolInvocation< // Wire external abort signal to session abort const abortListener = () => void session.abort(); - _signal.addEventListener('abort', abortListener, { once: true }); + _signal?.addEventListener('abort', abortListener, { once: true }); // Subscribe for parent session observability let unsubscribeParent: (() => void) | undefined; @@ -170,7 +170,7 @@ export class RemoteSessionInvocation extends BaseToolInvocation< // The protocol resolves aborts with an empty result rather than // rejecting. Detect this and surface proper error state. - if (_signal.aborted) { + if (_signal?.aborted) { const partialProgress = session.getLatestProgress(); const errorProgress: SubagentProgress = { isSubagentProgress: true, @@ -229,7 +229,7 @@ export class RemoteSessionInvocation extends BaseToolInvocation< stateKey, session.getSessionState(), ); - _signal.removeEventListener('abort', abortListener); + _signal?.removeEventListener('abort', abortListener); unsubscribeProgress(); unsubscribeParent?.(); } From 14f14f58f0c3e4cf6296c99320da399626a7d666 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Mon, 18 May 2026 11:11:10 -0400 Subject: [PATCH 14/14] fix(core): stop spinners for running activities on remote session error/abort --- .../agents/remote-session-invocation.test.ts | 27 +++++++++++-- .../src/agents/remote-session-invocation.ts | 39 ++++++++++++++++--- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/packages/core/src/agents/remote-session-invocation.test.ts b/packages/core/src/agents/remote-session-invocation.test.ts index d611dd7399c..555af74ad03 100644 --- a/packages/core/src/agents/remote-session-invocation.test.ts +++ b/packages/core/src/agents/remote-session-invocation.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -354,7 +354,22 @@ describe('RemoteSessionInvocation', () => { it('should handle abort gracefully', async () => { const controller = new AbortController(); - const { mockSession } = setupMockSession(); + const partialProgress: SubagentProgress = { + isSubagentProgress: true, + agentName: 'Test Agent', + state: SubagentState.RUNNING, + result: '', + recentActivity: [ + { + id: 'a1', + type: 'thought', + content: 'Thinking...', + status: SubagentState.RUNNING, + }, + ], + }; + + const { mockSession } = setupMockSession({ progress: partialProgress }); // When getResult resolves, the signal will already be aborted mockSession.getResult.mockImplementation(async () => { @@ -378,7 +393,10 @@ describe('RemoteSessionInvocation', () => { updateOutput, }); - expect(result.returnDisplay).toMatchObject({ state: 'error' }); + expect(result.returnDisplay).toMatchObject({ state: 'cancelled' }); + expect( + (result.returnDisplay as SubagentProgress).recentActivity[0].status, + ).toBe(SubagentState.CANCELLED); expect(result.llmContent).toEqual([ { text: 'Operation cancelled by user' }, ]); @@ -452,9 +470,10 @@ describe('RemoteSessionInvocation', () => { // Should contain both the partial output and the error expect(display.result).toContain('Partial work so far'); expect(display.result).toContain('mid-stream error'); - // Should preserve partial activity + // Should preserve and update partial activity status to ERROR expect(display.recentActivity).toHaveLength(1); expect(display.recentActivity[0].content).toBe('Thinking...'); + expect(display.recentActivity[0].status).toBe(SubagentState.ERROR); }); it('should clean up listeners in finally', async () => { diff --git a/packages/core/src/agents/remote-session-invocation.ts b/packages/core/src/agents/remote-session-invocation.ts index 2434f6fc303..6ff32ba41a7 100644 --- a/packages/core/src/agents/remote-session-invocation.ts +++ b/packages/core/src/agents/remote-session-invocation.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -17,6 +17,7 @@ import { type RemoteAgentDefinition, type AgentInputs, type SubagentProgress, + type SubagentActivityItem, SubagentState, getRemoteAgentTargetUrl, } from './types.js'; @@ -116,6 +117,7 @@ export class RemoteSessionInvocation extends BaseToolInvocation< async execute(options: ExecuteOptions): Promise { const { abortSignal: _signal, updateOutput } = options; const agentName = this.definition.displayName ?? this.definition.name; + const emptyActivity: SubagentActivityItem[] = []; // Seed session with prior A2A conversation state const stateKey = RemoteSessionInvocation.sessionKey(this.definition); @@ -172,15 +174,19 @@ export class RemoteSessionInvocation extends BaseToolInvocation< // rejecting. Detect this and surface proper error state. if (_signal?.aborted) { const partialProgress = session.getLatestProgress(); + const recentActivity = this.stopRunningActivities( + partialProgress?.recentActivity ?? emptyActivity, + SubagentState.CANCELLED, + ); const errorProgress: SubagentProgress = { isSubagentProgress: true, agentName, - state: SubagentState.ERROR, + state: SubagentState.CANCELLED, result: typeof partialProgress?.result === 'string' ? partialProgress.result : '', - recentActivity: partialProgress?.recentActivity ?? [], + recentActivity, }; if (updateOutput) updateOutput(errorProgress); return { @@ -207,12 +213,22 @@ export class RemoteSessionInvocation extends BaseToolInvocation< ? `${partialOutput}\n\n${errorMessage}` : errorMessage; + const isAbort = + (error instanceof Error && error.name === 'AbortError') || + errorMessage.includes('Aborted'); + + const status = isAbort ? SubagentState.CANCELLED : SubagentState.ERROR; + const recentActivity = this.stopRunningActivities( + partialProgress?.recentActivity ?? emptyActivity, + status, + ); + const errorProgress: SubagentProgress = { isSubagentProgress: true, agentName, - state: SubagentState.ERROR, + state: status, result: fullDisplay, - recentActivity: partialProgress?.recentActivity ?? [], + recentActivity, }; if (updateOutput) { @@ -235,6 +251,19 @@ export class RemoteSessionInvocation extends BaseToolInvocation< } } + private stopRunningActivities( + activity: SubagentActivityItem[], + status: SubagentState, + ): SubagentActivityItem[] { + const result: SubagentActivityItem[] = []; + for (const item of activity) { + result.push( + item.status === SubagentState.RUNNING ? { ...item, status } : item, + ); + } + return result; + } + /** * Formats an execution error into a user-friendly message. * Recognizes typed A2AAgentError subclasses and falls back to