diff --git a/README.md b/README.md index 0348adc..dc6ee43 100644 --- a/README.md +++ b/README.md @@ -113,30 +113,37 @@ The `A2AClient` makes it easy to communicate with any A2A-compliant agent. ```typescript // client.ts -import { A2AClient, SendMessageSuccessResponse } from "@a2a-js/sdk/client"; +import { A2AClient, A2AClientError, isMessage, withResultType } from "@a2a-js/sdk/client"; import { Message, MessageSendParams } from "@a2a-js/sdk"; import { v4 as uuidv4 } from "uuid"; async function run() { - // Create a client pointing to the agent's Agent Card URL. - const client = await A2AClient.fromCardUrl("http://localhost:4000/.well-known/agent-card.json"); - - const sendParams: MessageSendParams = { - message: { - messageId: uuidv4(), - role: "user", - parts: [{ kind: "text", text: "Hi there!" }], - kind: "message", - }, - }; - - const response = await client.sendMessage(sendParams); + try { + // Create a client pointing to the agent's Agent Card URL. + const client = await A2AClient.fromCardUrl("http://localhost:4000/.well-known/agent-card.json"); + + const sendParams: MessageSendParams = { + message: { + messageId: uuidv4(), + role: "user", + parts: [{ kind: "text", text: "Hi there!" }], + kind: "message", + }, + }; - if ("error" in response) { - console.error("Error:", response.error.message); - } else { - const result = (response as SendMessageSuccessResponse).result as Message; - console.log("Agent response:", result.parts[0].text); // "Hello, world!" + // Type-safe response handling - no manual error checking needed + const response = await client.sendMessage(sendParams); + + // Type guards for automatic type inference + if (isMessage(response.result)) { + console.log("Agent response:", response.result.parts[0].text); // "Hello, world!" + } + } catch (error) { + if (error instanceof A2AClientError) { + console.error(`A2A Error (${error.rpcError.code}): ${error.rpcError.message}`); + } else { + console.error("Unexpected error:", error); + } } } @@ -212,33 +219,48 @@ The client sends a message and receives a `Task` object as the result. ```typescript // client.ts -import { A2AClient, SendMessageSuccessResponse } from "@a2a-js/sdk/client"; -import { Message, MessageSendParams, Task } from "@a2a-js/sdk"; -// ... other imports ... - -const client = await A2AClient.fromCardUrl("http://localhost:4000/.well-known/agent-card.json"); +import { A2AClient, A2AClientError, isTask, isMessage, withResultType } from "@a2a-js/sdk/client"; +import { MessageSendParams } from "@a2a-js/sdk"; +import { v4 as uuidv4 } from "uuid"; -const response = await client.sendMessage({ message: { messageId: uuidv4(), role: "user", parts: [{ kind: "text", text: "Do something." }], kind: "message" } }); +async function run() { + try { + const client = await A2AClient.fromCardUrl("http://localhost:4000/.well-known/agent-card.json"); + + const response = await client.sendMessage({ + message: { + messageId: uuidv4(), + role: "user", + parts: [{ kind: "text", text: "Do something." }], + kind: "message" + } + }); -if ("error" in response) { - console.error("Error:", response.error.message); -} else { - const result = (response as SendMessageSuccessResponse).result; + // Pattern-based result processing with automatic type inference + withResultType(response.result, { + task: (task) => { + console.log(`Task [${task.id}] completed with status: ${task.status.state}`); - // Check if the agent's response is a Task or a direct Message. - if (result.kind === "task") { - const task = result as Task; - console.log(`Task [${task.id}] completed with status: ${task.status.state}`); + if (task.artifacts && task.artifacts.length > 0) { + console.log(`Artifact found: ${task.artifacts[0].name}`); + console.log(`Content: ${task.artifacts[0].parts[0].text}`); + } + }, + message: (message) => { + console.log("Received direct message:", message.parts[0].text); + } + }); - if (task.artifacts && task.artifacts.length > 0) { - console.log(`Artifact found: ${task.artifacts[0].name}`); - console.log(`Content: ${task.artifacts[0].parts[0].text}`); + } catch (error) { + if (error instanceof A2AClientError) { + console.error(`A2A Error (${error.rpcError.code}): ${error.rpcError.message}`); + } else { + console.error("Unexpected error:", error); } - } else { - const message = result as Message; - console.log("Received direct message:", message.parts[0].text); } } + +await run(); ``` ----- @@ -278,7 +300,20 @@ const client = await A2AClient.fromCardUrl( ); // Now, all requests made by this client instance will include the X-Request-ID header. -await client.sendMessage({ message: { messageId: uuidv4(), role: "user", parts: [{ kind: "text", text: "A message requiring custom headers." }], kind: "message" } }); +try { + await client.sendMessage({ + message: { + messageId: uuidv4(), + role: "user", + parts: [{ kind: "text", text: "A message requiring custom headers." }], + kind: "message" + } + }); +} catch (error) { + if (error instanceof A2AClientError) { + console.error(`Request failed: ${error.rpcError.message}`); + } +} ``` ### Using the Provided `AuthenticationHandler` diff --git a/src/client/client.ts b/src/client/client.ts index 9bc6f3c..4583e97 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -32,10 +32,19 @@ import { SendMessageSuccessResponse, ListTaskPushNotificationConfigParams, ListTaskPushNotificationConfigResponse, + ListTaskPushNotificationConfigSuccessResponse, DeleteTaskPushNotificationConfigResponse, + DeleteTaskPushNotificationConfigSuccessResponse, DeleteTaskPushNotificationConfigParams } from '../types.js'; // Assuming schema.ts is in the same directory or appropriately pathed import { AGENT_CARD_PATH } from "../constants.js"; +import { + parseSuccessResponse, + A2AClientError, + FilterSuccessResponse, + A2ASuccessResponse, + isErrorResponse +} from "./response-utils.js"; // Helper type for the data yielded by streaming methods type A2AStreamEventData = Message | Task | TaskStatusUpdateEvent | TaskArtifactUpdateEvent; @@ -215,10 +224,12 @@ export class A2AClient { * are specified within the `params.configuration` object. * Optionally, `params.message.contextId` or `params.message.taskId` can be provided. * @param params The parameters for sending the message, including the message content and configuration. - * @returns A Promise resolving to SendMessageResponse, which can be a Message, Task, or an error. + * @returns A Promise resolving to SendMessageSuccessResponse (Message or Task). Throws A2AClientError for error responses. + * @throws A2AClientError if the response contains an error. */ - public async sendMessage(params: MessageSendParams): Promise { - return this._postRpcRequest("message/send", params); + public async sendMessage(params: MessageSendParams): Promise { + const response = await this._postRpcRequest("message/send", params); + return parseSuccessResponse(response); } /** @@ -277,86 +288,100 @@ export class A2AClient { * Sets or updates the push notification configuration for a given task. * Requires the agent to support push notifications (`capabilities.pushNotifications: true` in AgentCard). * @param params Parameters containing the taskId and the TaskPushNotificationConfig. - * @returns A Promise resolving to SetTaskPushNotificationConfigResponse. + * @returns A Promise resolving to SetTaskPushNotificationConfigSuccessResponse. Throws A2AClientError for error responses. + * @throws A2AClientError if the response contains an error. */ - public async setTaskPushNotificationConfig(params: TaskPushNotificationConfig): Promise { + public async setTaskPushNotificationConfig(params: TaskPushNotificationConfig): Promise { const agentCard = await this.agentCardPromise; if (!agentCard.capabilities?.pushNotifications) { throw new Error("Agent does not support push notifications (AgentCard.capabilities.pushNotifications is not true)."); } // The 'params' directly matches the structure expected by the RPC method. - return this._postRpcRequest( + const response = await this._postRpcRequest( "tasks/pushNotificationConfig/set", params ); + return parseSuccessResponse(response); } /** * Gets the push notification configuration for a given task. * @param params Parameters containing the taskId. - * @returns A Promise resolving to GetTaskPushNotificationConfigResponse. + * @returns A Promise resolving to GetTaskPushNotificationConfigSuccessResponse. Throws A2AClientError for error responses. + * @throws A2AClientError if the response contains an error. */ - public async getTaskPushNotificationConfig(params: TaskIdParams): Promise { + public async getTaskPushNotificationConfig(params: TaskIdParams): Promise { // The 'params' (TaskIdParams) directly matches the structure expected by the RPC method. - return this._postRpcRequest( + const response = await this._postRpcRequest( "tasks/pushNotificationConfig/get", params ); + return parseSuccessResponse(response); } /** * Lists the push notification configurations for a given task. * @param params Parameters containing the taskId. - * @returns A Promise resolving to ListTaskPushNotificationConfigResponse. + * @returns A Promise resolving to ListTaskPushNotificationConfigSuccessResponse. Throws A2AClientError for error responses. + * @throws A2AClientError if the response contains an error. */ - public async listTaskPushNotificationConfig(params: ListTaskPushNotificationConfigParams): Promise { - return this._postRpcRequest( + public async listTaskPushNotificationConfig(params: ListTaskPushNotificationConfigParams): Promise { + const response = await this._postRpcRequest( "tasks/pushNotificationConfig/list", params ); + return parseSuccessResponse(response); } /** * Deletes the push notification configuration for a given task. * @param params Parameters containing the taskId and push notification configuration ID. - * @returns A Promise resolving to DeleteTaskPushNotificationConfigResponse. + * @returns A Promise resolving to DeleteTaskPushNotificationConfigSuccessResponse. Throws A2AClientError for error responses. + * @throws A2AClientError if the response contains an error. */ - public async deleteTaskPushNotificationConfig(params: DeleteTaskPushNotificationConfigParams): Promise { - return this._postRpcRequest( + public async deleteTaskPushNotificationConfig(params: DeleteTaskPushNotificationConfigParams): Promise { + const response = await this._postRpcRequest( "tasks/pushNotificationConfig/delete", params ); + return parseSuccessResponse(response); } /** * Retrieves a task by its ID. * @param params Parameters containing the taskId and optional historyLength. - * @returns A Promise resolving to GetTaskResponse, which contains the Task object or an error. + * @returns A Promise resolving to GetTaskSuccessResponse containing the Task object. Throws A2AClientError for error responses. + * @throws A2AClientError if the response contains an error. */ - public async getTask(params: TaskQueryParams): Promise { - return this._postRpcRequest("tasks/get", params); + public async getTask(params: TaskQueryParams): Promise { + const response = await this._postRpcRequest("tasks/get", params); + return parseSuccessResponse(response); } /** * Cancels a task by its ID. * @param params Parameters containing the taskId. - * @returns A Promise resolving to CancelTaskResponse, which contains the updated Task object or an error. + * @returns A Promise resolving to CancelTaskSuccessResponse containing the updated Task object. Throws A2AClientError for error responses. + * @throws A2AClientError if the response contains an error. */ - public async cancelTask(params: TaskIdParams): Promise { - return this._postRpcRequest("tasks/cancel", params); + public async cancelTask(params: TaskIdParams): Promise { + const response = await this._postRpcRequest("tasks/cancel", params); + return parseSuccessResponse(response); } /** * @template TExtensionParams The type of parameters for the custom extension method. - * @template TExtensionResponse The type of response expected from the custom extension method. + * @template TExtensionResponse The type of response expected from the custom extension method. * This should extend JSONRPCResponse. This ensures the extension response is still a valid A2A response. * @param method Custom JSON-RPC method defined in the AgentCard's extensions. * @param params Extension paramters defined in the AgentCard's extensions. - * @returns A Promise that resolves to the RPC response. + * @returns A Promise that resolves to the success response. Throws A2AClientError for error responses. + * @throws A2AClientError if the response contains an error. */ - public async callExtensionMethod(method: string, params: TExtensionParams) { - return this._postRpcRequest(method, params); + public async callExtensionMethod(method: string, params: TExtensionParams): Promise> { + const response = await this._postRpcRequest(method, params); + return parseSuccessResponse(response); } @@ -507,32 +532,21 @@ export class A2AClient { // Depending on strictness, this could be an error. For now, it's a warning. } - if (this.isErrorResponse(a2aStreamResponse)) { - const err = a2aStreamResponse.error as (JSONRPCError | A2AError); - throw new Error(`SSE event contained an error: ${err.message} (Code: ${err.code}) Data: ${JSON.stringify(err.data || {})}`); - } - - // Check if 'result' exists, as it's mandatory for successful JSON-RPC responses - if (!('result' in a2aStreamResponse) || typeof (a2aStreamResponse as SendStreamingMessageSuccessResponse).result === 'undefined') { - throw new Error(`SSE event JSON-RPC response is missing 'result' field. Data: ${jsonData}`); - } - - const successResponse = a2aStreamResponse as SendStreamingMessageSuccessResponse; + // Use the type-safe parseSuccessResponse to handle error responses and extract result + const successResponse = parseSuccessResponse(a2aStreamResponse); return successResponse.result as TStreamItem; } catch (e: any) { - // Catch errors from JSON.parse or if it's an error response that was thrown by this function - if (e.message.startsWith("SSE event contained an error") || e.message.startsWith("SSE event JSON-RPC response is missing 'result' field")) { - throw e; // Re-throw errors already processed/identified by this function + // Re-throw A2AClientError (from parseSuccessResponse) without modification + if (e instanceof A2AClientError) { + throw e; } - // For other parsing errors or unexpected structures: + + // For JSON parsing errors or other unexpected structures: console.error("Failed to parse SSE event data string or unexpected JSON-RPC structure:", jsonData, e); throw new Error(`Failed to parse SSE event data: "${jsonData.substring(0, 100)}...". Original error: ${e.message}`); } } - isErrorResponse(response: JSONRPCResponse): response is JSONRPCErrorResponse { - return "error" in response; - } //////////////////////////////////////////////////////////////////////////////// // Functions used to support old A2AClient Constructor to be deprecated soon diff --git a/src/client/index.ts b/src/client/index.ts index c6689cc..c1c7e22 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -5,3 +5,5 @@ export { A2AClient } from "./client.js"; export type { A2AClientOptions } from "./client.js"; export * from "./auth-handler.js"; +export * from "./response-utils.js"; +export * from "./type-guards.js"; diff --git a/src/client/response-utils.ts b/src/client/response-utils.ts new file mode 100644 index 0000000..2fe7980 --- /dev/null +++ b/src/client/response-utils.ts @@ -0,0 +1,105 @@ +import { + JSONRPCResponse, + JSONRPCErrorResponse, + JSONRPCError, + A2AError +} from '../types.js'; + +/** + * Type utility to filter out error responses from JSON-RPC response unions. + * Similar to Hono's FilterClientResponseByStatusCode for excluding error responses. + */ +export type FilterSuccessResponse = T extends JSONRPCErrorResponse ? never : T; + +/** + * Union type of all successful A2A JSON-RPC responses (excludes error responses) + */ +export type A2ASuccessResponse = FilterSuccessResponse; + +/** + * Extracts the result type from a successful JSON-RPC response + */ +export type ExtractResult = T['result']; + +/** + * Custom error class for A2A client RPC errors. + * Provides detailed error information from JSON-RPC error responses. + */ +export class A2AClientError extends Error { + constructor( + public readonly rpcError: JSONRPCError | A2AError, + public readonly requestId: string | number | null + ) { + super(`A2A RPC Error (${rpcError.code}): ${rpcError.message}`); + this.name = 'A2AClientError'; + } +} + +/** + * Type-safe response parser that excludes error responses at the type level. + * Throws A2AClientError for error responses, returns success response otherwise. + * + * @param response The JSON-RPC response to parse + * @returns The success response, with error responses filtered out at type level + * @throws A2AClientError if the response contains an error + */ +export function parseSuccessResponse( + response: T +): FilterSuccessResponse { + if ('error' in response) { + throw new A2AClientError(response.error, response.id); + } + return response as FilterSuccessResponse; +} + +/** + * Type guard to check if a response is an error response + */ +export function isErrorResponse(response: JSONRPCResponse): response is JSONRPCErrorResponse { + return 'error' in response; +} + +/** + * Type guard to check if a response is a success response + */ +export function isSuccessResponse(response: JSONRPCResponse): response is A2ASuccessResponse { + return !isErrorResponse(response); +} + +/** + * Handles JSON-RPC response by either returning the success response or throwing an error. + * This provides a uniform way to handle responses across all client methods. + */ +export function handleRpcResponse( + response: T +): FilterSuccessResponse { + return parseSuccessResponse(response); +} + +/** + * Type-safe streaming response parser that filters out error responses. + * Processes an async generator of JSON-RPC responses and yields only the result data. + * + * @param stream AsyncGenerator of JSON-RPC responses from SSE + * @returns AsyncGenerator yielding only the result data from successful responses + * @throws A2AClientError for any error responses in the stream + */ +export async function* parseStreamingResponse( + stream: AsyncGenerator +): AsyncGenerator>, void, undefined> { + for await (const response of stream) { + const successResponse = parseSuccessResponse(response); + yield successResponse.result; + } +} + +/** + * Type-safe streaming helper for A2A event data. + * Specifically designed for A2A streaming responses (Message, Task, TaskStatusUpdateEvent, TaskArtifactUpdateEvent). + */ +export async function* parseA2AStreamingResponse( + stream: AsyncGenerator +): AsyncGenerator { + yield* parseStreamingResponse(stream); +} + diff --git a/src/client/type-guards.ts b/src/client/type-guards.ts new file mode 100644 index 0000000..1f9958c --- /dev/null +++ b/src/client/type-guards.ts @@ -0,0 +1,70 @@ +import { Task, Message1, Message2 } from '../types.js'; + +/** + * Conditional type utilities for extracting specific result types. + * Uses advanced TypeScript features for precise type extraction. + */ +export type ExtractByKind = + TUnion extends { kind: TKind } ? TUnion : never; + +export type ExtractTask = ExtractByKind; +export type ExtractMessage = ExtractByKind | ExtractByKind; + +/** + * Template literal types for A2A result pattern matching. + * Provides compile-time pattern validation for result IDs. + */ +export type A2AResultPattern = + T extends `task-${infer _Rest}` ? 'task' + : T extends `msg-${infer _Rest}` ? 'message' + : T extends `user-msg-${infer _Rest}` ? 'user-message' + : 'unknown'; + +/** + * Type guards with conditional type inference. + * Provides precise type narrowing for union types. + */ +export function isTask(result: T): result is Extract { + return ( + typeof result === 'object' && + result !== null && + 'kind' in result && + result.kind === 'task' + ); +} + +export function isMessage(result: T): result is Extract { + return ( + typeof result === 'object' && + result !== null && + 'kind' in result && + (result.kind === 'message' || result.kind === 'user-message') + ); +} + +/** + * Type-safe result processor using conditional types. + * Automatically infers correct handler types based on result kind. + */ +export function withResultType( + result: T, + handlers: { + task?: T extends { kind: 'task' } ? (task: Extract) => R : never; + message?: T extends { kind: 'message' | 'user-message' } ? (message: Extract) => R : never; + fallback?: () => R; + } +): R { + if (isTask(result) && handlers.task) { + return (handlers.task as (task: typeof result) => R)(result); + } + + if (isMessage(result) && handlers.message) { + return (handlers.message as (message: typeof result) => R)(result); + } + + if (handlers.fallback) { + return handlers.fallback(); + } + + throw new Error(`No handler provided for result kind: ${(result as any)?.kind}`); +} \ No newline at end of file diff --git a/src/samples/agents/movie-agent/index.ts b/src/samples/agents/movie-agent/index.ts index 6c6621d..ac5c579 100644 --- a/src/samples/agents/movie-agent/index.ts +++ b/src/samples/agents/movie-agent/index.ts @@ -256,6 +256,7 @@ const movieAgentCard: AgentCard = { description: 'An agent that can answer questions about movies and actors using TMDB.', // Adjust the base URL and port as needed. /a2a is the default base in A2AExpressApp url: 'http://localhost:41241/', // Example: if baseUrl in A2AExpressApp + protocolVersion: '1.0', provider: { organization: 'A2A Samples', url: 'https://example.com/a2a-samples' // Added provider URL diff --git a/test/client/client.spec.ts b/test/client/client.spec.ts index 2029530..1f3320c 100644 --- a/test/client/client.spec.ts +++ b/test/client/client.spec.ts @@ -2,6 +2,7 @@ import { describe, it, beforeEach, afterEach } from 'mocha'; import { expect } from 'chai'; import sinon from 'sinon'; import { A2AClient } from '../../src/client/client.js'; +import { A2AClientError } from '../../src/client/response-utils.js'; import { MessageSendParams, TextPart, @@ -11,9 +12,7 @@ import { ListTaskPushNotificationConfigSuccessResponse, DeleteTaskPushNotificationConfigResponse, DeleteTaskPushNotificationConfigSuccessResponse, - JSONRPCErrorResponse, - JSONRPCResponse, - JSONRPCSuccessResponse + JSONRPCResponse } from '../../src/types.js'; import { AGENT_CARD_PATH } from '../../src/constants.js'; import { extractRequestId, createResponse, createAgentCardResponse, createMockAgentCard, createMockFetch } from './util.js'; @@ -31,10 +30,6 @@ function isDeleteConfigSuccessResponse(response: DeleteTaskPushNotificationConfi return 'result' in response; } -function isErrorResponse(response: any): response is JSONRPCErrorResponse { - return 'error' in response; -} - describe('A2AClient Basic Tests', () => { let client: A2AClient; let mockFetch: sinon.SinonStub; @@ -438,18 +433,6 @@ describe('Extension Methods', () => { limit: number; } - // Define the expected response type - // Define custom extension result type - interface CustomExtensionResult { - result: { - items: Array<{ - id: string; - name: string; - }>; - totalCount: number; - }; - } - // Set up custom params for the test const customParams: CustomExtensionParams = { query: 'test query', @@ -556,16 +539,14 @@ describe('Extension Methods', () => { message: 'Extension method error: Invalid parameters' }; - const response = await errorClient.callExtensionMethod(extensionMethod, customParams); - - // Check that we got a JSON-RPC error response - expect(isErrorResponse(response)).to.be.true; - if (isErrorResponse(response)) { - // Verify the error details match what we expect - expect(response.error.code).to.equal(expectedError.code); - expect(response.error.message).to.equal(expectedError.message); - } else { - expect.fail('Expected JSON-RPC error response but got success response'); + // The method should throw A2AClientError for error responses + try { + await errorClient.callExtensionMethod(extensionMethod, customParams); + expect.fail('Should have thrown A2AClientError'); + } catch (error: any) { + expect(error).to.be.instanceOf(A2AClientError); + expect(error.rpcError.code).to.equal(expectedError.code); + expect(error.rpcError.message).to.equal(expectedError.message); } }); }); diff --git a/test/client/client_auth.spec.ts b/test/client/client_auth.spec.ts index bb3dfc3..233e28c 100644 --- a/test/client/client_auth.spec.ts +++ b/test/client/client_auth.spec.ts @@ -340,14 +340,15 @@ describe('A2AClient Authentication Tests', () => { text: 'Test without auth handler' }); - // The client should return a JSON-RPC error response rather than throwing an error - const result = await clientNoAuthHandler.sendMessage(messageParams); - - // Verify that the result is a JSON-RPC error response - expect(result).to.have.property('jsonrpc', '2.0'); - expect(result).to.have.property('error'); - expect((result as any).error).to.have.property('code', -32001); - expect((result as any).error).to.have.property('message', 'Authentication required'); + // The client should throw an A2AClientError for error responses + try { + await clientNoAuthHandler.sendMessage(messageParams); + expect.fail('Should have thrown A2AClientError'); + } catch (error) { + expect(error.name).to.equal('A2AClientError'); + expect(error.rpcError).to.have.property('code', -32001); + expect(error.message).to.include('Authentication required'); + } // Verify that fetch was called only once (no retry attempted) expect(fetchWithApiError.callCount).to.equal(2); // One for agent card, one for API call diff --git a/test/client/response-utils.spec.ts b/test/client/response-utils.spec.ts new file mode 100644 index 0000000..780301f --- /dev/null +++ b/test/client/response-utils.spec.ts @@ -0,0 +1,301 @@ +import { describe, it } from 'mocha'; +import { expect } from 'chai'; +import { + FilterSuccessResponse, + A2AClientError, + parseSuccessResponse, + isErrorResponse, + isSuccessResponse, + parseStreamingResponse +} from '../../src/client/response-utils.js'; +import { + isTask, + isMessage, + withResultType +} from '../../src/client/type-guards.js'; +import { + JSONRPCErrorResponse, + SendMessageSuccessResponse, + SendMessageResponse, + GetTaskSuccessResponse, + GetTaskResponse, + JSONRPCResponse, + CancelTaskSuccessResponse, + CancelTaskResponse +} from '../../src/types.js'; + +describe('Response Utils - Type Safety and Error Handling', () => { + + describe('FilterSuccessResponse Type Utility', () => { + it('should filter out error responses at the type level', () => { + // This test validates type-level filtering - compilation success indicates correct typing + type TestResponse = SendMessageResponse; + type FilteredResponse = FilterSuccessResponse; + + // If this compiles, FilterSuccessResponse correctly excludes JSONRPCErrorResponse + const successResponse: FilteredResponse = { + jsonrpc: '2.0', + id: 1, + result: { + id: 'task-123', + contextId: 'ctx-123', + kind: 'task' as const, + status: { + state: 'submitted' + }, + history: [] + } + }; + + expect(successResponse).to.have.property('result'); + expect(successResponse).to.not.have.property('error'); + }); + }); + + describe('parseSuccessResponse', () => { + it('should return success response unchanged', () => { + const successResponse: SendMessageSuccessResponse = { + jsonrpc: '2.0', + id: 1, + result: { + id: 'task-123', + contextId: 'ctx-123', + kind: 'task' as const, + status: { + state: 'submitted' + }, + history: [] + } + }; + + const parsed = parseSuccessResponse(successResponse); + expect(parsed).to.deep.equal(successResponse); + expect(parsed).to.have.property('result'); + }); + + it('should throw A2AClientError for error responses', () => { + const errorResponse: JSONRPCErrorResponse = { + jsonrpc: '2.0', + id: 1, + error: { + code: -32600, + message: 'Invalid Request', + data: { details: 'Test error' } + } + }; + + expect(() => parseSuccessResponse(errorResponse)).to.throw(A2AClientError); + + try { + parseSuccessResponse(errorResponse); + expect.fail('Should have thrown A2AClientError'); + } catch (error) { + expect(error).to.be.instanceOf(A2AClientError); + expect((error as A2AClientError).rpcError.code).to.equal(-32600); + expect((error as A2AClientError).rpcError.message).to.equal('Invalid Request'); + expect((error as A2AClientError).requestId).to.equal(1); + } + }); + + it('should work with different response types', () => { + const getTaskResponse: GetTaskSuccessResponse = { + jsonrpc: '2.0', + id: 2, + result: { + id: 'task-456', + contextId: 'ctx-456', + kind: 'task' as const, + status: { + state: 'completed' + }, + history: [] + } + }; + + const parsed = parseSuccessResponse(getTaskResponse); + expect(parsed.result.id).to.equal('task-456'); + expect(parsed.result.status.state).to.equal('completed'); + }); + }); + + describe('A2AClientError', () => { + it('should create error with RPC error details', () => { + const rpcError = { + code: -32601, + message: 'Method not found' + }; + + const clientError = new A2AClientError(rpcError, 'request-123'); + + expect(clientError.name).to.equal('A2AClientError'); + expect(clientError.message).to.include('Method not found'); + expect(clientError.message).to.include('-32601'); + expect(clientError.rpcError).to.deep.equal(rpcError); + expect(clientError.requestId).to.equal('request-123'); + }); + + it('should handle null request ID', () => { + const rpcError = { + code: -32700, + message: 'Parse error' + }; + + const clientError = new A2AClientError(rpcError, null); + expect(clientError.requestId).to.be.null; + }); + }); + + describe('Type Guards', () => { + it('isErrorResponse should correctly identify error responses', () => { + const errorResponse: JSONRPCErrorResponse = { + jsonrpc: '2.0', + id: 1, + error: { code: -32600, message: 'Invalid Request' } + }; + + const successResponse: SendMessageSuccessResponse = { + jsonrpc: '2.0', + id: 1, + result: { id: 'task-123', contextId: 'ctx-123', kind: 'task' as const, status: { state: 'submitted' }, history: [] } + }; + + expect(isErrorResponse(errorResponse)).to.be.true; + expect(isErrorResponse(successResponse)).to.be.false; + }); + + it('isSuccessResponse should correctly identify success responses', () => { + const errorResponse: JSONRPCErrorResponse = { + jsonrpc: '2.0', + id: 1, + error: { code: -32600, message: 'Invalid Request' } + }; + + const successResponse: CancelTaskSuccessResponse = { + jsonrpc: '2.0', + id: 1, + result: { id: 'task-789', contextId: 'ctx-789', kind: 'task' as const, status: { state: 'canceled' }, history: [] } + }; + + expect(isSuccessResponse(errorResponse)).to.be.false; + expect(isSuccessResponse(successResponse)).to.be.true; + }); + }); + + describe('parseStreamingResponse', () => { + it('should yield only result data from successful streaming responses', async () => { + async function* mockStream(): AsyncGenerator { + yield { + jsonrpc: '2.0', + id: 1, + result: { type: 'message', content: 'Hello' } + } as any; + + yield { + jsonrpc: '2.0', + id: 1, + result: { type: 'status', status: 'processing' } + } as any; + } + + const results: any[] = []; + for await (const result of parseStreamingResponse(mockStream())) { + results.push(result); + } + + expect(results).to.have.length(2); + expect(results[0]).to.deep.equal({ type: 'message', content: 'Hello' }); + expect(results[1]).to.deep.equal({ type: 'status', status: 'processing' }); + }); + + it('should throw A2AClientError for error responses in stream', async () => { + async function* mockStreamWithError(): AsyncGenerator { + yield { + jsonrpc: '2.0', + id: 1, + result: { type: 'message', content: 'Hello' } + } as any; + + yield { + jsonrpc: '2.0', + id: 1, + error: { code: -32603, message: 'Internal error' } + }; + } + + const results: any[] = []; + + try { + for await (const result of parseStreamingResponse(mockStreamWithError())) { + results.push(result); + } + expect.fail('Should have thrown A2AClientError'); + } catch (error) { + expect(error).to.be.instanceOf(A2AClientError); + expect(results).to.have.length(1); // Only the first successful result should be processed + expect(results[0]).to.deep.equal({ type: 'message', content: 'Hello' }); + } + }); + }); + + describe('Integration with A2AClient methods', () => { + it('should provide type-safe error handling for client methods', () => { + + // This test demonstrates the improved developer experience + const successResponse: SendMessageSuccessResponse = { + jsonrpc: '2.0', + id: 1, + result: { id: 'task-123', contextId: 'ctx-123', kind: 'task' as const, status: { state: 'submitted' }, history: [] } + }; + + // Developers can access .result directly + // without manual error checking, knowing that errors are thrown as exceptions + expect(successResponse.result).to.exist; + + // For union types, use improved type guards instead of manual property checking + if (isTask(successResponse.result)) { + expect(successResponse.result.id).to.equal('task-123'); + } + }); + + it('should demonstrate improved type guards for union types', () => { + + const taskResult = { id: 'task-123', contextId: 'ctx-123', kind: 'task' as const, status: { state: 'submitted' as const }, history: [] }; + const messageResult = { + messageId: 'msg-123', + kind: 'message' as const, + parts: [], + role: 'agent' as const + }; + + // Improved type guards - cleaner than manual property checking + if (isTask(taskResult)) { + // TypeScript automatically knows this is a Task + expect(taskResult.id).to.equal('task-123'); + expect(taskResult.status.state).to.equal('submitted'); + } + + if (isMessage(messageResult)) { + // TypeScript automatically knows this is a Message + expect(messageResult.messageId).to.equal('msg-123'); + } + + // Pattern-based processing eliminates boilerplate + const taskResult2 = withResultType(taskResult, { + task: (task) => { + return `Processing task: ${task.id}`; + }, + fallback: () => 'Unknown result type' + }); + + const messageResult2 = withResultType(messageResult, { + message: (message) => { + return `Processing message: ${message.messageId}`; + }, + fallback: () => 'Unknown result type' + }); + + expect(taskResult2).to.equal('Processing task: task-123'); + expect(messageResult2).to.equal('Processing message: msg-123'); + }); + }); +}); \ No newline at end of file