/** * @license * Copyright 2026 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, 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'; 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: SubagentState.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: SubagentState.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::http://test-agent/card', 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::http://test-agent/card'); 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: SubagentState.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 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 () => { 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: 'cancelled' }); expect( (result.returnDisplay as SubagentProgress).recentActivity[0].status, ).toBe(SubagentState.CANCELLED); 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: SubagentState.RUNNING, result: 'Partial work so far', recentActivity: [ { id: 'a1', type: 'thought', content: 'Thinking...', status: SubagentState.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 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 () => { 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 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 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 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 () => { 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::http://test-agent/card')).toEqual( stateOnError, ); }); }); });