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' ;
88import {
99 extractMessageText ,
1010 extractIdsFromResponse ,
1111 isTerminalState ,
1212 A2AResultReassembler ,
1313 AUTH_REQUIRED_MSG ,
14+ normalizeAgentCard ,
15+ getGrpcCredentials ,
16+ pinUrlToIp ,
17+ splitAgentCardUrl ,
1418} from './a2aUtils.js' ;
1519import type { SendMessageResult } from './a2a-client-manager.js' ;
1620import 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
2635describe ( '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