Download packages/core/src/agents/remote-session-invocation.test.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 20 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/agents/remote-session-invocation.test.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/core/src/agents/remote-session-invocation.test.ts
-
curl -L -o remote-session-invocation.test.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/agents/remote-session-invocation.test.ts
20 kB
| /** | |
| * @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<string, unknown>; | |
| } | |
| ).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<string, unknown>; | |
| } | |
| ).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<string, { contextId?: string; taskId?: string }>; | |
| } | |
| ).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<string, { contextId?: string; taskId?: string }>; | |
| } | |
| ).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<string, { contextId?: string; taskId?: string }>; | |
| } | |
| ).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<string, { contextId?: string; taskId?: string }>; | |
| } | |
| ).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<string, { contextId?: string; taskId?: string }>; | |
| } | |
| ).sessionState; | |
| expect(stateMap.get('test-agent::http://test-agent/card')).toEqual( | |
| stateOnError, | |
| ); | |
| }); | |
| }); | |
| }); | |