diff --git a/.github/conventional-commit-lint.yaml b/.github/conventional-commit-lint.yaml deleted file mode 100644 index c967ffa6..00000000 --- a/.github/conventional-commit-lint.yaml +++ /dev/null @@ -1,2 +0,0 @@ -enabled: true -always_check_pr_title: true diff --git a/.github/workflows/conventional-commits.yml b/.github/workflows/conventional-commits.yml new file mode 100644 index 00000000..d23da45d --- /dev/null +++ b/.github/workflows/conventional-commits.yml @@ -0,0 +1,26 @@ +name: "Conventional Commits" + +on: + pull_request: + types: + - opened + - edited + - synchronize + +permissions: + contents: read + +jobs: + main: + permissions: + pull-requests: read + statuses: write + name: Validate PR Title + runs-on: ubuntu-latest + steps: + - name: semantic-pull-request + uses: amannn/action-semantic-pull-request@v5.5.3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + validateSingleCommit: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f503aa..ce6ff98f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [0.3.2](https://github.com/a2aproject/a2a-js/compare/v0.3.1...v0.3.2) (2025-08-17) + + +### Features + +* Added generic client hooks for HTTP based authentication, and improved agent.json resolution ([#33](https://github.com/a2aproject/a2a-js/issues/33)) ([a9826ac](https://github.com/a2aproject/a2a-js/commit/a9826acde3bb1f741153407e6179fd21f2e7a4bb)) + + +### Bug Fixes + +* fix Incorrect Well-Known Path for Agent Card ([#102](https://github.com/a2aproject/a2a-js/issues/102)) ([3a0f1d0](https://github.com/a2aproject/a2a-js/commit/3a0f1d07843b725c9beaf1078bc43418ff2871ed)) + + +### Miscellaneous Chores + +* release 0.3.2 ([#111](https://github.com/a2aproject/a2a-js/issues/111)) ([03f35e0](https://github.com/a2aproject/a2a-js/commit/03f35e0ed29d2b24df7eddb2a7fe21d0690f503e)) + ## [0.3.1](https://github.com/a2aproject/a2a-js/compare/v0.3.0...v0.3.1) (2025-08-06) diff --git a/README.md b/README.md index c737bc77..18d58004 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ expressApp.listen(PORT, () => { `[MyAgent] Server using new framework started on http://localhost:${PORT}` ); console.log( - `[MyAgent] Agent Card: http://localhost:${PORT}/.well-known/agent.json` + `[MyAgent] Agent Card: http://localhost:${PORT}/.well-known/agent-card.json` ); console.log("[MyAgent] Press Ctrl+C to stop the server"); }); diff --git a/package-lock.json b/package-lock.json index 58377844..e17ef3c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@a2a-js/sdk", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@a2a-js/sdk", - "version": "0.3.1", + "version": "0.3.2", "dependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.23", diff --git a/package.json b/package.json index 5b0daaf5..0deed6b7 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,11 @@ { "name": "@a2a-js/sdk", - "version": "0.3.1", + "version": "0.3.2", "description": "Server & Client SDK for Agent2Agent protocol", - "repository": "google-a2a/a2a-js.git", + "repository": { + "type": "git", + "url": "git+https://github.com/a2aproject/a2a-js.git" + }, "engines": { "node": ">=18" }, diff --git a/src/client/auth-handler.ts b/src/client/auth-handler.ts new file mode 100644 index 00000000..e46dc5e5 --- /dev/null +++ b/src/client/auth-handler.ts @@ -0,0 +1,114 @@ +export interface HttpHeaders { [key: string]: string }; + +/** + * Generic interface for handling authentication for HTTP requests. + * + * - For each HTTP request, this handler is called to provide additional headers to the request through + * the headers() function. + * - After the server returns a response, the shouldRetryWithHeaders() function is called. Usually this + * function responds to a 401 or 403 response or JSON-RPC codes, but can respond to any other signal - + * that is an implementation detail of the AuthenticationHandler. + * - If the shouldRetryWithHeaders() function returns new headers, then the request should retried with the provided + * revised headers. These provisional headers may, or may not, be optimistically stored for subsequent requests - + * that is an implementation detail of the AuthenticationHandler. + * - If the request is successful and the onSuccessfulRetry() is defined, then the onSuccessfulRetry() function is + * called with the headers that were used to successfully complete the request. This callback provides an + * opportunity to save the headers for subsequent requests if they were not already saved. + * + */ +export interface AuthenticationHandler { + /** + * Provides additional HTTP request headers. + * @returns HTTP headers which may include Authorization if available. + */ + headers: () => Promise; + + /** + * For every HTTP response (even 200s) the shouldRetryWithHeaders() method is called. + * This method is supposed to check if the request needs to be retried and if, yes, + * return a set of headers. An A2A server might indicate auth failures in its response + * by JSON-rpc codes, HTTP codes like 401, 403 or headers like WWW-Authenticate. + * + * @param req The RequestInit object used to invoke fetch() + * @param res The fetch Response object + * @returns If the HTTP request should be retried then returns the HTTP headers to use, + * or returns undefined if no retry should be made. + */ + shouldRetryWithHeaders: (req:RequestInit, res:Response) => Promise; + + /** + * If the last HTTP request using the headers from shouldRetryWithHeaders() was successful, and + * this function is implemented, then it will be called with the headers provided from + * shouldRetryWithHeaders(). + * + * This callback allows transient headers to be saved for subsequent requests only when they + * are validated by the server. + */ + onSuccessfulRetry?: (headers:HttpHeaders) => Promise +} + +/** + * Higher-order function that wraps fetch with authentication handling logic. + * Returns a new fetch function that automatically handles authentication retries for 401/403 responses. + * + * @param fetchImpl The underlying fetch implementation to wrap + * @param authHandler Authentication handler for managing auth headers and retries + * @returns A new fetch function with authentication handling capabilities + * + * Usage examples: + * - const authFetch = createAuthHandlingFetch(fetch, authHandler); + * - const response = await authFetch(url, options); + * - const response = await authFetch(url); // Direct function call + */ +export function createAuthenticatingFetchWithRetry( + fetchImpl: typeof fetch, + authHandler: AuthenticationHandler +): typeof fetch { + /** + * Executes a fetch request with authentication handling. + * If the auth handler provides new headers for the shouldRetryWithHeaders() function, + * then the request is retried. + * @param url The URL to fetch + * @param init The fetch request options + * @returns A Promise that resolves to the Response + */ + async function authFetch(url: RequestInfo | URL, init?: RequestInit): Promise { + // Merge auth headers with provided headers + const authHeaders = await authHandler.headers() || {}; + const mergedInit: RequestInit = { + ...(init || {}), + headers: { + ...authHeaders, + ...(init?.headers || {}), + }, + }; + + let response = await fetchImpl(url, mergedInit); + + // Check if the auth handler wants to retry the request with new headers + const updatedHeaders = await authHandler.shouldRetryWithHeaders(mergedInit, response); + if (updatedHeaders) { + // Retry request with revised headers + const retryInit: RequestInit = { + ...(init || {}), + headers: { + ...updatedHeaders, + ...(init?.headers || {}), + }, + }; + response = await fetchImpl(url, retryInit); + + if (response.ok && authHandler.onSuccessfulRetry) { + await authHandler.onSuccessfulRetry(updatedHeaders); // Remember headers that worked + } + } + + return response; + } + + // Copy fetch properties to maintain compatibility + Object.setPrototypeOf(authFetch, Object.getPrototypeOf(fetchImpl)); + Object.defineProperties(authFetch, Object.getOwnPropertyDescriptors(fetchImpl)); + + return authFetch; +} diff --git a/src/client/client.ts b/src/client/client.ts index 14e5524d..d4091e9d 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -31,45 +31,49 @@ import { A2AError, SendMessageSuccessResponse } from '../types.js'; // Assuming schema.ts is in the same directory or appropriately pathed +import { AGENT_CARD_PATH } from "../constants.js"; // Helper type for the data yielded by streaming methods type A2AStreamEventData = Message | Task | TaskStatusUpdateEvent | TaskArtifactUpdateEvent; +export interface A2AClientOptions { + agentCardPath?: string; + fetchImpl?: typeof fetch; +} /** * A2AClient is a TypeScript HTTP client for interacting with A2A-compliant agents. */ export class A2AClient { - private agentBaseUrl: string; - private agentCardPath: string; private agentCardPromise: Promise; - private static readonly DEFAULT_AGENT_CARD_PATH = ".well-known/agent.json"; private requestIdCounter: number = 1; private serviceEndpointUrl?: string; // To be populated from AgentCard after fetching + private fetchImpl: typeof fetch; /** * Constructs an A2AClient instance. * It initiates fetching the agent card from the provided agent baseUrl. - * The Agent Card is fetched from a path relative to the agentBaseUrl, which defaults to '.well-known/agent.json'. + * The Agent Card is fetched from a path relative to the agentBaseUrl, which defaults to '.well-known/agent-card.json'. * The `url` field from the Agent Card will be used as the RPC service endpoint. * @param agentBaseUrl The base URL of the A2A agent (e.g., https://agent.example.com) - * @param agentCardPath path to the agent card, defaults to .well-known/agent.json + * @param options Optional. The options for the A2AClient including the fetch implementation, agent card path, and authentication handler. */ - constructor(agentBaseUrl: string, agentCardPath: string = A2AClient.DEFAULT_AGENT_CARD_PATH) { - this.agentBaseUrl = agentBaseUrl.replace(/\/$/, ""); // Remove trailing slash if any - this.agentCardPath = agentCardPath.replace(/^\//, ""); // Remove leading slash if any - this.agentCardPromise = this._fetchAndCacheAgentCard(); + constructor(agentBaseUrl: string, options?: A2AClientOptions) { + this.fetchImpl = options?.fetchImpl ?? fetch; + this.agentCardPromise = this._fetchAndCacheAgentCard( agentBaseUrl, options?.agentCardPath ); } /** * Fetches the Agent Card from the agent's well-known URI and caches its service endpoint URL. * This method is called by the constructor. + * @param agentBaseUrl The base URL of the A2A agent (e.g., https://agent.example.com) + * @param agentCardPath path to the agent card, defaults to .well-known/agent-card.json * @returns A Promise that resolves to the AgentCard. */ - private async _fetchAndCacheAgentCard(): Promise { - const agentCardUrl = `${this.agentBaseUrl}/${this.agentCardPath}` + private async _fetchAndCacheAgentCard( agentBaseUrl: string, agentCardPath?: string ): Promise { try { - const response = await fetch(agentCardUrl, { + const agentCardUrl = this.resolveAgentCardUrl( agentBaseUrl, agentCardPath ); + const response = await this.fetchImpl(agentCardUrl, { headers: { 'Accept': 'application/json' }, }); if (!response.ok) { @@ -82,7 +86,7 @@ export class A2AClient { this.serviceEndpointUrl = agentCard.url; // Cache the service endpoint URL from the agent card return agentCard; } catch (error) { - console.error("Error fetching or parsing Agent Card:"); + console.error("Error fetching or parsing Agent Card:", error); // Allow the promise to reject so users of agentCardPromise can handle it. throw error; } @@ -93,14 +97,15 @@ export class A2AClient { * If an `agentBaseUrl` is provided, it fetches the card from that specific URL. * Otherwise, it returns the card fetched and cached during client construction. * @param agentBaseUrl Optional. The base URL of the agent to fetch the card from. - * @param agentCardPath path to the agent card, defaults to .well-known/agent.json + * @param agentCardPath path to the agent card, defaults to .well-known/agent-card.json * If provided, this will fetch a new card, not use the cached one from the constructor's URL. * @returns A Promise that resolves to the AgentCard. */ - public async getAgentCard(agentBaseUrl?: string, agentCardPath: string = A2AClient.DEFAULT_AGENT_CARD_PATH): Promise { + public async getAgentCard(agentBaseUrl?: string, agentCardPath?: string): Promise { if (agentBaseUrl) { - const agentCardUrl = `${agentBaseUrl.replace(/\/$/, "")}/${agentCardPath.replace(/^\//, "")}` - const response = await fetch(agentCardUrl, { + const agentCardUrl = this.resolveAgentCardUrl( agentBaseUrl, agentCardPath ); + + const response = await this.fetchImpl(agentCardUrl, { headers: { 'Accept': 'application/json' }, }); if (!response.ok) { @@ -112,6 +117,15 @@ export class A2AClient { return this.agentCardPromise; } + /** + * Determines the agent card URL based on the agent URL. + * @param agentBaseUrl The agent URL. + * @param agentCardPath Optional relative path to the agent card, defaults to .well-known/agent-card.json + */ + private resolveAgentCardUrl( agentBaseUrl: string, agentCardPath: string = AGENT_CARD_PATH ): string { + return `${agentBaseUrl.replace(/\/$/, "")}/${agentCardPath.replace(/^\//, "")}`; + } + /** * Gets the RPC service endpoint URL. Ensures the agent card has been fetched first. * @returns A Promise that resolves to the service endpoint URL string. @@ -149,23 +163,17 @@ export class A2AClient { id: requestId, }; - const httpResponse = await fetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Accept": "application/json", // Expect JSON response for non-streaming requests - }, - body: JSON.stringify(rpcRequest), - }); + const httpResponse = await this._fetchRpc( endpoint, rpcRequest ); if (!httpResponse.ok) { let errorBodyText = '(empty or non-JSON response)'; try { errorBodyText = await httpResponse.text(); const errorJson = JSON.parse(errorBodyText); - // If the body is a valid JSON-RPC error response, let it be handled by the standard parsing below. - // However, if it's not even a JSON-RPC structure but still an error, throw based on HTTP status. - if (!errorJson.jsonrpc && errorJson.error) { // Check if it's a JSON-RPC error structure + // If the body is a valid JSON-RPC error response, return it as a proper JSON-RPC error response. + if (errorJson.jsonrpc && errorJson.error) { + return errorJson as TResponse; + } else if (!errorJson.jsonrpc && errorJson.error) { // Check if it's a JSON-RPC error structure throw new Error(`RPC error for ${method}: ${errorJson.error.message} (Code: ${errorJson.error.code}, HTTP Status: ${httpResponse.status}) Data: ${JSON.stringify(errorJson.error.data || {})}`); } else if (!errorJson.jsonrpc) { throw new Error(`HTTP error for ${method}! Status: ${httpResponse.status} ${httpResponse.statusText}. Response: ${errorBodyText}`); @@ -190,6 +198,25 @@ export class A2AClient { return rpcResponse as TResponse; } + /** + * Internal helper method to fetch the RPC service endpoint. + * @param url The URL to fetch. + * @param rpcRequest The JSON-RPC request to send. + * @param acceptHeader The Accept header to use. Defaults to "application/json". + * @returns A Promise that resolves to the fetch HTTP response. + */ + private async _fetchRpc( url: string, rpcRequest: JSONRPCRequest, acceptHeader: string = "application/json" ): Promise { + const requestInit: RequestInit = { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": acceptHeader, // Expect JSON response for non-streaming requests + }, + body: JSON.stringify(rpcRequest) + }; + + return this.fetchImpl(url, requestInit); + } /** * Sends a message to the agent. @@ -227,14 +254,7 @@ export class A2AClient { id: clientRequestId, }; - const response = await fetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Accept": "text/event-stream", // Crucial for SSE - }, - body: JSON.stringify(rpcRequest), - }); + const response = await this._fetchRpc( endpoint, rpcRequest, "text/event-stream" ); if (!response.ok) { // Attempt to read error body for more details @@ -334,7 +354,7 @@ export class A2AClient { id: clientRequestId, }; - const response = await fetch(endpoint, { + const response = await this.fetchImpl(endpoint, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/src/client/index.ts b/src/client/index.ts index a3e9e7c7..c6689cc4 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -3,3 +3,5 @@ */ export { A2AClient } from "./client.js"; +export type { A2AClientOptions } from "./client.js"; +export * from "./auth-handler.js"; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 00000000..d84b820e --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,8 @@ +/** + * Shared constants for the A2A library + */ + +/** + * The well-known path for the agent card + */ +export const AGENT_CARD_PATH = ".well-known/agent-card.json"; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 5a1a02e1..34fe0008 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,10 @@ /** - * Main entry point for the A2A Server V2 library. * Exports the common types. + * + * Use the client/index.ts file to import the client-only codebase. + * Use the server/index.ts file to import the server-only codebase. */ export * from "./types.js"; export type { A2AResponse } from "./a2a_response.js"; +export { AGENT_CARD_PATH } from "./constants.js"; diff --git a/src/samples/agents/movie-agent/index.ts b/src/samples/agents/movie-agent/index.ts index b69479bc..6c6621d3 100644 --- a/src/samples/agents/movie-agent/index.ts +++ b/src/samples/agents/movie-agent/index.ts @@ -314,7 +314,7 @@ async function main() { const PORT = process.env.PORT || 41241; expressApp.listen(PORT, () => { console.log(`[MovieAgent] Server using new framework started on http://localhost:${PORT}`); - console.log(`[MovieAgent] Agent Card: http://localhost:${PORT}/.well-known/agent.json`); + console.log(`[MovieAgent] Agent Card: http://localhost:${PORT}/.well-known/agent-card.json`); console.log('[MovieAgent] Press Ctrl+C to stop the server'); }); } diff --git a/src/server/express/a2a_express_app.ts b/src/server/express/a2a_express_app.ts index ef8aa3ae..e4b352cd 100644 --- a/src/server/express/a2a_express_app.ts +++ b/src/server/express/a2a_express_app.ts @@ -4,6 +4,7 @@ import { A2AError } from "../error.js"; import { JSONRPCErrorResponse, JSONRPCSuccessResponse, JSONRPCResponse } from "../../index.js"; import { A2ARequestHandler } from "../request_handler/a2a_request_handler.js"; import { JsonRpcTransportHandler } from "../transports/jsonrpc_transport_handler.js"; +import { AGENT_CARD_PATH } from "../../constants.js"; export class A2AExpressApp { private requestHandler: A2ARequestHandler; // Kept for getAgentCard @@ -19,17 +20,19 @@ export class A2AExpressApp { * @param app Optional existing Express app. * @param baseUrl The base URL for A2A endpoints (e.g., "/a2a/api"). * @param middlewares Optional array of Express middlewares to apply to the A2A routes. + * @param agentCardPath Optional custom path for the agent card endpoint (defaults to /.well-known/agent-card.json). * @returns The Express app with A2A routes. */ public setupRoutes( app: Express, baseUrl: string = "", - middlewares?: Array + middlewares?: Array, + agentCardPath: string = AGENT_CARD_PATH ): Express { const router = express.Router(); router.use(express.json(), ...(middlewares ?? [])); - router.get("/.well-known/agent.json", async (req: Request, res: Response) => { + router.get(`/${agentCardPath}`, async (req: Request, res: Response) => { try { // getAgentCard is on A2ARequestHandler, which DefaultRequestHandler implements const agentCard = await this.requestHandler.getAgentCard(); diff --git a/test/client/client.spec.ts b/test/client/client.spec.ts new file mode 100644 index 00000000..3a002d17 --- /dev/null +++ b/test/client/client.spec.ts @@ -0,0 +1,279 @@ +import { describe, it, beforeEach, afterEach } from 'mocha'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { A2AClient } from '../../src/client/client.js'; +import { MessageSendParams, TextPart, SendMessageResponse, SendMessageSuccessResponse } from '../../src/types.js'; +import { AGENT_CARD_PATH } from '../../src/constants.js'; +import { extractRequestId, createResponse, createAgentCardResponse, createMockAgentCard, createMockFetch } from './util.js'; + +// Helper function to check if response is a success response +function isSuccessResponse(response: SendMessageResponse): response is SendMessageSuccessResponse { + return 'result' in response; +} + +describe('A2AClient Basic Tests', () => { + let client: A2AClient; + let mockFetch: sinon.SinonStub; + let originalConsoleError: typeof console.error; + + beforeEach(() => { + // Suppress console.error during tests to avoid noise + originalConsoleError = console.error; + console.error = () => {}; + + // Create a fresh mock fetch for each test + mockFetch = createMockFetch(); + client = new A2AClient('https://test-agent.example.com', { + fetchImpl: mockFetch + }); + }); + + afterEach(() => { + // Restore console.error + console.error = originalConsoleError; + sinon.restore(); + }); + + describe('Client Initialization', () => { + it('should initialize client with default options', () => { + // Use a mock fetch to avoid real HTTP requests during testing + const mockFetchForDefault = createMockFetch(); + const basicClient = new A2AClient('https://test-agent.example.com', { + fetchImpl: mockFetchForDefault + }); + expect(basicClient).to.be.instanceOf(A2AClient); + }); + + it('should initialize client with custom fetch implementation', () => { + const customFetch = sinon.stub(); + const clientWithCustomFetch = new A2AClient('https://test-agent.example.com', { + fetchImpl: customFetch + }); + expect(clientWithCustomFetch).to.be.instanceOf(A2AClient); + }); + + it('should fetch agent card during initialization', async () => { + // Wait for agent card to be fetched + await client.getAgentCard(); + + expect(mockFetch.callCount).to.be.greaterThan(0); + const agentCardCall = mockFetch.getCalls().find(call => + call.args[0].includes(AGENT_CARD_PATH) + ); + expect(agentCardCall).to.exist; + }); + }); + + describe('Agent Card Handling', () => { + it('should fetch and parse agent card correctly', async () => { + const agentCard = await client.getAgentCard(); + + expect(agentCard).to.have.property('name', 'Test Agent'); + expect(agentCard).to.have.property('description', 'A test agent for basic client testing'); + expect(agentCard).to.have.property('url', 'https://test-agent.example.com/api'); + expect(agentCard).to.have.property('capabilities'); + expect(agentCard.capabilities).to.have.property('streaming', true); + expect(agentCard.capabilities).to.have.property('pushNotifications', true); + }); + + it('should cache agent card for subsequent requests', async () => { + // First call + await client.getAgentCard(); + + // Second call - should not fetch agent card again + await client.getAgentCard(); + + const agentCardCalls = mockFetch.getCalls().filter(call => + call.args[0].includes(AGENT_CARD_PATH) + ); + + expect(agentCardCalls).to.have.length(1); + }); + + it('should handle agent card fetch errors', async () => { + const errorFetch = sinon.stub().callsFake(async (url: string) => { + if (url.includes(AGENT_CARD_PATH)) { + return new Response('Not found', { status: 404 }); + } + return new Response('Not found', { status: 404 }); + }); + + // Create client after setting up the mock to avoid console.error during construction + const errorClient = new A2AClient('https://test-agent.example.com', { + fetchImpl: errorFetch + }); + + try { + await errorClient.getAgentCard(); + expect.fail('Expected error to be thrown'); + } catch (error) { + expect(error).to.be.instanceOf(Error); + expect((error as Error).message).to.include('Failed to fetch Agent Card'); + } + }); + }); + + describe('Message Sending', () => { + it('should send message successfully', async () => { + const messageParams: MessageSendParams = { + message: { + kind: 'message', + messageId: 'test-msg-1', + role: 'user', + parts: [{ + kind: 'text', + text: 'Hello, agent!' + } as TextPart] + } + }; + + const result = await client.sendMessage(messageParams); + + // Verify fetch was called + expect(mockFetch.callCount).to.be.greaterThan(0); + + // Verify RPC call was made + const rpcCall = mockFetch.getCalls().find(call => + call.args[0].includes('/api') + ); + expect(rpcCall).to.exist; + expect(rpcCall.args[1]).to.deep.include({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + }); + expect(rpcCall.args[1].body).to.include('"method":"message/send"'); + + // Verify the result + expect(isSuccessResponse(result)).to.be.true; + if (isSuccessResponse(result)) { + expect(result.result).to.have.property('kind', 'message'); + expect(result.result).to.have.property('messageId', 'msg-123'); + } + }); + + it('should handle message sending errors', async () => { + const errorFetch = sinon.stub().callsFake(async (url: string, options?: RequestInit) => { + if (url.includes(AGENT_CARD_PATH)) { + const mockAgentCard = createMockAgentCard({ + description: 'A test agent for error testing' + }); + return createAgentCardResponse(mockAgentCard); + } + + if (url.includes('/api')) { + // Extract request ID from the request body + const requestId = extractRequestId(options); + + return createResponse(requestId, undefined, { + code: -32603, + message: 'Internal error' + }, 500); + } + + return new Response('Not found', { status: 404 }); + }); + + const errorClient = new A2AClient('https://test-agent.example.com', { + fetchImpl: errorFetch + }); + + const messageParams: MessageSendParams = { + message: { + kind: 'message', + messageId: 'test-msg-error', + role: 'user', + parts: [{ + kind: 'text', + text: 'This should fail' + } as TextPart] + } + }; + + try { + await errorClient.sendMessage(messageParams); + expect.fail('Expected error to be thrown'); + } catch (error) { + expect(error).to.be.instanceOf(Error); + } + }); + }); + + describe('Error Handling', () => { + it('should handle network errors gracefully', async () => { + const networkErrorFetch = sinon.stub().rejects(new Error('Network error')); + + const networkErrorClient = new A2AClient('https://test-agent.example.com', { + fetchImpl: networkErrorFetch + }); + + try { + await networkErrorClient.getAgentCard(); + expect.fail('Expected error to be thrown'); + } catch (error) { + expect(error).to.be.instanceOf(Error); + expect((error as Error).message).to.include('Network error'); + } + }); + + it('should handle malformed JSON responses', async () => { + const malformedFetch = sinon.stub().callsFake(async (url: string) => { + if (url.includes(AGENT_CARD_PATH)) { + return new Response('Invalid JSON', { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } + return new Response('Not found', { status: 404 }); + }); + + const malformedClient = new A2AClient('https://test-agent.example.com', { + fetchImpl: malformedFetch + }); + + try { + await malformedClient.getAgentCard(); + expect.fail('Expected error to be thrown'); + } catch (error) { + expect(error).to.be.instanceOf(Error); + } + }); + + it('should handle missing agent card URL', async () => { + const missingUrlFetch = sinon.stub().callsFake(async (url: string) => { + if (url.includes(AGENT_CARD_PATH)) { + const invalidAgentCard = { + name: 'Test Agent', + description: 'A test agent without URL', + protocolVersion: '1.0.0', + version: '1.0.0', + // Missing url field + defaultInputModes: ['text'], + defaultOutputModes: ['text'], + capabilities: { + streaming: true, + pushNotifications: true + }, + skills: [] + }; + return createAgentCardResponse(invalidAgentCard); + } + return new Response('Not found', { status: 404 }); + }); + + const missingUrlClient = new A2AClient('https://test-agent.example.com', { + fetchImpl: missingUrlFetch + }); + + try { + await missingUrlClient.getAgentCard(); + expect.fail('Expected error to be thrown'); + } catch (error) { + expect(error).to.be.instanceOf(Error); + expect((error as Error).message).to.include("does not contain a valid 'url'"); + } + }); + }); +}); diff --git a/test/client/client_auth.spec.ts b/test/client/client_auth.spec.ts new file mode 100644 index 00000000..662f2043 --- /dev/null +++ b/test/client/client_auth.spec.ts @@ -0,0 +1,512 @@ +import { describe, it, beforeEach, afterEach } from 'mocha'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { A2AClient } from '../../src/client/client.js'; +import { AuthenticationHandler, HttpHeaders, createAuthenticatingFetchWithRetry } from '../../src/client/auth-handler.js'; +import {SendMessageResponse, SendMessageSuccessResponse } from '../../src/types.js'; +import { AGENT_CARD_PATH } from '../../src/constants.js'; +import { + createMessageParams, + createMockFetch +} from './util.js'; + + +// Challenge manager class for authentication testing +class ChallengeManager { + private challengeStore: Set = new Set(); + + createChallenge(): string { + const challenge = Math.random().toString(36).substring(2, 18); // just a random string + this.challengeStore.add(challenge); + return challenge; + } + + // used by clients to sign challenges + static signChallenge(challenge: string): string { + return challenge + '.' + challenge.split('.').reverse().join(''); + } + + // verify the "signature" as simply the reverse of the challenge + verifyToken(token: string): boolean { + const [challenge, signature] = token.split('.'); + if (!this.challengeStore.has(challenge)) + return false; + + return signature === challenge.split('.').reverse().join(''); + } + + clearStore(): void { + this.challengeStore.clear(); + } +} + +const challengeManager = new ChallengeManager(); + +// Mock authentication handler that simulates generating tokens and confirming signatures +class MockAuthHandler implements AuthenticationHandler { + private authorization: string | null = null; + + async headers(): Promise { + return this.authorization ? { 'Authorization': this.authorization } : {}; + } + + async shouldRetryWithHeaders(req: RequestInit, res: Response): Promise { + // Simulate 401/403 response handling + if (res.status !== 401 && res.status !== 403) + return undefined; + + // Parse WWW-Authenticate header to extract the token68/challenge value + const [scheme, challenge] = res.headers.get('WWW-Authenticate')?.split(/\s+/) || []; + if (scheme !== 'Bearer') + return undefined; // Not the type we expected for this test + + // Use the ChallengeManager to sign the challenge + const token = ChallengeManager.signChallenge(challenge); + + // have the client try the token, BUT don't save it in case the client doesn't accept it + return { 'Authorization': `Bearer ${token}` }; + } + + async onSuccessfulRetry(headers: HttpHeaders): Promise { + // Remember successful authorization header + const auth = headers['Authorization']; + if (auth) + this.authorization = auth; + } +} + +// Helper function to check if response is a success response +function isSuccessResponse(response: SendMessageResponse): response is SendMessageSuccessResponse { + return 'result' in response; +} + +describe('A2AClient Authentication Tests', () => { + let client: A2AClient; + let authHandler: MockAuthHandler; + let mockFetch: sinon.SinonStub; + let originalConsoleError: typeof console.error; + + beforeEach(() => { + // Suppress console.error during tests to avoid noise + originalConsoleError = console.error; + console.error = () => {}; + + // Create a fresh mock fetch for each test + mockFetch = createMockFetch({ + requiresAuth: true, + agentDescription: 'A test agent for authentication testing', + authErrorConfig: { + code: -32001, + message: 'Authentication required', + challenge: challengeManager.createChallenge() + } + }); + + authHandler = new MockAuthHandler(); + // Use AuthHandlingFetch to wrap the mock fetch with authentication handling + const authHandlingFetch = createAuthenticatingFetchWithRetry(mockFetch, authHandler); + client = new A2AClient('https://test-agent.example.com', { + fetchImpl: authHandlingFetch + }); + }); + + afterEach(() => { + // Restore console.error + console.error = originalConsoleError; + sinon.restore(); + }); + + describe('Authentication Flow', () => { + it('should handle authentication flow correctly', async () => { + const messageParams = createMessageParams({ + messageId: 'test-msg-1', + text: 'Hello, agent!' + }); + + // This should trigger the authentication flow + const result = await client.sendMessage(messageParams); + + // Verify fetch was called multiple times + expect(mockFetch.callCount).to.equal(3); + + // First call: agent card fetch + expect(mockFetch.firstCall.args[0]).to.equal(`https://test-agent.example.com/${AGENT_CARD_PATH}`); + expect(mockFetch.firstCall.args[1]).to.deep.include({ + headers: { 'Accept': 'application/json' } + }); + + // Second call: RPC request without auth header + expect(mockFetch.secondCall.args[0]).to.equal('https://test-agent.example.com/api'); + expect(mockFetch.secondCall.args[1]).to.deep.include({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + }); + expect(mockFetch.secondCall.args[1].body).to.include('"method":"message/send"'); + + // Third call: RPC request with auth header + expect(mockFetch.thirdCall.args[0]).to.equal('https://test-agent.example.com/api'); + expect(mockFetch.thirdCall.args[1]).to.deep.include({ + method: 'POST' + }); + // Check headers separately to avoid issues with Authorization header + expect(mockFetch.thirdCall.args[1].headers).to.have.property('Content-Type', 'application/json'); + expect(mockFetch.thirdCall.args[1].headers).to.have.property('Accept', 'application/json'); + expect(mockFetch.thirdCall.args[1].headers).to.have.property('Authorization'); + + expect(mockFetch.thirdCall.args[1].headers['Authorization']).to.match(/^Bearer .+$/); + expect(mockFetch.thirdCall.args[1].body).to.include('"method":"message/send"'); + + // Verify the result + expect(isSuccessResponse(result)).to.be.true; + if (isSuccessResponse(result)) { + expect(result.result).to.have.property('kind', 'message'); + } + }); + + it('should reuse authentication token for subsequent requests', async () => { + const messageParams = createMessageParams({ + messageId: 'test-msg-2', + text: 'Second message' + }); + + // First request - should trigger auth flow + const result1 = await client.sendMessage(messageParams); + + // Capture the token from the first request + const firstRequestAuthCall = mockFetch.getCalls().find(call => + call.args[0].includes('/api') && + call.args[1]?.headers?.['Authorization'] + ); + const firstRequestToken = firstRequestAuthCall?.args[1]?.headers?.['Authorization']; + + // Second request - should use existing token + const result2 = await client.sendMessage(messageParams); + + // Total calls should be 4: 3 for first request + 1 for second request (both agent card and auth token cached) + expect(mockFetch.callCount).to.equal(4); + + // Second request should start from call #4 (after the first 3 calls) + const secondRequestCalls = mockFetch.getCalls().slice(3); + + // Only one call for second request: RPC request with auth header (agent card and token cached) + expect(secondRequestCalls[0].args[0]).to.equal('https://test-agent.example.com/api'); + expect(secondRequestCalls[0].args[1].headers).to.have.property('Authorization'); + + // Should use the exact same token from the first request + expect(secondRequestCalls[0].args[1].headers['Authorization']).to.equal(firstRequestToken); + + expect(isSuccessResponse(result2)).to.be.true; + }); + }); + + describe('Authentication Handler Integration', () => { + it('should call auth handler methods correctly', async () => { + const authHandlerSpy = { + headers: sinon.spy(authHandler, 'headers'), + shouldRetryWithHeaders: sinon.spy(authHandler, 'shouldRetryWithHeaders'), + onSuccess: sinon.spy(authHandler, 'onSuccessfulRetry') + }; + + const messageParams = createMessageParams({ + messageId: 'test-msg-4', + text: 'Test auth handler' + }); + + await client.sendMessage(messageParams); + + // Verify auth handler methods were called + expect(authHandlerSpy.headers.called).to.be.true; + expect(authHandlerSpy.shouldRetryWithHeaders.called).to.be.true; + expect(authHandlerSpy.onSuccess.called).to.be.true; + }); + + it('should handle auth handler returning undefined for retry', async () => { + // Create a mock that doesn't retry + const noRetryHandler = new MockAuthHandler(); + const originalShouldRetry = noRetryHandler.shouldRetryWithHeaders.bind(noRetryHandler); + noRetryHandler.shouldRetryWithHeaders = sinon.stub().resolves(undefined); + + const clientNoRetry = new A2AClient('https://test-agent.example.com', { + fetchImpl: mockFetch + }); + + const messageParams = createMessageParams({ + messageId: 'test-msg-5', + text: 'No retry test' + }); + + // This should fail because we're not retrying with auth + try { + await clientNoRetry.sendMessage(messageParams); + expect.fail('Expected error to be thrown'); + } catch (error) { + expect(error).to.be.instanceOf(Error); + } + }); + + it('should retry with new auth headers', async () => { + // Create a mock that tracks the Authorization headers sent + const authRetryTestFetch = createMockFetch({ + agentDescription: 'A test agent for authentication testing', + messageConfig: { + messageId: 'msg-auth-retry', + text: 'Test auth retry' + }, + captureAuthHeaders: true, + behavior: 'authRetry' + }); + const { capturedAuthHeaders } = authRetryTestFetch; + + const authHandlingFetch = createAuthenticatingFetchWithRetry(authRetryTestFetch, authHandler); + const clientAuthTest = new A2AClient('https://test-agent.example.com', { + fetchImpl: authHandlingFetch + }); + + const messageParams = createMessageParams({ + messageId: 'test-msg-auth-retry', + text: 'Test auth retry' + }); + + // This should trigger the auth flow and succeed + const result = await clientAuthTest.sendMessage(messageParams); + + // Verify the Authorization headers were sent correctly + // With AuthHandlingFetch, the auth handler makes the retry internally, so we see both calls + expect(capturedAuthHeaders).to.have.length(2); + expect(capturedAuthHeaders[0]).to.equal(''); // First call: no Authorization header + expect(capturedAuthHeaders[1]).to.be.a('string').and.not.be.empty; // Second call: with Authorization header + + // Verify the result + expect(isSuccessResponse(result)).to.be.true; + }); + + it('should continue without authentication when server does not return 401', async () => { + // Create a mock that doesn't require authentication + const noAuthRequiredFetch = createMockFetch({ + requiresAuth: false, + agentDescription: 'A test agent that does not require authentication', + messageConfig: { + messageId: 'msg-no-auth-required', + text: 'Test without authentication' + }, + captureAuthHeaders: true + }); + const { capturedAuthHeaders } = noAuthRequiredFetch; + + const clientNoAuth = new A2AClient('https://test-agent.example.com', { + fetchImpl: noAuthRequiredFetch + }); + + const messageParams = createMessageParams({ + messageId: 'test-msg-no-auth', + text: 'Test without authentication' + }); + + // This should succeed without any authentication flow + const result = await clientNoAuth.sendMessage(messageParams); + + // Verify that no Authorization headers were sent + expect(capturedAuthHeaders).to.have.length(1); + expect(capturedAuthHeaders[0]).to.equal(''); // No auth header sent + + // Verify the result + expect(isSuccessResponse(result)).to.be.true; + if (isSuccessResponse(result)) { + // Check if result is a Message1 (which has messageId) or Task2 + if ('messageId' in result.result) { + expect(result.result.messageId).to.equal('msg-no-auth-required'); + } + } + }); + + it('Client pipes server errors when no auth handler is specified', async () => { + // Create a mock that returns 401 without authHandler + const fetchWithApiError = createMockFetch({ + agentDescription: 'A test agent that requires authentication', + behavior: 'alwaysFail' + }); + + // Create client WITHOUT authHandler + const clientNoAuthHandler = new A2AClient('https://test-agent.example.com', { + fetchImpl: fetchWithApiError + }); + + const messageParams = createMessageParams({ + messageId: 'test-msg-no-auth-handler', + 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'); + + // Verify that fetch was called only once (no retry attempted) + expect(fetchWithApiError.callCount).to.equal(2); // One for agent card, one for API call + }); + }); +}); + +describe('AuthHandlingFetch Tests', () => { + let mockFetch: sinon.SinonStub; + let authHandler: MockAuthHandler; + let authHandlingFetch: ReturnType; + + beforeEach(() => { + mockFetch = createMockFetch({ + requiresAuth: true, + agentDescription: 'A test agent for authentication testing', + authErrorConfig: { + code: -32001, + message: 'Authentication required', + challenge: challengeManager.createChallenge() + } + }); + authHandler = new MockAuthHandler(); + authHandlingFetch = createAuthenticatingFetchWithRetry(mockFetch, authHandler); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('Constructor and Function Call', () => { + it('should create a callable instance', () => { + expect(typeof authHandlingFetch).to.equal('function'); + }); + + it('should support direct function calls', async () => { + const response = await authHandlingFetch('https://test.example.com/api'); + expect(response).to.be.instanceOf(Response); + }); + }); + + describe('Header Merging', () => { + it('should merge auth headers with provided headers when auth headers exist', async () => { + // Create an auth handler that has stored authorization headers + const authHandlerWithHeaders = new MockAuthHandler(); + + // Simulate a successful authentication by calling onSuccessfulRetry + // This will store the Authorization header in the auth handler + await authHandlerWithHeaders.onSuccessfulRetry({ + 'Authorization': 'Bearer test-token-123' + }); + + const authHandlingFetchWithHeaders = createAuthenticatingFetchWithRetry(mockFetch, authHandlerWithHeaders); + + await authHandlingFetchWithHeaders('https://test.example.com/api', { + headers: { + 'Content-Type': 'application/json', + 'Custom-Header': 'custom-value' + } + }); + + // Verify that the fetch was called with merged headers including auth headers + const fetchCall = mockFetch.getCall(0); + const headers = fetchCall.args[1]?.headers as Record; + + // Should include both user headers and auth headers + expect(headers).to.include({ + 'Content-Type': 'application/json', + 'Custom-Header': 'custom-value', + 'Authorization': 'Bearer test-token-123' + }); + + // Verify the auth handler's headers method returns the stored authorization + const storedHeaders = await authHandlerWithHeaders.headers(); + expect(storedHeaders['Authorization']).to.equal('Bearer test-token-123'); + }); + + it('should handle empty headers gracefully', async () => { + const emptyAuthHandler = new MockAuthHandler(); + const emptyAuthFetch = createAuthenticatingFetchWithRetry(mockFetch, emptyAuthHandler); + + await emptyAuthFetch('https://test.example.com/api'); + + const fetchCall = mockFetch.getCall(0); + expect(fetchCall.args[1]).to.exist; + }); + }); + + describe('Success Callback', () => { + it('should call onSuccessfulRetry when retry succeeds', async () => { + const successAuthHandler = new MockAuthHandler(); + const onSuccessSpy = sinon.spy(successAuthHandler, 'onSuccessfulRetry'); + + // Create a modified version of the existing mockFetch that returns 401 first, then 200 + const successMockFetch = createMockFetch({ + messageConfig: { + messageId: 'msg-success', + text: 'Success after retry' + }, + behavior: 'authRetry' + }); + + const successAuthFetch = createAuthenticatingFetchWithRetry(successMockFetch, successAuthHandler); + + await successAuthFetch('https://test.example.com/api'); + + expect(onSuccessSpy.called).to.be.true; + expect(onSuccessSpy.firstCall.args[0]).to.deep.include({ + 'Authorization': 'Bearer challenge123.challenge123' + }); + }); + + it('should not call onSuccessfulRetry when retry fails', async () => { + const failAuthHandler = new MockAuthHandler(); + const onSuccessSpy = sinon.spy(failAuthHandler, 'onSuccessfulRetry'); + + const failFetch = createAuthenticatingFetchWithRetry(mockFetch, failAuthHandler); + + // Mock fetch to return 401 first, then 401 again + const failMockFetch = createMockFetch({ + behavior: 'alwaysFail' + }); + + const failAuthFetch = createAuthenticatingFetchWithRetry(failMockFetch, failAuthHandler); + + const response = await failAuthFetch('https://test.example.com/api'); + + expect(onSuccessSpy.called).to.be.false; + expect(response.status).to.equal(401); + }); + }); + + describe('Error Handling', () => { + it('should propagate fetch errors', async () => { + const errorFetch = sinon.stub().rejects(new Error('Network error')); + const errorAuthFetch = createAuthenticatingFetchWithRetry(errorFetch, authHandler); + + try { + await errorAuthFetch('https://test.example.com/api'); + expect.fail('Expected error to be thrown'); + } catch (error) { + expect(error).to.be.instanceOf(Error); + expect((error as Error).message).to.include('Network error'); + } + }); + + it('should handle auth handler errors gracefully', async () => { + const errorAuthHandler = new MockAuthHandler(); + const shouldRetrySpy = sinon.stub(errorAuthHandler, 'shouldRetryWithHeaders'); + shouldRetrySpy.rejects(new Error('Auth handler error')); + + const errorAuthFetch = createAuthenticatingFetchWithRetry(mockFetch, errorAuthHandler); + + try { + await errorAuthFetch('https://test.example.com/api'); + expect.fail('Expected error to be thrown'); + } catch (error) { + expect(error).to.be.instanceOf(Error); + expect((error as Error).message).to.include('Auth handler error'); + } + }); + }); +}); diff --git a/test/client/util.ts b/test/client/util.ts new file mode 100644 index 00000000..9ed265f2 --- /dev/null +++ b/test/client/util.ts @@ -0,0 +1,329 @@ +/** + * Utility functions for A2A client tests + */ + +import sinon from 'sinon'; +import { AGENT_CARD_PATH } from '../../src/constants.js'; + +/** + * Extracts the request ID from a RequestInit options object. + * Parses the JSON body and returns the 'id' field, or 1 as default. + * + * @param options - The RequestInit options object containing the request body + * @returns The request ID as a number, defaults to 1 if not found or parsing fails + */ +export function extractRequestId(options?: RequestInit): number { + if (!options?.body) { + return 1; + } + + try { + const requestBody = JSON.parse(options.body as string); + return requestBody.id || 1; + } catch (e) { + // If parsing fails, use default ID + return 1; + } +} + +/** + * Factory function to create fresh Response objects for agent card endpoints. + * Agent cards are returned as raw JSON, not JSON-RPC responses. + * + * @param data - The agent card data to include in the response + * @param status - HTTP status code (defaults to 200) + * @param headers - Additional headers to include in the response + * @returns A fresh Response object with the specified data + */ +export function createAgentCardResponse( + data: any, + status: number = 200, + headers: Record = {} +): Response { + const defaultHeaders = { 'Content-Type': 'application/json' }; + const responseHeaders = { ...defaultHeaders, ...headers }; + + // Create a fresh body each time to avoid "Body is unusable" errors + const body = JSON.stringify(data); + + return new Response(body, { + status, + headers: responseHeaders + }); +} + +/** + * Factory function to create fresh Response objects that can be read multiple times. + * Creates a proper JSON-RPC 2.0 response structure. + * + * @param id - The response ID (used for JSON-RPC responses) + * @param result - The result data to include in the response (for success responses) + * @param error - Optional error object for error responses (mutually exclusive with result) + * @param status - HTTP status code (defaults to 200 for success, 500 for errors) + * @param headers - Additional headers to include in the response + * @returns A fresh Response object with the specified data + */ +export function createResponse( + id: number, + result?: any, + error?: { code: number; message: string; data?: any }, + status: number = 200, + headers: Record = {} +): Response { + const defaultHeaders = { 'Content-Type': 'application/json' }; + const responseHeaders = { ...defaultHeaders, ...headers }; + + // Construct the JSON-RPC response structure + const jsonRpcResponse: any = { + jsonrpc: "2.0", + id: id + }; + + // Add either result or error (mutually exclusive) + if (error) { + jsonRpcResponse.error = error; + // Use provided status or default to 500 for errors + status = status !== 200 ? status : 500; + } else { + jsonRpcResponse.result = result; + } + + return new Response(JSON.stringify(jsonRpcResponse), { + status, + headers: responseHeaders + }); +} + +/** + * Factory function to create mock agent cards for testing. + * + * @param options - Configuration options for the mock agent card + * @param options.name - Agent name (defaults to 'Test Agent') + * @param options.description - Agent description (defaults to 'A test agent for testing') + * @param options.url - Service endpoint URL (defaults to 'https://test-agent.example.com/api') + * @param options.protocolVersion - Protocol version (defaults to '1.0.0') + * @param options.version - Agent version (defaults to '1.0.0') + * @param options.defaultInputModes - Default input modes (defaults to ['text']) + * @param options.defaultOutputModes - Default output modes (defaults to ['text']) + * @param options.capabilities - Agent capabilities (defaults to { streaming: true, pushNotifications: true }) + * @param options.skills - Agent skills (defaults to []) + * @returns A mock AgentCard object + */ +export function createMockAgentCard(options: { + name?: string; + description?: string; + url?: string; + protocolVersion?: string; + version?: string; + defaultInputModes?: string[]; + defaultOutputModes?: string[]; + capabilities?: { + streaming?: boolean; + pushNotifications?: boolean; + }; + skills?: any[]; +} = {}): any { + return { + name: options.name ?? 'Test Agent', + description: options.description ?? 'A test agent for testing', + protocolVersion: options.protocolVersion ?? '1.0.0', + version: options.version ?? '1.0.0', + url: options.url ?? 'https://test-agent.example.com/api', + defaultInputModes: options.defaultInputModes ?? ['text'], + defaultOutputModes: options.defaultOutputModes ?? ['text'], + capabilities: { + streaming: options.capabilities?.streaming ?? true, + pushNotifications: options.capabilities?.pushNotifications ?? true, + ...options.capabilities + }, + skills: options.skills ?? [] + }; +} + +/** + * Factory function to create common message parameters for testing. + * Creates a MessageSendParams object with a text message that can be used + * across multiple test scenarios. + * + * @param options - Configuration options for the message parameters + * @param options.messageId - Message ID (defaults to 'test-msg') + * @param options.text - Message text content (defaults to 'Hello, agent!') + * @param options.role - Message role (defaults to 'user') + * @returns A MessageSendParams object with the specified configuration + */ +export function createMessageParams(options: { + messageId?: string; + text?: string; + role?: 'user' | 'assistant'; +} = {}): any { + const messageId = options.messageId ?? 'test-msg'; + const text = options.text ?? 'Hello, agent!'; + const role = options.role ?? 'user'; + + return { + message: { + kind: 'message', + messageId: messageId, + role: role, + parts: [{ + kind: 'text', + text: text + }] + } + }; +} + +/** + * Factory function to create common mock message objects for testing. + * Creates a Message object with text content that can be used + * across multiple test scenarios. + * + * @param options - Configuration options for the mock message + * @param options.messageId - Message ID (defaults to 'msg-123') + * @param options.text - Message text content (defaults to 'Hello, agent!') + * @param options.role - Message role (defaults to 'user') + * @returns A Message object with the specified configuration + */ +export function createMockMessage(options: { + messageId?: string; + text?: string; + role?: 'user' | 'assistant'; +} = {}): any { + const messageId = options.messageId ?? 'msg-123'; + const text = options.text ?? 'Hello, agent!'; + const role = options.role ?? 'user'; + + return { + kind: 'message', + messageId: messageId, + role: role, + parts: [{ + kind: 'text', + text: text + }] + }; +} + +/** + * Configuration options for creating mock fetch functions + */ +export interface MockFetchConfig { + /** Whether the mock should require authentication */ + requiresAuth?: boolean; + /** Custom agent card description */ + agentDescription?: string; + /** Custom message configuration */ + messageConfig?: { + messageId?: string; + text?: string; + }; + /** Custom error configuration for auth failures */ + authErrorConfig?: { + code?: number; + message?: string; + challenge?: string; + }; + /** Whether to capture auth headers for testing */ + captureAuthHeaders?: boolean; + /** Behavior mode for the mock fetch */ + behavior?: 'standard' | 'authRetry' | 'alwaysFail'; +} + +/** + * Creates a mock fetch function with configurable behavior. + * This is the single function that replaces all previous mock fetch utilities. + * + * @param config - Configuration options for the mock fetch behavior + * @returns A sinon stub that can be used as a mock fetch implementation, with capturedAuthHeaders attached as a property + */ +export function createMockFetch(config: MockFetchConfig = {}): sinon.SinonStub & { capturedAuthHeaders: string[] } { + const { + requiresAuth = false, // Default to no auth required for basic testing + agentDescription = 'A test agent for basic client testing', + messageConfig = { + messageId: 'msg-123', + text: 'Hello, agent!' + }, + authErrorConfig = { + code: -32001, + message: 'Authentication required', + challenge: 'challenge123' + }, + captureAuthHeaders = false, + behavior = 'standard' + } = config; + + let callCount = 0; + const capturedAuthHeaders: string[] = []; + + const mockFetch = sinon.stub().callsFake(async (url: string, options?: RequestInit) => { + // Handle agent card requests + if (url.includes(AGENT_CARD_PATH)) { + const mockAgentCard = createMockAgentCard({ + description: agentDescription + }); + return createAgentCardResponse(mockAgentCard); + } + + // Handle API requests + if (url.includes('/api')) { + const authHeader = options?.headers?.['Authorization'] as string; + + // Capture auth headers if requested + if (captureAuthHeaders) { + capturedAuthHeaders.push(authHeader || ''); + } + + const requestId = extractRequestId(options); + + // Determine response based on behavior + switch (behavior) { + case 'alwaysFail': + // Always return 401 for API calls + return createResponse(requestId, undefined, { + code: authErrorConfig.code!, + message: authErrorConfig.message! + }, 401, { 'WWW-Authenticate': `Bearer ${authErrorConfig.challenge}` }); + + case 'authRetry': + // First call: return 401 to trigger auth flow + if (callCount === 0) { + callCount++; + return createResponse(requestId, undefined, { + code: authErrorConfig.code!, + message: authErrorConfig.message! + }, 401, { 'WWW-Authenticate': `Bearer ${authErrorConfig.challenge}` }); + } + // Subsequent calls: return success + break; + + case 'standard': + default: + // If authentication is required and no valid header is present + if (requiresAuth && !authHeader) { + return createResponse(requestId, undefined, { + code: authErrorConfig.code!, + message: authErrorConfig.message! + }, 401, { 'WWW-Authenticate': `Bearer ${authErrorConfig.challenge}` }); + } + break; + } + + // Return success response + const mockMessage = createMockMessage({ + messageId: messageConfig.messageId || 'msg-123', + text: messageConfig.text || 'Hello, agent!' + }); + + return createResponse(requestId, mockMessage); + } + + // Default: return 404 for unknown endpoints + return new Response('Not found', { status: 404 }); + }); + + // Attach the capturedAuthHeaders as a property to the mock fetch function + (mockFetch as any).capturedAuthHeaders = capturedAuthHeaders; + + return mockFetch as sinon.SinonStub & { capturedAuthHeaders: string[] }; +}