@@ -3,18 +3,37 @@ import type { JSONRPCErrorResponse } from '../src/core.js';
33import {
44 A2A_ERROR_CLASSES ,
55 A2A_ERROR_CODE ,
6+ A2A_ERROR_DOMAIN ,
7+ A2A_ERROR_SPECS ,
68 A2A_ERROR_SPECS_BY_CODE ,
9+ A2AError ,
710 ContentTypeNotSupportedError ,
11+ ERROR_INFO_TYPE ,
812 ExtendedAgentCardNotConfiguredError ,
913 ExtensionSupportRequiredError ,
1014 extractErrorMessage ,
1115 fromJsonRpcErrorResponse as mapJsonRpcErrorToSdkError ,
16+ fromRestErrorBody ,
17+ GenericError ,
18+ GRPC_STATUS ,
19+ GrpcTaskNotFoundError ,
20+ grpcStatusFor ,
21+ HTTP_STATUS ,
1222 InvalidAgentResponseError ,
23+ isGrpcError ,
24+ isJsonRpcError ,
25+ isRestError ,
26+ JsonRpcRequestMalformedError ,
27+ JsonRpcTaskNotFoundError ,
1328 JsonRpcTransportError as JSONRPCTransportError ,
1429 PushNotificationNotSupportedError ,
1530 RequestMalformedError ,
31+ RestTaskNotFoundError ,
32+ restStatusFor ,
1633 TaskNotCancelableError ,
1734 TaskNotFoundError ,
35+ toJsonRpcError ,
36+ toRestErrorBody ,
1837 UnsupportedOperationError ,
1938 VersionNotSupportedError ,
2039} from '../src/errors/index.js' ;
@@ -160,3 +179,353 @@ describe('extractErrorMessage', () => {
160179 expect ( extractErrorMessage ( cyclic ) ) . toBe ( '[object Object]' ) ;
161180 } ) ;
162181} ) ;
182+
183+ // ---------------------------------------------------------------------------
184+ // Hierarchy: every transport variant is-a semantic subclass is-a A2AError.
185+ // Guards `isRestError` / `isGrpcError` / `isJsonRpcError` narrow correctly,
186+ // and only ONE guard matches per instance (transports are exclusive).
187+ // ---------------------------------------------------------------------------
188+
189+ describe ( 'A2AError hierarchy' , ( ) => {
190+ it ( 'semantic class extends A2AError extends Error' , ( ) => {
191+ const e = new TaskNotFoundError ( { message : 't-1' } ) ;
192+ expect ( e ) . toBeInstanceOf ( TaskNotFoundError ) ;
193+ expect ( e ) . toBeInstanceOf ( A2AError ) ;
194+ expect ( e ) . toBeInstanceOf ( Error ) ;
195+ expect ( e . name ) . toBe ( 'TaskNotFoundError' ) ;
196+ expect ( e . code ) . toBe ( - 32001 ) ;
197+ expect ( e . reason ) . toBe ( 'TASK_NOT_FOUND' ) ;
198+ } ) ;
199+
200+ it ( 'RestTaskNotFoundError is-a TaskNotFoundError is-a A2AError' , ( ) => {
201+ const e = new RestTaskNotFoundError ( { statusCode : 404 } ) ;
202+ expect ( e ) . toBeInstanceOf ( RestTaskNotFoundError ) ;
203+ expect ( e ) . toBeInstanceOf ( TaskNotFoundError ) ;
204+ expect ( e ) . toBeInstanceOf ( A2AError ) ;
205+ expect ( e ) . toBeInstanceOf ( Error ) ;
206+ } ) ;
207+
208+ it ( 'GrpcTaskNotFoundError is-a TaskNotFoundError is-a A2AError' , ( ) => {
209+ const e = new GrpcTaskNotFoundError ( { status : GRPC_STATUS . NOT_FOUND } ) ;
210+ expect ( e ) . toBeInstanceOf ( GrpcTaskNotFoundError ) ;
211+ expect ( e ) . toBeInstanceOf ( TaskNotFoundError ) ;
212+ expect ( e ) . toBeInstanceOf ( A2AError ) ;
213+ } ) ;
214+
215+ it ( 'JsonRpcTaskNotFoundError is-a TaskNotFoundError is-a A2AError' , ( ) => {
216+ const e = new JsonRpcTaskNotFoundError ( { envelopeCode : - 32001 } ) ;
217+ expect ( e ) . toBeInstanceOf ( JsonRpcTaskNotFoundError ) ;
218+ expect ( e ) . toBeInstanceOf ( TaskNotFoundError ) ;
219+ expect ( e ) . toBeInstanceOf ( A2AError ) ;
220+ } ) ;
221+
222+ it ( 'semantic class name is preserved across transport variants' , ( ) => {
223+ // Users rely on `error.name === 'TaskNotFoundError'` for logging /
224+ // instanceof-adjacent branching; the makeRest/makeGrpc/makeJsonRpc
225+ // factories override `this.name = <semantic>` for this reason.
226+ expect ( new RestTaskNotFoundError ( ) . name ) . toBe ( 'TaskNotFoundError' ) ;
227+ expect ( new GrpcTaskNotFoundError ( ) . name ) . toBe ( 'TaskNotFoundError' ) ;
228+ expect ( new JsonRpcTaskNotFoundError ( ) . name ) . toBe ( 'TaskNotFoundError' ) ;
229+ } ) ;
230+ } ) ;
231+
232+ describe ( 'transport type guards' , ( ) => {
233+ it ( 'isRestError narrows only RestA2AError instances' , ( ) => {
234+ expect ( isRestError ( new RestTaskNotFoundError ( { statusCode : 404 } ) ) ) . toBe ( true ) ;
235+ expect ( isRestError ( new GrpcTaskNotFoundError ( ) ) ) . toBe ( false ) ;
236+ expect ( isRestError ( new JsonRpcTaskNotFoundError ( ) ) ) . toBe ( false ) ;
237+ expect ( isRestError ( new TaskNotFoundError ( ) ) ) . toBe ( false ) ; // plain semantic
238+ expect ( isRestError ( new Error ( 'nope' ) ) ) . toBe ( false ) ;
239+ expect ( isRestError ( null ) ) . toBe ( false ) ;
240+ expect ( isRestError ( undefined ) ) . toBe ( false ) ;
241+ expect ( isRestError ( 'string' ) ) . toBe ( false ) ;
242+ } ) ;
243+
244+ it ( 'isGrpcError narrows only GrpcA2AError instances' , ( ) => {
245+ expect ( isGrpcError ( new GrpcTaskNotFoundError ( { status : GRPC_STATUS . NOT_FOUND } ) ) ) . toBe ( true ) ;
246+ expect ( isGrpcError ( new RestTaskNotFoundError ( ) ) ) . toBe ( false ) ;
247+ expect ( isGrpcError ( new JsonRpcTaskNotFoundError ( ) ) ) . toBe ( false ) ;
248+ expect ( isGrpcError ( new TaskNotFoundError ( ) ) ) . toBe ( false ) ;
249+ expect ( isGrpcError ( new Error ( 'nope' ) ) ) . toBe ( false ) ;
250+ } ) ;
251+
252+ it ( 'isJsonRpcError narrows only JsonRpcA2AError instances' , ( ) => {
253+ expect ( isJsonRpcError ( new JsonRpcTaskNotFoundError ( { envelopeCode : - 32001 } ) ) ) . toBe ( true ) ;
254+ expect ( isJsonRpcError ( new RestTaskNotFoundError ( ) ) ) . toBe ( false ) ;
255+ expect ( isJsonRpcError ( new GrpcTaskNotFoundError ( ) ) ) . toBe ( false ) ;
256+ expect ( isJsonRpcError ( new TaskNotFoundError ( ) ) ) . toBe ( false ) ;
257+ expect ( isJsonRpcError ( new Error ( 'nope' ) ) ) . toBe ( false ) ;
258+ } ) ;
259+
260+ it ( 'transports are mutually exclusive: exactly one guard matches per instance' , ( ) => {
261+ const rest = new RestTaskNotFoundError ( { statusCode : 404 } ) ;
262+ const grpc = new GrpcTaskNotFoundError ( { status : GRPC_STATUS . NOT_FOUND } ) ;
263+ const json = new JsonRpcTaskNotFoundError ( { envelopeCode : - 32001 } ) ;
264+
265+ expect ( [ isRestError ( rest ) , isGrpcError ( rest ) , isJsonRpcError ( rest ) ] ) . toEqual ( [
266+ true ,
267+ false ,
268+ false ,
269+ ] ) ;
270+ expect ( [ isRestError ( grpc ) , isGrpcError ( grpc ) , isJsonRpcError ( grpc ) ] ) . toEqual ( [
271+ false ,
272+ true ,
273+ false ,
274+ ] ) ;
275+ expect ( [ isRestError ( json ) , isGrpcError ( json ) , isJsonRpcError ( json ) ] ) . toEqual ( [
276+ false ,
277+ false ,
278+ true ,
279+ ] ) ;
280+ } ) ;
281+
282+ it ( 'guards enable typed access to transport context' , ( ) => {
283+ // Compile-time proof (delete the guard and TS complains) + runtime.
284+ const err : A2AError = new RestTaskNotFoundError ( {
285+ statusCode : 429 ,
286+ headers : { 'retry-after' : '10' } ,
287+ } ) ;
288+ if ( isRestError ( err ) ) {
289+ expect ( err . statusCode ) . toBe ( 429 ) ;
290+ expect ( err . headers ?. [ 'retry-after' ] ) . toBe ( '10' ) ;
291+ }
292+ } ) ;
293+ } ) ;
294+
295+ // ---------------------------------------------------------------------------
296+ // Options plumbing: message defaults, message override, metadata, cause.
297+ // ---------------------------------------------------------------------------
298+
299+ describe ( 'A2AError construction' , ( ) => {
300+ it ( 'defaults message from spec.defaultMessage when none is passed' , ( ) => {
301+ expect ( new TaskNotFoundError ( ) . message ) . toBe ( 'Task not found' ) ;
302+ expect ( new UnsupportedOperationError ( ) . message ) . toBe ( 'This operation is not supported' ) ;
303+ expect ( new GenericError ( ) . message ) . toBe ( 'An unexpected error occurred.' ) ;
304+ } ) ;
305+
306+ it ( 'accepts a bare string as the message (legacy call shape)' , ( ) => {
307+ expect ( new TaskNotFoundError ( 'custom text' ) . message ) . toBe ( 'custom text' ) ;
308+ } ) ;
309+
310+ it ( 'accepts an options object with message' , ( ) => {
311+ expect ( new TaskNotFoundError ( { message : 'via options' } ) . message ) . toBe ( 'via options' ) ;
312+ } ) ;
313+
314+ it ( 'preserves cause (ES2022 Error.cause)' , ( ) => {
315+ const root = new Error ( 'root' ) ;
316+ const e = new TaskNotFoundError ( { message : 'wrapped' , cause : root } ) ;
317+ expect ( ( e as unknown as { cause : unknown } ) . cause ) . toBe ( root ) ;
318+ } ) ;
319+
320+ it ( 'stores metadata when non-empty and omits it when empty' , ( ) => {
321+ const withMd = new TaskNotFoundError ( { metadata : { taskId : 't-1' } } ) ;
322+ expect ( withMd . metadata ) . toEqual ( { taskId : 't-1' } ) ;
323+
324+ const withoutMd = new TaskNotFoundError ( { metadata : { } } ) ;
325+ expect ( withoutMd . metadata ) . toBeUndefined ( ) ;
326+
327+ const noArg = new TaskNotFoundError ( ) ;
328+ expect ( noArg . metadata ) . toBeUndefined ( ) ;
329+ } ) ;
330+
331+ it ( 'every semantic error has a corresponding entry in A2A_ERROR_SPECS' , ( ) => {
332+ for ( const [ name , Cls ] of Object . entries ( A2A_ERROR_CLASSES ) ) {
333+ const instance = new Cls ( ) ;
334+ const spec = A2A_ERROR_SPECS [ name ] ;
335+ expect ( spec ) . toBeDefined ( ) ;
336+ expect ( instance . code ) . toBe ( spec . code ) ;
337+ expect ( instance . reason ) . toBe ( spec . reason ) ;
338+ }
339+ } ) ;
340+ } ) ;
341+
342+ // ---------------------------------------------------------------------------
343+ // toErrorInfo(): the shape shipped in google.rpc.ErrorInfo (spec §10.6/§11.6).
344+ // ---------------------------------------------------------------------------
345+
346+ describe ( 'A2AError.toErrorInfo' , ( ) => {
347+ it ( 'returns spec-shaped ErrorInfo with the right @type and domain' , ( ) => {
348+ const info = new TaskNotFoundError ( ) . toErrorInfo ( ) ;
349+ expect ( info [ '@type' ] ) . toBe ( ERROR_INFO_TYPE ) ;
350+ expect ( info . reason ) . toBe ( 'TASK_NOT_FOUND' ) ;
351+ expect ( info . domain ) . toBe ( A2A_ERROR_DOMAIN ) ;
352+ expect ( info ) . not . toHaveProperty ( 'metadata' ) ; // omitted when empty
353+ } ) ;
354+
355+ it ( 'emits metadata when the constructor received a non-empty map' , ( ) => {
356+ const info = new TaskNotFoundError ( { metadata : { taskId : 't-1' } } ) . toErrorInfo ( ) ;
357+ expect ( info . metadata ) . toEqual ( { taskId : 't-1' } ) ;
358+ } ) ;
359+ } ) ;
360+
361+ // ---------------------------------------------------------------------------
362+ // Status helpers: restStatusFor / grpcStatusFor honor the instance override
363+ // but fall back to the semantic spec, and default to UNKNOWN/500 otherwise.
364+ // ---------------------------------------------------------------------------
365+
366+ describe ( 'restStatusFor' , ( ) => {
367+ it ( 'returns the instance-level statusCode when it is a RestA2AError' , ( ) => {
368+ expect ( restStatusFor ( new RestTaskNotFoundError ( { statusCode : 418 } ) ) ) . toBe ( 418 ) ;
369+ } ) ;
370+
371+ it ( 'falls back to the spec httpStatus for a plain semantic error' , ( ) => {
372+ expect ( restStatusFor ( new TaskNotFoundError ( ) ) ) . toBe ( HTTP_STATUS . NOT_FOUND ) ;
373+ expect ( restStatusFor ( new UnsupportedOperationError ( ) ) ) . toBe ( HTTP_STATUS . BAD_REQUEST ) ;
374+ expect ( restStatusFor ( new InvalidAgentResponseError ( ) ) ) . toBe ( HTTP_STATUS . INTERNAL_SERVER_ERROR ) ;
375+ } ) ;
376+
377+ it ( 'returns 500 for non-A2A throwables' , ( ) => {
378+ expect ( restStatusFor ( new Error ( 'unrelated' ) ) ) . toBe ( HTTP_STATUS . INTERNAL_SERVER_ERROR ) ;
379+ expect ( restStatusFor ( 'string' ) ) . toBe ( HTTP_STATUS . INTERNAL_SERVER_ERROR ) ;
380+ expect ( restStatusFor ( undefined ) ) . toBe ( HTTP_STATUS . INTERNAL_SERVER_ERROR ) ;
381+ } ) ;
382+ } ) ;
383+
384+ describe ( 'grpcStatusFor' , ( ) => {
385+ it ( 'returns the instance-level status when it is a GrpcA2AError' , ( ) => {
386+ expect ( grpcStatusFor ( new GrpcTaskNotFoundError ( { status : GRPC_STATUS . CANCELLED } ) ) ) . toBe (
387+ GRPC_STATUS . CANCELLED
388+ ) ;
389+ } ) ;
390+
391+ it ( 'falls back to the spec grpcStatus for a plain semantic error' , ( ) => {
392+ expect ( grpcStatusFor ( new TaskNotFoundError ( ) ) ) . toBe ( GRPC_STATUS . NOT_FOUND ) ;
393+ expect ( grpcStatusFor ( new ContentTypeNotSupportedError ( ) ) ) . toBe ( GRPC_STATUS . INVALID_ARGUMENT ) ;
394+ expect ( grpcStatusFor ( new InvalidAgentResponseError ( ) ) ) . toBe ( GRPC_STATUS . INTERNAL ) ;
395+ } ) ;
396+
397+ it ( 'returns UNKNOWN for non-A2A throwables' , ( ) => {
398+ expect ( grpcStatusFor ( new Error ( 'unrelated' ) ) ) . toBe ( GRPC_STATUS . UNKNOWN ) ;
399+ expect ( grpcStatusFor ( null ) ) . toBe ( GRPC_STATUS . UNKNOWN ) ;
400+ } ) ;
401+ } ) ;
402+
403+ // ---------------------------------------------------------------------------
404+ // Wire roundtrips: serialize semantic error -> parse -> same class + metadata.
405+ // ---------------------------------------------------------------------------
406+
407+ describe ( 'REST roundtrip' , ( ) => {
408+ it ( 'semantic error survives toRestErrorBody -> fromRestErrorBody' , ( ) => {
409+ const original = new TaskNotFoundError ( {
410+ message : 'task xyz missing' ,
411+ metadata : { taskId : 'xyz' } ,
412+ } ) ;
413+ const body = toRestErrorBody ( original , HTTP_STATUS . NOT_FOUND ) ;
414+
415+ // §11.6 body shape.
416+ expect ( body . error . code ) . toBe ( HTTP_STATUS . NOT_FOUND ) ;
417+ expect ( body . error . status ) . toBe ( 'NOT_FOUND' ) ;
418+ expect ( body . error . message ) . toBe ( 'task xyz missing' ) ;
419+ expect ( body . error . details [ 0 ] ) . toMatchObject ( {
420+ '@type' : ERROR_INFO_TYPE ,
421+ reason : 'TASK_NOT_FOUND' ,
422+ domain : A2A_ERROR_DOMAIN ,
423+ metadata : { taskId : 'xyz' } ,
424+ } ) ;
425+
426+ const rebuilt = fromRestErrorBody ( body . error , {
427+ statusCode : HTTP_STATUS . NOT_FOUND ,
428+ headers : { 'x-a' : '1' } ,
429+ } ) ;
430+
431+ expect ( rebuilt ) . toBeInstanceOf ( TaskNotFoundError ) ;
432+ expect ( rebuilt ) . toBeInstanceOf ( RestTaskNotFoundError ) ;
433+ expect ( rebuilt . statusCode ) . toBe ( HTTP_STATUS . NOT_FOUND ) ;
434+ expect ( rebuilt . headers ?. [ 'x-a' ] ) . toBe ( '1' ) ;
435+ expect ( rebuilt . metadata ) . toEqual ( { taskId : 'xyz' } ) ;
436+ expect ( rebuilt . message ) . toBe ( 'task xyz missing' ) ;
437+ } ) ;
438+
439+ it ( 'body without ErrorInfo detail becomes RestGenericError' , ( ) => {
440+ const rebuilt = fromRestErrorBody ( { message : 'plain 500' , details : [ ] } , { statusCode : 500 } ) ;
441+ expect ( rebuilt ) . toBeInstanceOf ( A2AError ) ;
442+ expect ( isRestError ( rebuilt ) ) . toBe ( true ) ;
443+ expect ( rebuilt . name ) . toBe ( 'GenericError' ) ;
444+ expect ( rebuilt . message ) . toBe ( 'plain 500' ) ;
445+ expect ( rebuilt . statusCode ) . toBe ( 500 ) ;
446+ } ) ;
447+
448+ it ( 'body with unknown ErrorInfo.reason becomes RestGenericError' , ( ) => {
449+ const rebuilt = fromRestErrorBody (
450+ {
451+ message : 'unknown' ,
452+ details : [ { '@type' : ERROR_INFO_TYPE , reason : 'MADE_UP_REASON' , domain : 'nope' } ] ,
453+ } ,
454+ { statusCode : 500 }
455+ ) ;
456+ expect ( rebuilt . name ) . toBe ( 'GenericError' ) ;
457+ } ) ;
458+
459+ it ( 'ignores metadata when the ErrorInfo.domain is not a2a-protocol.org' , ( ) => {
460+ const rebuilt = fromRestErrorBody (
461+ {
462+ message : 'foreign' ,
463+ details : [
464+ {
465+ '@type' : ERROR_INFO_TYPE ,
466+ reason : 'TASK_NOT_FOUND' ,
467+ domain : 'other.example' ,
468+ metadata : { taskId : 't-1' } ,
469+ } ,
470+ ] ,
471+ } ,
472+ { statusCode : 404 }
473+ ) ;
474+ expect ( rebuilt ) . toBeInstanceOf ( TaskNotFoundError ) ;
475+ expect ( rebuilt . metadata ) . toBeUndefined ( ) ;
476+ } ) ;
477+ } ) ;
478+
479+ describe ( 'JSON-RPC roundtrip' , ( ) => {
480+ it ( 'semantic error survives toJsonRpcError -> fromJsonRpcErrorResponse' , ( ) => {
481+ const original = new TaskNotFoundError ( {
482+ message : 'task xyz missing' ,
483+ metadata : { taskId : 'xyz' } ,
484+ } ) ;
485+ const envelopeError = toJsonRpcError ( original ) ;
486+
487+ expect ( envelopeError . code ) . toBe ( A2A_ERROR_CODE . TASK_NOT_FOUND ) ;
488+ expect ( envelopeError . message ) . toBe ( 'task xyz missing' ) ;
489+ expect ( envelopeError . data ?. [ 0 ] ) . toMatchObject ( {
490+ '@type' : ERROR_INFO_TYPE ,
491+ reason : 'TASK_NOT_FOUND' ,
492+ domain : A2A_ERROR_DOMAIN ,
493+ metadata : { taskId : 'xyz' } ,
494+ } ) ;
495+
496+ const rebuilt = mapJsonRpcErrorToSdkError ( {
497+ jsonrpc : '2.0' ,
498+ id : 1 ,
499+ error : envelopeError ,
500+ } ) ;
501+ expect ( rebuilt ) . toBeInstanceOf ( TaskNotFoundError ) ;
502+ expect ( rebuilt ) . toBeInstanceOf ( JsonRpcTaskNotFoundError ) ;
503+ expect ( rebuilt . envelopeCode ) . toBe ( A2A_ERROR_CODE . TASK_NOT_FOUND ) ;
504+ expect ( rebuilt . message ) . toBe ( 'task xyz missing' ) ;
505+ } ) ;
506+
507+ it ( 'JsonRpc*Error.envelopeCode overrides the semantic default' , ( ) => {
508+ // v0.3 compat case: METHOD_NOT_FOUND has no semantic twin, so we
509+ // route it through JsonRpcRequestMalformedError with envelopeCode
510+ // overridden. The envelope must preserve that wire code.
511+ const err = new JsonRpcRequestMalformedError ( {
512+ message : 'no such method' ,
513+ envelopeCode : A2A_ERROR_CODE . METHOD_NOT_FOUND ,
514+ } ) ;
515+ const envelope = toJsonRpcError ( err ) ;
516+ expect ( envelope . code ) . toBe ( A2A_ERROR_CODE . METHOD_NOT_FOUND ) ; // NOT -32602
517+ expect ( envelope . message ) . toBe ( 'no such method' ) ;
518+ } ) ;
519+
520+ it ( 'unknown code becomes JsonRpcTransportError carrying the full envelope' , ( ) => {
521+ const envelope : JSONRPCErrorResponse = {
522+ jsonrpc : '2.0' ,
523+ id : 9 ,
524+ error : { code : - 99999 , message : 'mystery' , data : { foo : 'bar' } } ,
525+ } ;
526+ const rebuilt = mapJsonRpcErrorToSdkError ( envelope ) ;
527+ expect ( rebuilt ) . toBeInstanceOf ( JSONRPCTransportError ) ;
528+ expect ( ( rebuilt as JSONRPCTransportError ) . errorResponse ) . toBe ( envelope ) ;
529+ expect ( isJsonRpcError ( rebuilt ) ) . toBe ( true ) ;
530+ } ) ;
531+ } ) ;
0 commit comments