Skip to content

Commit 2742c7c

Browse files
committed
feat(a2a): implement standardized normalization and streaming reassembly
1 parent ab64b15 commit 2742c7c

File tree

2 files changed

+607
-89
lines changed

2 files changed

+607
-89
lines changed

packages/core/src/agents/a2aUtils.test.ts

Lines changed: 269 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import { describe, it, expect } from 'vitest';
7+
import { describe, it, expect, vi, beforeEach } from 'vitest';
88
import {
99
extractMessageText,
1010
extractIdsFromResponse,
1111
isTerminalState,
1212
A2AResultReassembler,
1313
AUTH_REQUIRED_MSG,
14+
normalizeAgentCard,
15+
getGrpcCredentials,
16+
pinUrlToIp,
17+
splitAgentCardUrl,
1418
} from './a2aUtils.js';
1519
import type { SendMessageResult } from './a2a-client-manager.js';
1620
import type {
@@ -22,8 +26,92 @@ import type {
2226
TaskStatusUpdateEvent,
2327
TaskArtifactUpdateEvent,
2428
} from '@a2a-js/sdk';
29+
import * as dnsPromises from 'node:dns/promises';
30+
31+
vi.mock('node:dns/promises', () => ({
32+
lookup: vi.fn(),
33+
}));
2534

2635
describe('a2aUtils', () => {
36+
beforeEach(() => {
37+
vi.clearAllMocks();
38+
});
39+
40+
describe('getGrpcCredentials', () => {
41+
it('should return secure credentials for https', () => {
42+
const credentials = getGrpcCredentials('https://test.agent');
43+
expect(credentials).toBeDefined();
44+
});
45+
46+
it('should return insecure credentials for http', () => {
47+
const credentials = getGrpcCredentials('http://test.agent');
48+
expect(credentials).toBeDefined();
49+
});
50+
});
51+
52+
describe('pinUrlToIp', () => {
53+
it('should resolve and pin hostname to IP', async () => {
54+
vi.mocked(dnsPromises.lookup).mockResolvedValue([
55+
{ address: '93.184.216.34', family: 4 },
56+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
57+
] as any);
58+
59+
const { pinnedUrl, hostname } = await pinUrlToIp(
60+
'http://example.com:9000',
61+
'test-agent',
62+
);
63+
expect(hostname).toBe('example.com');
64+
expect(pinnedUrl).toBe('http://93.184.216.34:9000/');
65+
});
66+
67+
it('should handle raw host:port strings (standard for gRPC)', async () => {
68+
vi.mocked(dnsPromises.lookup).mockResolvedValue([
69+
{ address: '93.184.216.34', family: 4 },
70+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
71+
] as any);
72+
73+
const { pinnedUrl, hostname } = await pinUrlToIp(
74+
'example.com:9000',
75+
'test-agent',
76+
);
77+
expect(hostname).toBe('example.com');
78+
expect(pinnedUrl).toBe('93.184.216.34:9000');
79+
});
80+
81+
it('should throw error if resolution fails (fail closed)', async () => {
82+
vi.mocked(dnsPromises.lookup).mockRejectedValue(new Error('DNS Error'));
83+
84+
await expect(
85+
pinUrlToIp('http://unreachable.com', 'test-agent'),
86+
).rejects.toThrow("Failed to resolve host for agent 'test-agent'");
87+
});
88+
89+
it('should throw error if resolved to private IP', async () => {
90+
vi.mocked(dnsPromises.lookup).mockResolvedValue([
91+
{ address: '10.0.0.1', family: 4 },
92+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
93+
] as any);
94+
95+
await expect(
96+
pinUrlToIp('http://malicious.com', 'test-agent'),
97+
).rejects.toThrow('resolves to private IP range');
98+
});
99+
100+
it('should allow localhost/127.0.0.1/::1 exceptions', async () => {
101+
vi.mocked(dnsPromises.lookup).mockResolvedValue([
102+
{ address: '127.0.0.1', family: 4 },
103+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
104+
] as any);
105+
106+
const { pinnedUrl, hostname } = await pinUrlToIp(
107+
'http://localhost:9000',
108+
'test-agent',
109+
);
110+
expect(hostname).toBe('localhost');
111+
expect(pinnedUrl).toBe('http://127.0.0.1:9000/');
112+
});
113+
});
114+
27115
describe('isTerminalState', () => {
28116
it('should return true for completed, failed, canceled, and rejected', () => {
29117
expect(isTerminalState('completed')).toBe(true);
@@ -223,6 +311,173 @@ describe('a2aUtils', () => {
223311
} as Message),
224312
).toBe('');
225313
});
314+
315+
it('should handle file parts with neither name nor uri', () => {
316+
const message: Message = {
317+
kind: 'message',
318+
role: 'user',
319+
messageId: '1',
320+
parts: [
321+
{
322+
kind: 'file',
323+
file: {
324+
mimeType: 'text/plain',
325+
},
326+
} as FilePart,
327+
],
328+
};
329+
expect(extractMessageText(message)).toBe('File: [binary/unnamed]');
330+
});
331+
});
332+
333+
describe('normalizeAgentCard', () => {
334+
it('should throw if input is not an object', () => {
335+
expect(() => normalizeAgentCard(null)).toThrow('Agent card is missing.');
336+
expect(() => normalizeAgentCard(undefined)).toThrow(
337+
'Agent card is missing.',
338+
);
339+
expect(() => normalizeAgentCard('not an object')).toThrow(
340+
'Agent card is missing.',
341+
);
342+
});
343+
344+
it('should preserve unknown fields while providing defaults for mandatory ones', () => {
345+
const raw = {
346+
name: 'my-agent',
347+
customField: 'keep-me',
348+
};
349+
350+
const normalized = normalizeAgentCard(raw);
351+
352+
expect(normalized.name).toBe('my-agent');
353+
// @ts-expect-error - testing dynamic preservation
354+
expect(normalized.customField).toBe('keep-me');
355+
expect(normalized.description).toBe('');
356+
expect(normalized.skills).toEqual([]);
357+
expect(normalized.defaultInputModes).toEqual([]);
358+
});
359+
360+
it('should normalize and synchronize interfaces while preserving other fields', () => {
361+
const raw = {
362+
name: 'test',
363+
supportedInterfaces: [
364+
{
365+
url: 'grpc://test',
366+
protocolBinding: 'GRPC',
367+
protocolVersion: '1.0',
368+
},
369+
],
370+
};
371+
372+
const normalized = normalizeAgentCard(raw);
373+
374+
// Should exist in both fields
375+
expect(normalized.additionalInterfaces).toHaveLength(1);
376+
expect(
377+
(normalized as unknown as Record<string, unknown>)[
378+
'supportedInterfaces'
379+
],
380+
).toHaveLength(1);
381+
382+
const intf = normalized.additionalInterfaces?.[0] as unknown as Record<
383+
string,
384+
unknown
385+
>;
386+
387+
expect(intf['transport']).toBe('GRPC');
388+
expect(intf['url']).toBe('grpc://test');
389+
390+
// Should fallback top-level url
391+
expect(normalized.url).toBe('grpc://test');
392+
});
393+
394+
it('should preserve existing top-level url if present', () => {
395+
const raw = {
396+
name: 'test',
397+
url: 'http://existing',
398+
supportedInterfaces: [{ url: 'http://other', transport: 'REST' }],
399+
};
400+
401+
const normalized = normalizeAgentCard(raw);
402+
expect(normalized.url).toBe('http://existing');
403+
});
404+
405+
it('should NOT prepend http:// scheme to raw IP:port strings for gRPC interfaces', () => {
406+
const raw = {
407+
name: 'raw-ip-grpc',
408+
supportedInterfaces: [{ url: '127.0.0.1:9000', transport: 'GRPC' }],
409+
};
410+
411+
const normalized = normalizeAgentCard(raw);
412+
expect(normalized.additionalInterfaces?.[0].url).toBe('127.0.0.1:9000');
413+
expect(normalized.url).toBe('127.0.0.1:9000');
414+
});
415+
416+
it('should prepend http:// scheme to raw IP:port strings for REST interfaces', () => {
417+
const raw = {
418+
name: 'raw-ip-rest',
419+
supportedInterfaces: [{ url: '127.0.0.1:8080', transport: 'REST' }],
420+
};
421+
422+
const normalized = normalizeAgentCard(raw);
423+
expect(normalized.additionalInterfaces?.[0].url).toBe(
424+
'http://127.0.0.1:8080',
425+
);
426+
});
427+
428+
it('should NOT override existing transport if protocolBinding is also present', () => {
429+
const raw = {
430+
name: 'priority-test',
431+
supportedInterfaces: [
432+
{ url: 'foo', transport: 'GRPC', protocolBinding: 'REST' },
433+
],
434+
};
435+
const normalized = normalizeAgentCard(raw);
436+
expect(normalized.additionalInterfaces?.[0].transport).toBe('GRPC');
437+
});
438+
});
439+
440+
describe('splitAgentCardUrl', () => {
441+
const standard = '.well-known/agent-card.json';
442+
443+
it('should return baseUrl as-is if it does not end with standard path', () => {
444+
const url = 'http://localhost:9001/custom/path';
445+
expect(splitAgentCardUrl(url)).toEqual({ baseUrl: url });
446+
});
447+
448+
it('should split correctly if URL ends with standard path', () => {
449+
const url = `http://localhost:9001/${standard}`;
450+
expect(splitAgentCardUrl(url)).toEqual({
451+
baseUrl: 'http://localhost:9001/',
452+
path: undefined,
453+
});
454+
});
455+
456+
it('should handle trailing slash in baseUrl when splitting', () => {
457+
const url = `http://example.com/api/${standard}`;
458+
expect(splitAgentCardUrl(url)).toEqual({
459+
baseUrl: 'http://example.com/api/',
460+
path: undefined,
461+
});
462+
});
463+
464+
it('should ignore hashes and query params when splitting', () => {
465+
const url = `http://localhost:9001/${standard}?foo=bar#baz`;
466+
expect(splitAgentCardUrl(url)).toEqual({
467+
baseUrl: 'http://localhost:9001/',
468+
path: undefined,
469+
});
470+
});
471+
472+
it('should return original URL if parsing fails', () => {
473+
const url = 'not-a-url';
474+
expect(splitAgentCardUrl(url)).toEqual({ baseUrl: url });
475+
});
476+
477+
it('should handle standard path appearing earlier in the path', () => {
478+
const url = `http://localhost:9001/${standard}/something-else`;
479+
expect(splitAgentCardUrl(url)).toEqual({ baseUrl: url });
480+
});
226481
});
227482

228483
describe('A2AResultReassembler', () => {
@@ -233,6 +488,7 @@ describe('a2aUtils', () => {
233488
reassembler.update({
234489
kind: 'status-update',
235490
taskId: 't1',
491+
contextId: 'ctx1',
236492
status: {
237493
state: 'working',
238494
message: {
@@ -247,6 +503,7 @@ describe('a2aUtils', () => {
247503
reassembler.update({
248504
kind: 'artifact-update',
249505
taskId: 't1',
506+
contextId: 'ctx1',
250507
append: false,
251508
artifact: {
252509
artifactId: 'a1',
@@ -259,6 +516,7 @@ describe('a2aUtils', () => {
259516
reassembler.update({
260517
kind: 'status-update',
261518
taskId: 't1',
519+
contextId: 'ctx1',
262520
status: {
263521
state: 'working',
264522
message: {
@@ -273,6 +531,7 @@ describe('a2aUtils', () => {
273531
reassembler.update({
274532
kind: 'artifact-update',
275533
taskId: 't1',
534+
contextId: 'ctx1',
276535
append: true,
277536
artifact: {
278537
artifactId: 'a1',
@@ -291,6 +550,7 @@ describe('a2aUtils', () => {
291550

292551
reassembler.update({
293552
kind: 'status-update',
553+
contextId: 'ctx1',
294554
status: {
295555
state: 'auth-required',
296556
message: {
@@ -310,6 +570,7 @@ describe('a2aUtils', () => {
310570

311571
reassembler.update({
312572
kind: 'status-update',
573+
contextId: 'ctx1',
313574
status: {
314575
state: 'auth-required',
315576
},
@@ -323,6 +584,7 @@ describe('a2aUtils', () => {
323584

324585
const chunk = {
325586
kind: 'status-update',
587+
contextId: 'ctx1',
326588
status: {
327589
state: 'auth-required',
328590
message: {
@@ -351,6 +613,8 @@ describe('a2aUtils', () => {
351613

352614
reassembler.update({
353615
kind: 'task',
616+
id: 'task-1',
617+
contextId: 'ctx1',
354618
status: { state: 'completed' },
355619
history: [
356620
{
@@ -369,6 +633,8 @@ describe('a2aUtils', () => {
369633

370634
reassembler.update({
371635
kind: 'task',
636+
id: 'task-1',
637+
contextId: 'ctx1',
372638
status: { state: 'working' },
373639
history: [
374640
{
@@ -387,6 +653,8 @@ describe('a2aUtils', () => {
387653

388654
reassembler.update({
389655
kind: 'task',
656+
id: 'task-1',
657+
contextId: 'ctx1',
390658
status: { state: 'completed' },
391659
artifacts: [
392660
{

0 commit comments

Comments
 (0)