Skip to content
Merged
6 changes: 2 additions & 4 deletions packages/core/src/agents/a2a-client-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ describe('A2AClientManager', () => {
expect(call.message.taskId).toBe(expectedTaskId);
});

it('should throw prefixed error on failure', async () => {
it('should propagate the original error on failure', async () => {
sendMessageStreamMock.mockImplementationOnce(() => {
throw new Error('Network error');
});
Expand All @@ -252,9 +252,7 @@ describe('A2AClientManager', () => {
for await (const _ of stream) {
// consume
}
}).rejects.toThrow(
'[A2AClientManager] sendMessageStream Error [TestAgent]: Network error',
);
}).rejects.toThrow('Network error');
});

it('should throw an error if the agent is not found', async () => {
Expand Down
39 changes: 17 additions & 22 deletions packages/core/src/agents/a2a-client-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import { v4 as uuidv4 } from 'uuid';
import { Agent as UndiciAgent } from 'undici';
import { debugLogger } from '../utils/debugLogger.js';
import { classifyAgentError } from './a2a-errors.js';

// Remote agents can take 10+ minutes (e.g. Deep Research).
// Use a dedicated dispatcher so the global 5-min timeout isn't affected.
Expand Down Expand Up @@ -109,18 +110,22 @@ export class A2AClientManager {
},
);

const factory = new ClientFactory(options);
const client = await factory.createFromUrl(agentCardUrl, '');
const agentCard = await client.getAgentCard();
try {
const factory = new ClientFactory(options);
const client = await factory.createFromUrl(agentCardUrl, '');
const agentCard = await client.getAgentCard();

this.clients.set(name, client);
this.agentCards.set(name, agentCard);
this.clients.set(name, client);
this.agentCards.set(name, agentCard);

debugLogger.debug(
`[A2AClientManager] Loaded agent '${name}' from ${agentCardUrl}`,
);
debugLogger.debug(
`[A2AClientManager] Loaded agent '${name}' from ${agentCardUrl}`,
);

return agentCard;
return agentCard;
} catch (error: unknown) {
throw classifyAgentError(name, agentCardUrl, error);
}
}

