/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { A2AClientManager } from './a2a-client-manager.js'; import type { AgentCard } from '@a2a-js/sdk'; import { ClientFactory, DefaultAgentCardResolver, createAuthenticatingFetchWithRetry, ClientFactoryOptions, type AuthenticationHandler, type Client, } from '@a2a-js/sdk/client'; import type { Config } from '../config/config.js'; import { Agent as UndiciAgent, ProxyAgent } from 'undici'; import { debugLogger } from '../utils/debugLogger.js'; interface MockClient { sendMessageStream: ReturnType; getTask: ReturnType; cancelTask: ReturnType; } vi.mock('@a2a-js/sdk/client', async (importOriginal) => { const actual = await importOriginal(); return { ...(actual as Record), createAuthenticatingFetchWithRetry: vi.fn(), ClientFactory: vi.fn(), DefaultAgentCardResolver: vi.fn(), ClientFactoryOptions: { createFrom: vi.fn(), default: {}, }, }; }); vi.mock('../utils/debugLogger.js', () => ({ debugLogger: { debug: vi.fn(), }, })); describe('A2AClientManager', () => { let manager: A2AClientManager; const mockAgentCard: AgentCard = { name: 'test-agent', description: 'A test agent', url: 'http://test.agent', version: '1.0.0', protocolVersion: '0.1.0', capabilities: {}, skills: [], defaultInputModes: [], defaultOutputModes: [], }; const mockClient: MockClient = { sendMessageStream: vi.fn(), getTask: vi.fn(), cancelTask: vi.fn(), }; const authFetchMock = vi.fn(); const mockConfig = { getProxy: vi.fn(), } as unknown as Config; beforeEach(() => { vi.clearAllMocks(); manager = new A2AClientManager(mockConfig); // Re-create the instances as plain objects that can be spied on const factoryInstance = { createFromUrl: vi.fn(), createFromAgentCard: vi.fn(), }; const resolverInstance = { resolve: vi.fn(), }; vi.mocked(ClientFactory).mockReturnValue( factoryInstance as unknown as ClientFactory, ); vi.mocked(DefaultAgentCardResolver).mockReturnValue( resolverInstance as unknown as DefaultAgentCardResolver, ); vi.spyOn(factoryInstance, 'createFromUrl').mockResolvedValue( mockClient as unknown as Client, ); vi.spyOn(factoryInstance, 'createFromAgentCard').mockResolvedValue( mockClient as unknown as Client, ); vi.spyOn(resolverInstance, 'resolve').mockResolvedValue({ ...mockAgentCard, url: 'http://test.agent/real/endpoint', } as AgentCard); vi.spyOn(ClientFactoryOptions, 'createFrom').mockImplementation( (_defaults, overrides) => overrides as unknown as ClientFactoryOptions, ); vi.mocked(createAuthenticatingFetchWithRetry).mockImplementation(() => authFetchMock.mockResolvedValue({ ok: true, json: async () => ({}), } as Response), ); vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}), } as Response), ); }); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe('getInstance / dispatcher initialization', () => { it('should use UndiciAgent when no proxy is configured', async () => { await manager.loadAgent('TestAgent', { type: 'url', url: 'http://test.agent/card', }); const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock .calls[0][0]; const cardFetch = resolverOptions?.fetchImpl as typeof fetch; await cardFetch('http://test.agent/card'); const fetchCall = vi .mocked(fetch) .mock.calls.find((call) => call[0] === 'http://test.agent/card'); expect(fetchCall).toBeDefined(); expect( (fetchCall![1] as { dispatcher?: unknown })?.dispatcher, ).toBeInstanceOf(UndiciAgent); expect( (fetchCall![1] as { dispatcher?: unknown })?.dispatcher, ).not.toBeInstanceOf(ProxyAgent); }); it('should use ProxyAgent when a proxy is configured via Config', async () => { const mockConfigWithProxy = { getProxy: () => 'http://my-proxy:8080', } as Config; manager = new A2AClientManager(mockConfigWithProxy); await manager.loadAgent('TestProxyAgent', { type: 'url', url: 'http://test.proxy.agent/card', }); const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock .calls[0][0]; const cardFetch = resolverOptions?.fetchImpl as typeof fetch; await cardFetch('http://test.proxy.agent/card'); const fetchCall = vi .mocked(fetch) .mock.calls.find((call) => call[0] === 'http://test.proxy.agent/card'); expect(fetchCall).toBeDefined(); expect( (fetchCall![1] as { dispatcher?: unknown })?.dispatcher, ).toBeInstanceOf(ProxyAgent); }); }); describe('loadAgent', () => { it('should create and cache an A2AClient', async () => { const agentCard = await manager.loadAgent('TestAgent', { type: 'url', url: 'http://test.agent/card', }); expect(manager.getAgentCard('TestAgent')).toBe(agentCard); expect(manager.getClient('TestAgent')).toBeDefined(); }); it('should configure ClientFactory with REST, JSON-RPC, and gRPC transports', async () => { await manager.loadAgent('TestAgent', { type: 'url', url: 'http://test.agent/card', }); expect(ClientFactoryOptions.createFrom).toHaveBeenCalled(); }); it('should throw an error if an agent with the same name is already loaded', async () => { await manager.loadAgent('TestAgent', { type: 'url', url: 'http://test.agent/card', }); await expect( manager.loadAgent('TestAgent', { type: 'url', url: 'http://test.agent/card', }), ).rejects.toThrow("Agent with name 'TestAgent' is already loaded."); }); it('should use native fetch by default', async () => { await manager.loadAgent('TestAgent', { type: 'url', url: 'http://test.agent/card', }); expect(createAuthenticatingFetchWithRetry).not.toHaveBeenCalled(); }); it('should use provided custom authentication handler for transports only', async () => { const customAuthHandler = { headers: vi.fn(), shouldRetryWithHeaders: vi.fn(), }; await manager.loadAgent( 'TestAgent', { type: 'url', url: 'http://test.agent/card' }, customAuthHandler as unknown as AuthenticationHandler, ); // Card resolver should NOT use the authenticated fetch by default. const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock .calls[0][0]; expect(resolverOptions?.fetchImpl).not.toBe(authFetchMock); }); it('should use unauthenticated fetch for card resolver and avoid authenticated fetch if success', async () => { const customAuthHandler = { headers: vi.fn(), shouldRetryWithHeaders: vi.fn(), }; await manager.loadAgent( 'AuthCardAgent', { type: 'url', url: 'http://authcard.agent/card' }, customAuthHandler as unknown as AuthenticationHandler, ); const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock .calls[0][0]; const cardFetch = resolverOptions?.fetchImpl as typeof fetch; expect(cardFetch).toBeDefined(); await cardFetch('http://test.url'); expect(fetch).toHaveBeenCalledWith('http://test.url', expect.anything()); expect(authFetchMock).not.toHaveBeenCalled(); }); it('should retry with authenticating fetch if agent card fetch returns 401', async () => { const customAuthHandler = { headers: vi.fn(), shouldRetryWithHeaders: vi.fn(), }; // Mock the initial unauthenticated fetch to fail with 401 vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({}), } as Response); await manager.loadAgent( 'AuthCardAgent401', { type: 'url', url: 'http://authcard.agent/card' }, customAuthHandler as unknown as AuthenticationHandler, ); const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock .calls[0][0]; const cardFetch = resolverOptions?.fetchImpl as typeof fetch; await cardFetch('http://test.url'); expect(fetch).toHaveBeenCalledWith('http://test.url', expect.anything()); expect(authFetchMock).toHaveBeenCalledWith('http://test.url', undefined); }); it('should log a debug message upon loading an agent', async () => { await manager.loadAgent('TestAgent', { type: 'url', url: 'http://test.agent/card', }); expect(debugLogger.debug).toHaveBeenCalledWith( expect.stringContaining("Loaded agent 'TestAgent'"), ); }); it('should clear the cache', async () => { await manager.loadAgent('TestAgent', { type: 'url', url: 'http://test.agent/card', }); manager.clearCache(); expect(manager.getAgentCard('TestAgent')).toBeUndefined(); expect(manager.getClient('TestAgent')).toBeUndefined(); }); it('should load an agent from inline JSON without calling resolver', async () => { const inlineJson = JSON.stringify(mockAgentCard); const agentCard = await manager.loadAgent('JsonAgent', { type: 'json', json: inlineJson, }); expect(agentCard).toBeDefined(); expect(agentCard.name).toBe('test-agent'); expect(manager.getAgentCard('JsonAgent')).toBe(agentCard); expect(manager.getClient('JsonAgent')).toBeDefined(); // Resolver should not have been called for inline JSON const resolverInstance = vi.mocked(DefaultAgentCardResolver).mock .results[0]?.value; if (resolverInstance) { expect(resolverInstance.resolve).not.toHaveBeenCalled(); } }); it('should throw a descriptive error for invalid inline JSON', async () => { await expect( manager.loadAgent('BadJsonAgent', { type: 'json', json: 'not valid json {{', }), ).rejects.toThrow( /Failed to parse inline agent card JSON for agent 'BadJsonAgent'/, ); }); it('should log "inline JSON" for JSON-loaded agents', async () => { const inlineJson = JSON.stringify(mockAgentCard); await manager.loadAgent('JsonLogAgent', { type: 'json', json: inlineJson, }); expect(debugLogger.debug).toHaveBeenCalledWith( expect.stringContaining('inline JSON'), ); }); it('should throw if resolveAgentCard fails', async () => { const resolverInstance = { resolve: vi.fn().mockRejectedValue(new Error('Resolution failed')), }; vi.mocked(DefaultAgentCardResolver).mockReturnValue( resolverInstance as unknown as DefaultAgentCardResolver, ); await expect( manager.loadAgent('FailAgent', { type: 'url', url: 'http://fail.agent', }), ).rejects.toThrow('Resolution failed'); }); it('should throw if factory.createFromAgentCard fails', async () => { const factoryInstance = { createFromAgentCard: vi .fn() .mockRejectedValue(new Error('Factory failed')), }; vi.mocked(ClientFactory).mockReturnValue( factoryInstance as unknown as ClientFactory, ); await expect( manager.loadAgent('FailAgent', { type: 'url', url: 'http://fail.agent', }), ).rejects.toThrow('Factory failed'); }); }); describe('getAgentCard and getClient', () => { it('should return undefined if agent is not found', () => { expect(manager.getAgentCard('Unknown')).toBeUndefined(); expect(manager.getClient('Unknown')).toBeUndefined(); }); }); describe('sendMessageStream', () => { beforeEach(async () => { await manager.loadAgent('TestAgent', { type: 'url', url: 'http://test.agent/card', }); }); it('should send a message and return a stream', async () => { mockClient.sendMessageStream.mockReturnValue( (async function* () { yield { kind: 'message' }; })(), ); const stream = manager.sendMessageStream('TestAgent', 'Hello'); const results = []; for await (const result of stream) { results.push(result); } expect(results).toHaveLength(1); expect(mockClient.sendMessageStream).toHaveBeenCalled(); }); it('should use contextId and taskId when provided', async () => { mockClient.sendMessageStream.mockReturnValue( (async function* () { yield { kind: 'message' }; })(), ); const stream = manager.sendMessageStream('TestAgent', 'Hello', { contextId: 'ctx123', taskId: 'task456', }); // trigger execution for await (const _ of stream) { break; } expect(mockClient.sendMessageStream).toHaveBeenCalledWith( expect.objectContaining({ message: expect.objectContaining({ contextId: 'ctx123', taskId: 'task456', }), }), expect.any(Object), ); }); it('should correctly propagate AbortSignal to the stream', async () => { mockClient.sendMessageStream.mockReturnValue( (async function* () { yield { kind: 'message' }; })(), ); const controller = new AbortController(); const stream = manager.sendMessageStream('TestAgent', 'Hello', { signal: controller.signal, }); // trigger execution for await (const _ of stream) { break; } expect(mockClient.sendMessageStream).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ signal: controller.signal }), ); }); it('should handle a multi-chunk stream with different event types', async () => { mockClient.sendMessageStream.mockReturnValue( (async function* () { yield { kind: 'message', messageId: 'm1' }; yield { kind: 'status-update', taskId: 't1' }; })(), ); const stream = manager.sendMessageStream('TestAgent', 'Hello'); const results = []; for await (const result of stream) { results.push(result); } expect(results).toHaveLength(2); expect(results[0].kind).toBe('message'); expect(results[1].kind).toBe('status-update'); }); it('should throw prefixed error on failure', async () => { mockClient.sendMessageStream.mockImplementation(() => { throw new Error('Network failure'); }); const stream = manager.sendMessageStream('TestAgent', 'Hello'); await expect(async () => { for await (const _ of stream) { // empty } }).rejects.toThrow( '[A2AClientManager] sendMessageStream Error [TestAgent]: Network failure', ); }); it('should throw an error if the agent is not found', async () => { const stream = manager.sendMessageStream('NonExistentAgent', 'Hello'); await expect(async () => { for await (const _ of stream) { // empty } }).rejects.toThrow("Agent 'NonExistentAgent' not found."); }); }); describe('getTask', () => { beforeEach(async () => { await manager.loadAgent('TestAgent', { type: 'url', url: 'http://test.agent/card', }); }); it('should get a task from the correct agent', async () => { const mockTask = { id: 'task123', kind: 'task' }; mockClient.getTask.mockResolvedValue(mockTask); const result = await manager.getTask('TestAgent', 'task123'); expect(result).toBe(mockTask); expect(mockClient.getTask).toHaveBeenCalledWith({ id: 'task123' }); }); it('should throw prefixed error on failure', async () => { mockClient.getTask.mockRejectedValue(new Error('Not found')); await expect(manager.getTask('TestAgent', 'task123')).rejects.toThrow( 'A2AClient getTask Error [TestAgent]: Not found', ); }); it('should throw an error if the agent is not found', async () => { await expect( manager.getTask('NonExistentAgent', 'task123'), ).rejects.toThrow("Agent 'NonExistentAgent' not found."); }); }); describe('cancelTask', () => { beforeEach(async () => { await manager.loadAgent('TestAgent', { type: 'url', url: 'http://test.agent/card', }); }); it('should cancel a task on the correct agent', async () => { const mockTask = { id: 'task123', kind: 'task' }; mockClient.cancelTask.mockResolvedValue(mockTask); const result = await manager.cancelTask('TestAgent', 'task123'); expect(result).toBe(mockTask); expect(mockClient.cancelTask).toHaveBeenCalledWith({ id: 'task123' }); }); it('should throw prefixed error on failure', async () => { mockClient.cancelTask.mockRejectedValue(new Error('Cannot cancel')); await expect(manager.cancelTask('TestAgent', 'task123')).rejects.toThrow( 'A2AClient cancelTask Error [TestAgent]: Cannot cancel', ); }); it('should throw an error if the agent is not found', async () => { await expect( manager.cancelTask('NonExistentAgent', 'task123'), ).rejects.toThrow("Agent 'NonExistentAgent' not found."); }); }); });