/**
Expand Down Expand Up @@ -161,19 +166,9 @@ export class A2AClientManager {
},
};

try {
yield* client.sendMessageStream(messageParams, {
signal: options?.signal,
});
} catch (error: unknown) {
const prefix = `[A2AClientManager] sendMessageStream Error [${agentName}]`;
if (error instanceof Error) {
throw new Error(`${prefix}: ${error.message}`, { cause: error });
}
throw new Error(
`${prefix}: Unexpected error during sendMessageStream: ${String(error)}`,
);
}
yield* client.sendMessageStream(messageParams, {
signal: options?.signal,
});
}

/**
Expand Down
298 changes: 298 additions & 0 deletions packages/core/src/agents/a2a-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect } from 'vitest';
import {
A2AAgentError,
AgentCardNotFoundError,
AgentCardAuthError,
AgentAuthConfigMissingError,
AgentConnectionError,
classifyAgentError,
} from './a2a-errors.js';

describe('A2A Error Types', () => {
describe('A2AAgentError', () => {
it('should set name, agentName, and userMessage', () => {
const error = new A2AAgentError('my-agent', 'internal msg', 'user msg');
expect(error.name).toBe('A2AAgentError');
expect(error.agentName).toBe('my-agent');
expect(error.message).toBe('internal msg');
expect(error.userMessage).toBe('user msg');
});
});

describe('AgentCardNotFoundError', () => {
it('should produce a user-friendly 404 message', () => {
const error = new AgentCardNotFoundError(
'my-agent',
'https://example.com/card',
);
expect(error.name).toBe('AgentCardNotFoundError');
expect(error.agentName).toBe('my-agent');
expect(error.userMessage).toContain('404');
expect(error.userMessage).toContain('https://example.com/card');
expect(error.userMessage).toContain('agent_card_url');
});
});

describe('AgentCardAuthError', () => {
it('should produce a user-friendly 401 message', () => {
const error = new AgentCardAuthError(
'secure-agent',
'https://example.com/card',
401,
);
expect(error.name).toBe('AgentCardAuthError');
expect(error.statusCode).toBe(401);
expect(error.userMessage).toContain('401');
expect(error.userMessage).toContain('Unauthorized');
expect(error.userMessage).toContain('"auth" configuration');
});

it('should produce a user-friendly 403 message', () => {
const error = new AgentCardAuthError(
'secure-agent',
'https://example.com/card',
403,
);
expect(error.statusCode).toBe(403);
expect(error.userMessage).toContain('403');
expect(error.userMessage).toContain('Forbidden');
});
});

describe('AgentAuthConfigMissingError', () => {
it('should list missing config fields', () => {
const error = new AgentAuthConfigMissingError(
'api-agent',
'API Key (x-api-key): Send x-api-key in header',
[
'Authentication is required but not configured',
"Scheme 'api_key' requires apiKey authentication",
],
);
expect(error.name).toBe('AgentAuthConfigMissingError');
expect(error.requiredAuth).toContain('API Key');
expect(error.missingFields).toHaveLength(2);
expect(error.userMessage).toContain('API Key');
expect(error.userMessage).toContain('no auth is configured');
expect(error.userMessage).toContain('Missing:');
});
});

describe('AgentConnectionError', () => {
it('should wrap the original error cause', () => {
const cause = new Error('ECONNREFUSED');
const error = new AgentConnectionError(
'my-agent',
'https://example.com/card',
cause,
);
expect(error.name).toBe('AgentConnectionError');
expect(error.userMessage).toContain('ECONNREFUSED');
expect(error.userMessage).toContain('https://example.com/card');
});

it('should handle non-Error causes', () => {
const error = new AgentConnectionError(
'my-agent',
'https://example.com/card',
'raw string error',
);
expect(error.userMessage).toContain('raw string error');
});
});

describe('classifyAgentError', () => {
it('should classify a 404 error message', () => {
const raw = new Error('HTTP 404: Not Found');
const result = classifyAgentError(
'agent-a',
'https://example.com/card',
raw,
);
expect(result).toBeInstanceOf(AgentCardNotFoundError);
expect(result.agentName).toBe('agent-a');
});

it('should classify a "not found" error message (case-insensitive)', () => {
const raw = new Error('Agent card not found at the given URL');
const result = classifyAgentError(
'agent-a',
'https://example.com/card',
raw,
);
expect(result).toBeInstanceOf(AgentCardNotFoundError);
});

it('should classify a 401 error message', () => {
const raw = new Error('Request failed with status 401');
const result = classifyAgentError(
'agent-b',
'https://example.com/card',
raw,
);
expect(result).toBeInstanceOf(AgentCardAuthError);
expect((result as AgentCardAuthError).statusCode).toBe(401);
});

it('should classify an "unauthorized" error message', () => {
const raw = new Error('Unauthorized access to agent card');
const result = classifyAgentError(
'agent-b',
'https://example.com/card',
raw,
);
expect(result).toBeInstanceOf(AgentCardAuthError);
});

it('should classify a 403 error message', () => {
const raw = new Error('HTTP 403 Forbidden');
const result = classifyAgentError(
'agent-c',
'https://example.com/card',
raw,
);
expect(result).toBeInstanceOf(AgentCardAuthError);
expect((result as AgentCardAuthError).statusCode).toBe(403);
});

it('should fall back to AgentConnectionError for unknown errors', () => {
const raw = new Error('Something completely unexpected');
const result = classifyAgentError(
'agent-d',
'https://example.com/card',
raw,
);
expect(result).toBeInstanceOf(AgentConnectionError);
});

it('should classify ECONNREFUSED as AgentConnectionError', () => {
const raw = new Error('ECONNREFUSED 127.0.0.1:8080');
const result = classifyAgentError(
'agent-d',
'https://example.com/card',
raw,
);
expect(result).toBeInstanceOf(AgentConnectionError);
});

it('should handle non-Error values', () => {
const result = classifyAgentError(
'agent-e',
'https://example.com/card',
'some string error',
);
expect(result).toBeInstanceOf(AgentConnectionError);
});

describe('cause chain inspection', () => {
it('should detect 404 in a nested cause', () => {
const inner = new Error('HTTP 404 Not Found');
const outer = new Error('fetch failed', { cause: inner });
const result = classifyAgentError(
'agent-nested',
'https://example.com/card',
outer,
);
expect(result).toBeInstanceOf(AgentCardNotFoundError);
});

it('should detect 401 in a deeply nested cause', () => {
const innermost = new Error('Server returned 401');
const middle = new Error('Request error', { cause: innermost });
const outer = new Error('fetch failed', { cause: middle });
const result = classifyAgentError(
'agent-deep',
'https://example.com/card',
outer,
);
expect(result).toBeInstanceOf(AgentCardAuthError);
expect((result as AgentCardAuthError).statusCode).toBe(401);
});

it('should detect ECONNREFUSED error code in cause chain', () => {
const inner = Object.assign(new Error('connect failed'), {
code: 'ECONNREFUSED',
});
const outer = new Error('fetch failed', { cause: inner });
const result = classifyAgentError(
'agent-conn',
'https://example.com/card',
outer,
);
expect(result).toBeInstanceOf(AgentConnectionError);
});

it('should detect status property on error objects in cause chain', () => {
const inner = Object.assign(new Error('Bad response'), {
status: 403,
});
const outer = new Error('agent card resolution failed', {
cause: inner,
});
const result = classifyAgentError(
'agent-status',
'https://example.com/card',
outer,
);
expect(result).toBeInstanceOf(AgentCardAuthError);
expect((result as AgentCardAuthError).statusCode).toBe(403);
});

it('should detect status on a plain-object cause (non-Error)', () => {
const outer = new Error('fetch failed');
// Some HTTP libs set cause to a plain object, not an Error instance
(outer as unknown as { cause: unknown }).cause = {
message: 'Unauthorized',
status: 401,
};
const result = classifyAgentError(
'agent-plain-cause',
'https://example.com/card',
outer,
);
expect(result).toBeInstanceOf(AgentCardAuthError);
expect((result as AgentCardAuthError).statusCode).toBe(401);
});

it('should detect statusCode on a plain-object cause (non-Error)', () => {
const outer = new Error('fetch failed');
(outer as unknown as { cause: unknown }).cause = {
message: 'Forbidden',
statusCode: 403,
};
const result = classifyAgentError(
'agent-plain-cause-403',
'https://example.com/card',
outer,
);
expect(result).toBeInstanceOf(AgentCardAuthError);
expect((result as AgentCardAuthError).statusCode).toBe(403);
});

it('should classify ENOTFOUND as AgentConnectionError, not 404', () => {
// ENOTFOUND (DNS resolution failure) should NOT be misclassified
// as a 404 despite containing "NOTFOUND" in the error code.
const inner = Object.assign(
new Error('getaddrinfo ENOTFOUND example.invalid'),
{
code: 'ENOTFOUND',
},
);
const outer = new Error('fetch failed', { cause: inner });
const result = classifyAgentError(
'agent-dns',
'https://example.invalid/card',
outer,
);
expect(result).toBeInstanceOf(AgentConnectionError);
expect(result).not.toBeInstanceOf(AgentCardNotFoundError);
});
});
});
});
Loading