From cd031136fe91bacd417858c3f4c67daa1568391f Mon Sep 17 00:00:00 2001 From: "Shanshan Xu (from Dev Box)" Date: Wed, 25 Mar 2026 19:14:42 +0800 Subject: [PATCH 01/10] feat: Add A2A.V0_3Compat server-side compat and A2AClientFactory v1/v0.3 detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add V03ServerProcessor: handles v0.3 JSON-RPC wire format (method names, param types, enum values) and translates to/from v1.0 before calling the IA2ARequestHandler. Supports both non-streaming and SSE streaming paths. - Add V03ServerCompatEndpointExtensions.MapA2AWithV03Compat: drop-in replacement for MapA2A that accepts v0.3 client requests on a v1.0 server. - Add V03JsonRpcResponseResult and V03JsonRpcStreamedResult: IResult implementations that serialize responses in v0.3 wire format. - Extend V03TypeConverter with server-side conversion methods: v0.3 request params → v1.0 request types, and v1.0 responses → v0.3. - Add A2AClientFactory.Create(string agentCardJson, Uri baseUrl, ...): detects protocol version from raw JSON and routes to v1 client or fallback. - Add A2AClientFactory.RegisterFallback/ClearFallback for global v0.3 client factory registration; add per-call FallbackFactory on A2AClientOptions. - Restore agent-preference ordering in A2AClientFactory.Create(AgentCard, ...) (agent's declared interface order wins per spec §8.3). - Add A2A.V0_3Compat.UnitTests covering factory detection and type conversion. Co-Authored-By: Claude Sonnet 4.6 --- src/A2A.V0_3Compat/A2A.V0_3Compat.csproj | 34 ++ src/A2A.V0_3Compat/A2AClientFactory.cs | 51 ++ src/A2A.V0_3Compat/V03ClientAdapter.cs | 139 +++++ .../V03JsonRpcResponseResult.cs | 33 ++ .../V03JsonRpcStreamedResult.cs | 72 +++ .../V03ServerCompatEndpointExtensions.cs | 43 ++ src/A2A.V0_3Compat/V03ServerProcessor.cs | 204 +++++++ src/A2A.V0_3Compat/V03TypeConverter.cs | 496 ++++++++++++++++++ src/A2A/Client/A2AClientFactory.cs | 139 ++++- src/A2A/Client/A2AClientOptions.cs | 18 + .../A2A.V0_3Compat.UnitTests.csproj | 31 ++ .../A2AClientFactoryTests.cs | 153 ++++++ .../A2AClientVersionHeaderTests.cs | 98 ++++ .../MockHttpMessageHandler.cs | 19 + .../V03TypeConverterTests.cs | 413 +++++++++++++++ 15 files changed, 1932 insertions(+), 11 deletions(-) create mode 100644 src/A2A.V0_3Compat/A2A.V0_3Compat.csproj create mode 100644 src/A2A.V0_3Compat/A2AClientFactory.cs create mode 100644 src/A2A.V0_3Compat/V03ClientAdapter.cs create mode 100644 src/A2A.V0_3Compat/V03JsonRpcResponseResult.cs create mode 100644 src/A2A.V0_3Compat/V03JsonRpcStreamedResult.cs create mode 100644 src/A2A.V0_3Compat/V03ServerCompatEndpointExtensions.cs create mode 100644 src/A2A.V0_3Compat/V03ServerProcessor.cs create mode 100644 src/A2A.V0_3Compat/V03TypeConverter.cs create mode 100644 tests/A2A.V0_3Compat.UnitTests/A2A.V0_3Compat.UnitTests.csproj create mode 100644 tests/A2A.V0_3Compat.UnitTests/A2AClientFactoryTests.cs create mode 100644 tests/A2A.V0_3Compat.UnitTests/A2AClientVersionHeaderTests.cs create mode 100644 tests/A2A.V0_3Compat.UnitTests/MockHttpMessageHandler.cs create mode 100644 tests/A2A.V0_3Compat.UnitTests/V03TypeConverterTests.cs diff --git a/src/A2A.V0_3Compat/A2A.V0_3Compat.csproj b/src/A2A.V0_3Compat/A2A.V0_3Compat.csproj new file mode 100644 index 00000000..e2064a37 --- /dev/null +++ b/src/A2A.V0_3Compat/A2A.V0_3Compat.csproj @@ -0,0 +1,34 @@ + + + net10.0;net8.0 + true + A2A.V0_3Compat + Version compatibility layer providing automatic fallback from A2A v1.0 to v0.3 servers. + Agent2Agent;a2a;agent;ai;llm;compat + README.md + true + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/src/A2A.V0_3Compat/A2AClientFactory.cs b/src/A2A.V0_3Compat/A2AClientFactory.cs new file mode 100644 index 00000000..095399e7 --- /dev/null +++ b/src/A2A.V0_3Compat/A2AClientFactory.cs @@ -0,0 +1,51 @@ +namespace A2A.V0_3Compat; + +using System.Text.Json; + +using V03 = A2A.V0_3; + +/// +/// Registers a v0.3 compatibility fallback with . +/// Once registered, the factory automatically creates a v0.3 adapter for agents +/// whose agent card does not contain supportedInterfaces. +/// +public static class V03FallbackRegistration +{ + /// + /// Registers the v0.3 fallback globally with . + /// Call this once at application startup. + /// + public static void Register() + { + A2AClientFactory.RegisterFallback(CreateV03Client); + } + + /// + /// Creates an from a v0.3-shaped agent card JSON string. + /// Can be used directly as a delegate + /// or via for global registration. + /// + /// The raw JSON string of the agent card. + /// The base URL of the agent, used as fallback when the card does not specify a URL. + /// Optional HTTP client to use for requests. + /// An wrapping a v0.3 client. + public static IA2AClient CreateV03Client(string agentCardJson, Uri baseUrl, HttpClient? httpClient) + { + using var doc = JsonDocument.Parse(agentCardJson); + var root = doc.RootElement; + + string url; + if (root.TryGetProperty("url", out var urlProp) && + urlProp.ValueKind == JsonValueKind.String) + { + url = urlProp.GetString()!; + } + else + { + url = baseUrl.ToString(); + } + + var v03Client = new V03.A2AClient(new Uri(url), httpClient); + return new V03ClientAdapter(v03Client); + } +} diff --git a/src/A2A.V0_3Compat/V03ClientAdapter.cs b/src/A2A.V0_3Compat/V03ClientAdapter.cs new file mode 100644 index 00000000..09445e85 --- /dev/null +++ b/src/A2A.V0_3Compat/V03ClientAdapter.cs @@ -0,0 +1,139 @@ +namespace A2A.V0_3Compat; + +using System.Runtime.CompilerServices; + +using V03 = A2A.V0_3; + +/// Adapts a v0.3 A2A client to the v1.0 interface. +internal sealed class V03ClientAdapter : A2A.IA2AClient, IDisposable +{ + private readonly V03.A2AClient _v03Client; + + /// Initializes a new instance of the class. + /// The v0.3 client to wrap. + internal V03ClientAdapter(V03.A2AClient v03Client) + { + ArgumentNullException.ThrowIfNull(v03Client); + _v03Client = v03Client; + } + + /// + public async Task SendMessageAsync( + A2A.SendMessageRequest request, + CancellationToken cancellationToken = default) + { + var v03Params = V03TypeConverter.ToV03(request); + var v03Response = await _v03Client.SendMessageAsync(v03Params, cancellationToken).ConfigureAwait(false); + return V03TypeConverter.ToV1Response(v03Response); + } + + /// + public async IAsyncEnumerable SendStreamingMessageAsync( + A2A.SendMessageRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var v03Params = V03TypeConverter.ToV03(request); + await foreach (var sseItem in _v03Client.SendMessageStreamingAsync(v03Params, cancellationToken).ConfigureAwait(false)) + { + if (sseItem.Data is { } evt) + { + yield return V03TypeConverter.ToV1StreamResponse(evt); + } + } + } + + /// + public async Task GetTaskAsync( + A2A.GetTaskRequest request, + CancellationToken cancellationToken = default) + { + var v03Task = await _v03Client.GetTaskAsync(request.Id, cancellationToken).ConfigureAwait(false); + return V03TypeConverter.ToV1Task(v03Task); + } + + /// + public Task ListTasksAsync( + A2A.ListTasksRequest request, + CancellationToken cancellationToken = default) => + throw new NotSupportedException("v0.3 does not support listing tasks."); + + /// + public async Task CancelTaskAsync( + A2A.CancelTaskRequest request, + CancellationToken cancellationToken = default) + { + var v03Params = new V03.TaskIdParams + { + Id = request.Id, + Metadata = request.Metadata, + }; + var v03Task = await _v03Client.CancelTaskAsync(v03Params, cancellationToken).ConfigureAwait(false); + return V03TypeConverter.ToV1Task(v03Task); + } + + /// + public async IAsyncEnumerable SubscribeToTaskAsync( + A2A.SubscribeToTaskRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var sseItem in _v03Client.SubscribeToTaskAsync(request.Id, cancellationToken).ConfigureAwait(false)) + { + if (sseItem.Data is { } evt) + { + yield return V03TypeConverter.ToV1StreamResponse(evt); + } + } + } + + /// + public async Task CreateTaskPushNotificationConfigAsync( + A2A.CreateTaskPushNotificationConfigRequest request, + CancellationToken cancellationToken = default) + { + var v03Config = new V03.TaskPushNotificationConfig + { + TaskId = request.TaskId, + PushNotificationConfig = V03TypeConverter.ToV03PushNotificationConfig(request.Config), + }; + var v03Result = await _v03Client.SetPushNotificationAsync(v03Config, cancellationToken).ConfigureAwait(false); + return V03TypeConverter.ToV1TaskPushNotificationConfig(v03Result); + } + + /// + public async Task GetTaskPushNotificationConfigAsync( + A2A.GetTaskPushNotificationConfigRequest request, + CancellationToken cancellationToken = default) + { + var v03Params = new V03.GetTaskPushNotificationConfigParams + { + Id = request.TaskId, + PushNotificationConfigId = request.Id, + }; + var v03Result = await _v03Client.GetPushNotificationAsync(v03Params, cancellationToken).ConfigureAwait(false); + return V03TypeConverter.ToV1TaskPushNotificationConfig(v03Result); + } + + /// + public Task ListTaskPushNotificationConfigAsync( + A2A.ListTaskPushNotificationConfigRequest request, + CancellationToken cancellationToken = default) => + throw new NotSupportedException("v0.3 does not support listing push notification configs."); + + /// + public Task DeleteTaskPushNotificationConfigAsync( + A2A.DeleteTaskPushNotificationConfigRequest request, + CancellationToken cancellationToken = default) => + throw new NotSupportedException("v0.3 does not support deleting push notification configs."); + + /// + public Task GetExtendedAgentCardAsync( + A2A.GetExtendedAgentCardRequest request, + CancellationToken cancellationToken = default) => + throw new NotSupportedException("v0.3 does not support extended agent cards."); + + /// + public void Dispose() + { + // The v0.3 A2AClient does not implement IDisposable, so nothing to dispose. + } +} diff --git a/src/A2A.V0_3Compat/V03JsonRpcResponseResult.cs b/src/A2A.V0_3Compat/V03JsonRpcResponseResult.cs new file mode 100644 index 00000000..6e33c62c --- /dev/null +++ b/src/A2A.V0_3Compat/V03JsonRpcResponseResult.cs @@ -0,0 +1,33 @@ +namespace A2A.V0_3Compat; + +using Microsoft.AspNetCore.Http; +using System.Text.Json; + +using V03 = A2A.V0_3; + +/// Result type for returning v0.3-format JSON-RPC responses as JSON in HTTP responses. +internal sealed class V03JsonRpcResponseResult : IResult +{ + private readonly V03.JsonRpcResponse _response; + + internal V03JsonRpcResponseResult(V03.JsonRpcResponse response) + { + ArgumentNullException.ThrowIfNull(response); + _response = response; + } + + /// + public async Task ExecuteAsync(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + httpContext.Response.ContentType = "application/json"; + httpContext.Response.StatusCode = StatusCodes.Status200OK; + + await JsonSerializer.SerializeAsync( + httpContext.Response.Body, + _response, + V03.A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(V03.JsonRpcResponse)), + httpContext.RequestAborted).ConfigureAwait(false); + } +} diff --git a/src/A2A.V0_3Compat/V03JsonRpcStreamedResult.cs b/src/A2A.V0_3Compat/V03JsonRpcStreamedResult.cs new file mode 100644 index 00000000..19c49933 --- /dev/null +++ b/src/A2A.V0_3Compat/V03JsonRpcStreamedResult.cs @@ -0,0 +1,72 @@ +namespace A2A.V0_3Compat; + +using Microsoft.AspNetCore.Http; +using System.Net.ServerSentEvents; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; + +using V03 = A2A.V0_3; + +/// Result type for streaming v0.3-format JSON-RPC responses as SSE. +internal sealed class V03JsonRpcStreamedResult : IResult +{ + private readonly IAsyncEnumerable _events; + private readonly V03.JsonRpcId _requestId; + + internal V03JsonRpcStreamedResult(IAsyncEnumerable events, V03.JsonRpcId requestId) + { + ArgumentNullException.ThrowIfNull(events); + _events = events; + _requestId = requestId; + } + + /// + public async Task ExecuteAsync(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + httpContext.Response.StatusCode = StatusCodes.Status200OK; + httpContext.Response.ContentType = "text/event-stream"; + httpContext.Response.Headers.Append("Cache-Control", "no-cache"); + + var responseTypeInfo = V03.A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(V03.JsonRpcResponse)); + var eventTypeInfo = V03.A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(V03.A2AEvent)); + + try + { + await SseFormatter.WriteAsync( + _events.Select(e => new SseItem( + V03.JsonRpcResponse.CreateJsonRpcResponse(_requestId, e, eventTypeInfo))), + httpContext.Response.Body, + (item, writer) => + { + using Utf8JsonWriter json = new(writer, new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); + JsonSerializer.Serialize(json, item.Data, responseTypeInfo); + }, + httpContext.RequestAborted).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Client disconnected — expected + } + catch (Exception ex) + { + try + { + var errorResponse = ex is A2AException a2aEx + ? V03.JsonRpcResponse.CreateJsonRpcErrorResponse(_requestId, + new V03.A2AException(a2aEx.Message, (V03.A2AErrorCode)(int)a2aEx.ErrorCode)) + : V03.JsonRpcResponse.InternalErrorResponse(_requestId, "An internal error occurred during streaming."); + var errorJson = JsonSerializer.Serialize(errorResponse, responseTypeInfo); + var errorBytes = Encoding.UTF8.GetBytes($"data: {errorJson}\n\n"); + await httpContext.Response.Body.WriteAsync(errorBytes, httpContext.RequestAborted); + await httpContext.Response.Body.FlushAsync(httpContext.RequestAborted); + } + catch + { + // Response body is no longer writable — silently abandon + } + } + } +} diff --git a/src/A2A.V0_3Compat/V03ServerCompatEndpointExtensions.cs b/src/A2A.V0_3Compat/V03ServerCompatEndpointExtensions.cs new file mode 100644 index 00000000..3d854097 --- /dev/null +++ b/src/A2A.V0_3Compat/V03ServerCompatEndpointExtensions.cs @@ -0,0 +1,43 @@ +namespace A2A.V0_3Compat; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using System.Diagnostics.CodeAnalysis; + +/// +/// Extension methods for registering A2A endpoints with v0.3 compatibility support. +/// +public static class V03ServerCompatEndpointExtensions +{ + /// + /// Maps an A2A JSON-RPC endpoint that accepts both v0.3 and v1.0 client requests. + /// v0.3 requests are automatically translated to v1.0 before being handled, and responses + /// are translated back to v0.3 wire format. + /// + /// + /// Use this instead of MapA2A when you need to support v0.3 clients during a migration + /// period. Once all clients have been upgraded to v1.0, switch back to MapA2A. + /// + /// The endpoint route builder. + /// The v1.0 A2A request handler. + /// The route path for the endpoint. + /// An endpoint convention builder for further configuration. + [RequiresDynamicCode("MapA2AWithV03Compat uses runtime reflection for route binding. For AOT-compatible usage, use a source-generated host.")] + [RequiresUnreferencedCode("MapA2AWithV03Compat may perform reflection on types that are not preserved by trimming.")] + public static IEndpointConventionBuilder MapA2AWithV03Compat( + this IEndpointRouteBuilder endpoints, + IA2ARequestHandler requestHandler, + [StringSyntax("Route")] string path) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(requestHandler); + ArgumentException.ThrowIfNullOrEmpty(path); + + var routeGroup = endpoints.MapGroup(""); + routeGroup.MapPost(path, (HttpRequest request, CancellationToken cancellationToken) + => V03ServerProcessor.ProcessRequestAsync(requestHandler, request, cancellationToken)); + + return routeGroup; + } +} diff --git a/src/A2A.V0_3Compat/V03ServerProcessor.cs b/src/A2A.V0_3Compat/V03ServerProcessor.cs new file mode 100644 index 00000000..fd264d58 --- /dev/null +++ b/src/A2A.V0_3Compat/V03ServerProcessor.cs @@ -0,0 +1,204 @@ +namespace A2A.V0_3Compat; + +using Microsoft.AspNetCore.Http; +using System.Text.Json; + +using V03 = A2A.V0_3; + +/// +/// Processes incoming HTTP requests that may use A2A v0.3 or v1.0 JSON-RPC format. +/// Automatically translates v0.3 requests to v1.0 and v0.3 responses back to v0.3 wire format. +/// +public static class V03ServerProcessor +{ + /// + /// Processes an A2A JSON-RPC request, handling both v0.3 and v1.0 wire formats. + /// v0.3 requests are translated to v1.0, processed, and responses are translated back to v0.3. + /// v1.0 requests are passed through to the handler unchanged. + /// + /// The v1.0 A2A request handler. + /// The incoming HTTP request. + /// The cancellation token. + public static async Task ProcessRequestAsync( + IA2ARequestHandler requestHandler, + HttpRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(requestHandler); + ArgumentNullException.ThrowIfNull(request); + + V03.JsonRpcRequest? rpcRequest = null; + + try + { + rpcRequest = (V03.JsonRpcRequest?)await JsonSerializer.DeserializeAsync( + request.Body, + V03.A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(V03.JsonRpcRequest)), + cancellationToken).ConfigureAwait(false); + + if (rpcRequest is null) + { + return MakeErrorResult(default, V03.JsonRpcResponse.ParseErrorResponse); + } + + if (V03.A2AMethods.IsStreamingMethod(rpcRequest.Method)) + { + return HandleStreaming(requestHandler, rpcRequest, cancellationToken); + } + + return await HandleSingleAsync(requestHandler, rpcRequest, cancellationToken).ConfigureAwait(false); + } + catch (A2AException ex) + { + var id = rpcRequest?.Id ?? default; + return MakeV03ErrorResult(id, ex); + } + catch (JsonException) + { + return MakeErrorResult(default, V03.JsonRpcResponse.ParseErrorResponse); + } + catch (Exception) + { + var id = rpcRequest?.Id ?? default; + return MakeErrorResult(id, V03.JsonRpcResponse.InternalErrorResponse); + } + } + + private static async Task HandleSingleAsync( + IA2ARequestHandler handler, + V03.JsonRpcRequest rpcRequest, + CancellationToken ct) + { + if (rpcRequest.Params is null) + { + return MakeErrorResult(rpcRequest.Id, V03.JsonRpcResponse.InvalidParamsResponse); + } + + switch (rpcRequest.Method) + { + case V03.A2AMethods.MessageSend: + { + var v03Params = DeserializeParams(rpcRequest.Params.Value); + var v1Request = V03TypeConverter.ToV1SendMessageRequest(v03Params); + var v1Response = await handler.SendMessageAsync(v1Request, ct).ConfigureAwait(false); + var v03Response = V03TypeConverter.ToV03Response(v1Response); + return MakeSuccessResult(rpcRequest.Id, v03Response, typeof(V03.A2AResponse)); + } + + case V03.A2AMethods.TaskGet: + { + var v03Params = DeserializeParams(rpcRequest.Params.Value); + var v1Request = V03TypeConverter.ToV1GetTaskRequest(v03Params); + var agentTask = await handler.GetTaskAsync(v1Request, ct).ConfigureAwait(false); + return MakeSuccessResult(rpcRequest.Id, V03TypeConverter.ToV03AgentTask(agentTask), typeof(V03.AgentTask)); + } + + case V03.A2AMethods.TaskCancel: + { + var v03Params = DeserializeParams(rpcRequest.Params.Value); + var v1Request = V03TypeConverter.ToV1CancelTaskRequest(v03Params); + var agentTask = await handler.CancelTaskAsync(v1Request, ct).ConfigureAwait(false); + return MakeSuccessResult(rpcRequest.Id, V03TypeConverter.ToV03AgentTask(agentTask), typeof(V03.AgentTask)); + } + + case V03.A2AMethods.TaskPushNotificationConfigSet: + { + var v03Config = DeserializeParams(rpcRequest.Params.Value); + var v1Request = new CreateTaskPushNotificationConfigRequest + { + TaskId = v03Config.TaskId, + Config = V03TypeConverter.ToV1PushNotificationConfig(v03Config.PushNotificationConfig), + }; + var v1Result = await handler.CreateTaskPushNotificationConfigAsync(v1Request, ct).ConfigureAwait(false); + var v03Result = new V03.TaskPushNotificationConfig + { + TaskId = v1Result.TaskId, + PushNotificationConfig = V03TypeConverter.ToV03PushNotificationConfig(v1Result.PushNotificationConfig), + }; + return MakeSuccessResult(rpcRequest.Id, v03Result, typeof(V03.TaskPushNotificationConfig)); + } + + case V03.A2AMethods.TaskPushNotificationConfigGet: + { + var v03Params = DeserializeParams(rpcRequest.Params.Value); + var v1Request = new GetTaskPushNotificationConfigRequest + { + TaskId = v03Params.Id, + Id = v03Params.PushNotificationConfigId ?? string.Empty, + }; + var v1Result = await handler.GetTaskPushNotificationConfigAsync(v1Request, ct).ConfigureAwait(false); + var v03Result = new V03.TaskPushNotificationConfig + { + TaskId = v1Result.TaskId, + PushNotificationConfig = V03TypeConverter.ToV03PushNotificationConfig(v1Result.PushNotificationConfig), + }; + return MakeSuccessResult(rpcRequest.Id, v03Result, typeof(V03.TaskPushNotificationConfig)); + } + + default: + return MakeErrorResult(rpcRequest.Id, V03.JsonRpcResponse.MethodNotFoundResponse); + } + } + + private static IResult HandleStreaming( + IA2ARequestHandler handler, + V03.JsonRpcRequest rpcRequest, + CancellationToken ct) + { + if (rpcRequest.Params is null) + { + return MakeErrorResult(rpcRequest.Id, V03.JsonRpcResponse.InvalidParamsResponse); + } + + switch (rpcRequest.Method) + { + case V03.A2AMethods.MessageStream: + { + var v03Params = DeserializeParams(rpcRequest.Params.Value); + var v1Request = V03TypeConverter.ToV1SendMessageRequest(v03Params); + var v1Events = handler.SendStreamingMessageAsync(v1Request, ct); + return new V03JsonRpcStreamedResult(v1Events.Select(V03TypeConverter.ToV03Event), rpcRequest.Id); + } + + case V03.A2AMethods.TaskSubscribe: + { + var v03Params = DeserializeParams(rpcRequest.Params.Value); + var subscribeRequest = new SubscribeToTaskRequest { Id = v03Params.Id }; + var v1Events = handler.SubscribeToTaskAsync(subscribeRequest, ct); + return new V03JsonRpcStreamedResult(v1Events.Select(V03TypeConverter.ToV03Event), rpcRequest.Id); + } + + default: + return MakeErrorResult(rpcRequest.Id, V03.JsonRpcResponse.MethodNotFoundResponse); + } + } + + private static T DeserializeParams(JsonElement element) where T : class + { + T? result; + try + { + result = (T?)element.Deserialize(V03.A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T))); + } + catch (JsonException ex) + { + throw new A2AException($"Invalid parameters: {ex.Message}", A2AErrorCode.InvalidParams); + } + + return result ?? throw new A2AException($"Parameters could not be deserialized as {typeof(T).Name}", A2AErrorCode.InvalidParams); + } + + private static V03JsonRpcResponseResult MakeSuccessResult(V03.JsonRpcId id, T result, Type resultType) + { + var typeInfo = V03.A2AJsonUtilities.DefaultOptions.GetTypeInfo(resultType); + var response = V03.JsonRpcResponse.CreateJsonRpcResponse(id, result, typeInfo); + return new V03JsonRpcResponseResult(response); + } + + private static V03JsonRpcResponseResult MakeErrorResult(V03.JsonRpcId id, Func factory) + => new(factory(id, null)); + + private static V03JsonRpcResponseResult MakeV03ErrorResult(V03.JsonRpcId id, A2AException ex) + => new(V03.JsonRpcResponse.CreateJsonRpcErrorResponse(id, + new V03.A2AException(ex.Message, (V03.A2AErrorCode)(int)ex.ErrorCode))); +} diff --git a/src/A2A.V0_3Compat/V03TypeConverter.cs b/src/A2A.V0_3Compat/V03TypeConverter.cs new file mode 100644 index 00000000..c0b85196 --- /dev/null +++ b/src/A2A.V0_3Compat/V03TypeConverter.cs @@ -0,0 +1,496 @@ +namespace A2A.V0_3Compat; + +using System.Text.Json; + +using V03 = A2A.V0_3; + +/// Bidirectional type conversion between A2A v1.0 and v0.3 models. +internal static class V03TypeConverter +{ + // ──── v1.0 → v0.3 (request conversion) ──── + + /// Converts a v1.0 send message request to v0.3 message send params. + /// The v1.0 request to convert. + /// The converted v0.3 message send params. + internal static V03.MessageSendParams ToV03(A2A.SendMessageRequest request) + { + var result = new V03.MessageSendParams + { + Message = ToV03Message(request.Message), + Metadata = request.Metadata, + }; + + if (request.Configuration is { } config) + { + result.Configuration = new V03.MessageSendConfiguration + { + AcceptedOutputModes = config.AcceptedOutputModes ?? [], + HistoryLength = config.HistoryLength, + Blocking = config.Blocking, + }; + + if (config.PushNotificationConfig is { } pushConfig) + { + result.Configuration.PushNotification = ToV03PushNotificationConfig(pushConfig); + } + } + + return result; + } + + /// Converts a v1.0 message to a v0.3 agent message. + /// The v1.0 message to convert. + /// The converted v0.3 agent message. + internal static V03.AgentMessage ToV03Message(A2A.Message message) => + new() + { + Role = ToV03Role(message.Role), + Parts = message.Parts.Select(ToV03Part).ToList(), + MessageId = message.MessageId, + ContextId = message.ContextId, + TaskId = message.TaskId, + ReferenceTaskIds = message.ReferenceTaskIds, + Extensions = message.Extensions, + Metadata = message.Metadata, + }; + + /// Converts a v1.0 part to a v0.3 part. + /// The v1.0 part to convert. + /// The converted v0.3 part. + internal static V03.Part ToV03Part(A2A.Part part) => + part.ContentCase switch + { + PartContentCase.Text => new V03.TextPart + { + Text = part.Text!, + Metadata = part.Metadata, + }, + PartContentCase.Raw => new V03.FilePart + { + File = CreateV03FileContentFromBytes(Convert.ToBase64String(part.Raw!), part.MediaType, part.Filename), + Metadata = part.Metadata, + }, + PartContentCase.Url => new V03.FilePart + { + File = CreateV03FileContentFromUri(new Uri(part.Url!), part.MediaType, part.Filename), + Metadata = part.Metadata, + }, + PartContentCase.Data => new V03.DataPart + { + Data = ToV03DataDictionary(part.Data!.Value), + Metadata = part.Metadata, + }, + _ => new V03.TextPart { Text = string.Empty, Metadata = part.Metadata }, + }; + + /// Converts a v1.0 role to a v0.3 message role. + /// The v1.0 role to convert. + /// The converted v0.3 message role. + internal static V03.MessageRole ToV03Role(A2A.Role role) => + role switch + { + Role.User => V03.MessageRole.User, + Role.Agent => V03.MessageRole.Agent, + _ => V03.MessageRole.User, + }; + + // ──── v0.3 → v1.0 (response conversion) ──── + + /// Converts a v0.3 response to a v1.0 send message response. + /// The v0.3 response to convert. + /// The converted v1.0 send message response. + internal static A2A.SendMessageResponse ToV1Response(V03.A2AResponse response) => + response switch + { + V03.AgentTask task => new A2A.SendMessageResponse { Task = ToV1Task(task) }, + V03.AgentMessage message => new A2A.SendMessageResponse { Message = ToV1Message(message) }, + _ => throw new InvalidOperationException($"Unknown v0.3 response type: {response.GetType().Name}"), + }; + + /// Converts a v0.3 event to a v1.0 stream response. + /// The v0.3 event to convert. + /// The converted v1.0 stream response. + internal static A2A.StreamResponse ToV1StreamResponse(V03.A2AEvent evt) => + evt switch + { + V03.AgentTask task => new A2A.StreamResponse { Task = ToV1Task(task) }, + V03.AgentMessage message => new A2A.StreamResponse { Message = ToV1Message(message) }, + V03.TaskStatusUpdateEvent statusUpdate => new A2A.StreamResponse + { + StatusUpdate = new A2A.TaskStatusUpdateEvent + { + TaskId = statusUpdate.TaskId, + ContextId = statusUpdate.ContextId, + Status = ToV1Status(statusUpdate.Status), + Metadata = statusUpdate.Metadata, + }, + }, + V03.TaskArtifactUpdateEvent artifactUpdate => new A2A.StreamResponse + { + ArtifactUpdate = new A2A.TaskArtifactUpdateEvent + { + TaskId = artifactUpdate.TaskId, + ContextId = artifactUpdate.ContextId, + Artifact = ToV1Artifact(artifactUpdate.Artifact), + Append = artifactUpdate.Append ?? false, + LastChunk = artifactUpdate.LastChunk ?? false, + Metadata = artifactUpdate.Metadata, + }, + }, + _ => throw new InvalidOperationException($"Unknown v0.3 event type: {evt.GetType().Name}"), + }; + + /// Converts a v0.3 agent task to a v1.0 agent task. + /// The v0.3 task to convert. + /// The converted v1.0 agent task. + internal static A2A.AgentTask ToV1Task(V03.AgentTask task) => + new() + { + Id = task.Id, + ContextId = task.ContextId, + Status = ToV1Status(task.Status), + History = task.History?.Select(ToV1Message).ToList(), + Artifacts = task.Artifacts?.Select(ToV1Artifact).ToList(), + Metadata = task.Metadata, + }; + + /// Converts a v0.3 agent message to a v1.0 message. + /// The v0.3 message to convert. + /// The converted v1.0 message. + internal static A2A.Message ToV1Message(V03.AgentMessage message) => + new() + { + Role = ToV1Role(message.Role), + Parts = message.Parts.Select(ToV1Part).ToList(), + MessageId = message.MessageId, + ContextId = message.ContextId, + TaskId = message.TaskId, + ReferenceTaskIds = message.ReferenceTaskIds, + Extensions = message.Extensions, + Metadata = message.Metadata, + }; + + /// Converts a v0.3 part to a v1.0 part. + /// The v0.3 part to convert. + /// The converted v1.0 part. + internal static A2A.Part ToV1Part(V03.Part part) => + part switch + { + V03.TextPart textPart => new A2A.Part + { + Text = textPart.Text, + Metadata = textPart.Metadata, + }, + V03.FilePart filePart when filePart.File.Bytes is not null => new A2A.Part + { + Raw = Convert.FromBase64String(filePart.File.Bytes), + MediaType = filePart.File.MimeType, + Filename = filePart.File.Name, + Metadata = filePart.Metadata, + }, + V03.FilePart filePart when filePart.File.Uri is not null => new A2A.Part + { + Url = filePart.File.Uri.ToString(), + MediaType = filePart.File.MimeType, + Filename = filePart.File.Name, + Metadata = filePart.Metadata, + }, + V03.FilePart => new A2A.Part { Metadata = part.Metadata }, + V03.DataPart dataPart => new A2A.Part + { + Data = DataDictionaryToElement(dataPart.Data), + Metadata = dataPart.Metadata, + }, + _ => new A2A.Part { Metadata = part.Metadata }, + }; + + /// Converts a v0.3 agent task status to a v1.0 task status. + /// The v0.3 status to convert. + /// The converted v1.0 task status. + internal static A2A.TaskStatus ToV1Status(V03.AgentTaskStatus status) => + new() + { + State = ToV1State(status.State), + Message = status.Message is not null ? ToV1Message(status.Message) : null, + Timestamp = status.Timestamp, + }; + + /// Converts a v0.3 task state to a v1.0 task state. + /// The v0.3 state to convert. + /// The converted v1.0 task state. + internal static A2A.TaskState ToV1State(V03.TaskState state) => + state switch + { + V03.TaskState.Submitted => A2A.TaskState.Submitted, + V03.TaskState.Working => A2A.TaskState.Working, + V03.TaskState.Completed => A2A.TaskState.Completed, + V03.TaskState.Failed => A2A.TaskState.Failed, + V03.TaskState.Canceled => A2A.TaskState.Canceled, + V03.TaskState.InputRequired => A2A.TaskState.InputRequired, + V03.TaskState.Rejected => A2A.TaskState.Rejected, + V03.TaskState.AuthRequired => A2A.TaskState.AuthRequired, + _ => A2A.TaskState.Unspecified, + }; + + /// Converts a v0.3 message role to a v1.0 role. + /// The v0.3 role to convert. + /// The converted v1.0 role. + internal static A2A.Role ToV1Role(V03.MessageRole role) => + role switch + { + V03.MessageRole.User => A2A.Role.User, + V03.MessageRole.Agent => A2A.Role.Agent, + _ => A2A.Role.Unspecified, + }; + + /// Converts a v0.3 artifact to a v1.0 artifact. + /// The v0.3 artifact to convert. + /// The converted v1.0 artifact. + internal static A2A.Artifact ToV1Artifact(V03.Artifact artifact) => + new() + { + ArtifactId = artifact.ArtifactId, + Name = artifact.Name, + Description = artifact.Description, + Parts = artifact.Parts.Select(ToV1Part).ToList(), + Extensions = artifact.Extensions, + Metadata = artifact.Metadata, + }; + + // ──── Push notification config conversion ──── + + /// Converts a v1.0 push notification config to v0.3. + /// The v1.0 config to convert. + /// The converted v0.3 push notification config. + internal static V03.PushNotificationConfig ToV03PushNotificationConfig(A2A.PushNotificationConfig config) + { + var result = new V03.PushNotificationConfig + { + Url = config.Url, + Id = config.Id, + Token = config.Token, + }; + + if (config.Authentication is { } auth) + { + result.Authentication = new V03.PushNotificationAuthenticationInfo + { + Schemes = [auth.Scheme], + Credentials = auth.Credentials, + }; + } + + return result; + } + + /// Converts a v0.3 push notification config to v1.0. + /// The v0.3 config to convert. + /// The converted v1.0 push notification config. + internal static A2A.PushNotificationConfig ToV1PushNotificationConfig(V03.PushNotificationConfig config) + { + var result = new A2A.PushNotificationConfig + { + Url = config.Url, + Id = config.Id, + Token = config.Token, + }; + + if (config.Authentication is { } auth && auth.Schemes.Count > 0) + { + result.Authentication = new A2A.AuthenticationInfo + { + Scheme = auth.Schemes[0], + Credentials = auth.Credentials, + }; + } + + return result; + } + + /// Converts a v0.3 task push notification config to v1.0. + /// The v0.3 config to convert. + /// The converted v1.0 task push notification config. + internal static A2A.TaskPushNotificationConfig ToV1TaskPushNotificationConfig(V03.TaskPushNotificationConfig config) => + new() + { + Id = config.PushNotificationConfig.Id ?? string.Empty, + TaskId = config.TaskId, + PushNotificationConfig = ToV1PushNotificationConfig(config.PushNotificationConfig), + }; + + // ──── v0.3 request params → v1.0 request types (server-side compat) ──── + + /// Converts v0.3 message send params to a v1.0 send message request. + /// The v0.3 message send params to convert. + internal static A2A.SendMessageRequest ToV1SendMessageRequest(V03.MessageSendParams p) => + new() + { + Message = ToV1Message(p.Message), + Configuration = p.Configuration is { } cfg ? new A2A.SendMessageConfiguration + { + AcceptedOutputModes = cfg.AcceptedOutputModes, + HistoryLength = cfg.HistoryLength, + Blocking = cfg.Blocking, + PushNotificationConfig = cfg.PushNotification is { } pn + ? ToV1PushNotificationConfig(pn) + : null, + } : null, + Metadata = p.Metadata, + }; + + /// Converts v0.3 task query params to a v1.0 get task request. + /// The v0.3 task query params to convert. + internal static A2A.GetTaskRequest ToV1GetTaskRequest(V03.TaskQueryParams p) => + new() { Id = p.Id, HistoryLength = p.HistoryLength }; + + /// Converts v0.3 task ID params to a v1.0 cancel task request. + /// The v0.3 task ID params to convert. + internal static A2A.CancelTaskRequest ToV1CancelTaskRequest(V03.TaskIdParams p) => + new() { Id = p.Id, Metadata = p.Metadata }; + + // ──── v1.0 response → v0.3 (server-side compat) ──── + + /// Converts a v1.0 send message response to a v0.3 A2A response. + /// The v1.0 send message response to convert. + internal static V03.A2AResponse ToV03Response(A2A.SendMessageResponse response) => + response.PayloadCase switch + { + A2A.SendMessageResponseCase.Task => ToV03AgentTask(response.Task!), + A2A.SendMessageResponseCase.Message => ToV03Message(response.Message!), + _ => throw new InvalidOperationException($"Unknown SendMessageResponse payload case: {response.PayloadCase}"), + }; + + /// Converts a v1.0 stream response event to a v0.3 A2A event. + /// The v1.0 stream response to convert. + internal static V03.A2AEvent ToV03Event(A2A.StreamResponse response) => + response.PayloadCase switch + { + A2A.StreamResponseCase.Task => ToV03AgentTask(response.Task!), + A2A.StreamResponseCase.Message => ToV03Message(response.Message!), + A2A.StreamResponseCase.StatusUpdate => ToV03StatusUpdate(response.StatusUpdate!), + A2A.StreamResponseCase.ArtifactUpdate => ToV03ArtifactUpdate(response.ArtifactUpdate!), + _ => throw new InvalidOperationException($"Unknown StreamResponse payload case: {response.PayloadCase}"), + }; + + /// Converts a v1.0 agent task to a v0.3 agent task. + /// The v1.0 agent task to convert. + internal static V03.AgentTask ToV03AgentTask(A2A.AgentTask task) => + new() + { + Id = task.Id, + ContextId = task.ContextId, + Status = ToV03AgentTaskStatus(task.Status), + History = task.History?.Select(ToV03Message).ToList(), + Artifacts = task.Artifacts?.Select(ToV03Artifact).ToList(), + Metadata = task.Metadata, + }; + + /// Converts a v1.0 task status to a v0.3 agent task status. + /// The v1.0 task status to convert. + internal static V03.AgentTaskStatus ToV03AgentTaskStatus(A2A.TaskStatus status) => + new() + { + State = ToV03TaskState(status.State), + Message = status.Message is { } msg ? ToV03Message(msg) : null, + Timestamp = status.Timestamp ?? DateTimeOffset.UtcNow, + }; + + /// Converts a v1.0 task state to a v0.3 task state. + /// The v1.0 task state to convert. + internal static V03.TaskState ToV03TaskState(A2A.TaskState state) => + state switch + { + A2A.TaskState.Submitted => V03.TaskState.Submitted, + A2A.TaskState.Working => V03.TaskState.Working, + A2A.TaskState.Completed => V03.TaskState.Completed, + A2A.TaskState.Failed => V03.TaskState.Failed, + A2A.TaskState.Canceled => V03.TaskState.Canceled, + A2A.TaskState.InputRequired => V03.TaskState.InputRequired, + A2A.TaskState.Rejected => V03.TaskState.Rejected, + A2A.TaskState.AuthRequired => V03.TaskState.AuthRequired, + _ => V03.TaskState.Unknown, + }; + + /// Converts a v1.0 artifact to a v0.3 artifact. + /// The v1.0 artifact to convert. + internal static V03.Artifact ToV03Artifact(A2A.Artifact artifact) => + new() + { + ArtifactId = artifact.ArtifactId, + Name = artifact.Name, + Description = artifact.Description, + Parts = artifact.Parts.Select(ToV03Part).ToList(), + Extensions = artifact.Extensions, + Metadata = artifact.Metadata, + }; + + /// Converts a v1.0 task status update event to a v0.3 event. + /// The v1.0 task status update event to convert. + internal static V03.TaskStatusUpdateEvent ToV03StatusUpdate(A2A.TaskStatusUpdateEvent e) => + new() + { + TaskId = e.TaskId, + ContextId = e.ContextId, + Status = ToV03AgentTaskStatus(e.Status), + Metadata = e.Metadata, + }; + + /// Converts a v1.0 task artifact update event to a v0.3 event. + /// The v1.0 task artifact update event to convert. + internal static V03.TaskArtifactUpdateEvent ToV03ArtifactUpdate(A2A.TaskArtifactUpdateEvent e) => + new() + { + TaskId = e.TaskId, + ContextId = e.ContextId, + Artifact = ToV03Artifact(e.Artifact), + Append = e.Append, + LastChunk = e.LastChunk, + Metadata = e.Metadata, + }; + + // ──── Private helpers ──── + + private static V03.FileContent CreateV03FileContentFromBytes(string base64Bytes, string? mimeType, string? name) => + new(base64Bytes) { MimeType = mimeType, Name = name }; + + private static V03.FileContent CreateV03FileContentFromUri(Uri uri, string? mimeType, string? name) => + new(uri) { MimeType = mimeType, Name = name }; + + private static JsonElement DataDictionaryToElement(Dictionary data) + { + var buffer = new System.Buffers.ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + foreach (var kvp in data) + { + writer.WritePropertyName(kvp.Key); + kvp.Value.WriteTo(writer); + } + + writer.WriteEndObject(); + } + + var reader = new Utf8JsonReader(buffer.WrittenSpan); + return JsonElement.ParseValue(ref reader); + } + + private static Dictionary ToV03DataDictionary(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object) + { + var dict = new Dictionary(); + foreach (var property in element.EnumerateObject()) + { + dict[property.Name] = property.Value.Clone(); + } + + return dict; + } + + return new Dictionary + { + ["value"] = element.Clone(), + }; + } +} diff --git a/src/A2A/Client/A2AClientFactory.cs b/src/A2A/Client/A2AClientFactory.cs index 9bae32c9..c128b92e 100644 --- a/src/A2A/Client/A2AClientFactory.cs +++ b/src/A2A/Client/A2AClientFactory.cs @@ -1,16 +1,20 @@ using System.Collections.Concurrent; +using System.Text.Json; namespace A2A; /// -/// Factory for creating instances from an . -/// Selects the best protocol binding based on the agent's supported interfaces and the caller's preferences. +/// Factory for creating instances. +/// Supports creating clients from a parsed , a raw agent card JSON string, +/// or by fetching the agent card from a well-known URL. When given raw JSON, the factory detects +/// the protocol version and delegates to a registered fallback for non-v1.0 agent cards. /// /// /// The factory ships with built-in support for and /// . Additional bindings (including /// and custom bindings) can be registered via -/// . +/// . To support older protocol versions (e.g. v0.3), register a fallback +/// via or set . /// public static class A2AClientFactory { @@ -20,6 +24,10 @@ public static class A2AClientFactory [ProtocolBindingNames.JsonRpc] = (url, httpClient) => new A2AClient(url, httpClient), }; + private static Func? s_fallbackFactory; + + private static readonly HttpClient s_sharedClient = new(); + /// /// Registers a custom protocol binding so the factory can create clients for it. /// @@ -39,6 +47,26 @@ public static void Register(string protocolBinding, Func + /// Registers a global fallback factory for agent cards that do not declare + /// supportedInterfaces (e.g. v0.3 agents). This can be overridden per-call + /// via . + /// + /// + /// A delegate that creates an from the raw agent card JSON, + /// the agent's base URL, and an optional . + /// + public static void RegisterFallback(Func fallbackFactory) + { + ArgumentNullException.ThrowIfNull(fallbackFactory); + s_fallbackFactory = fallbackFactory; + } + + /// + /// Clears any previously registered global fallback factory. + /// + public static void ClearFallback() => s_fallbackFactory = null; + /// /// Creates an from an by selecting the /// best matching protocol binding from the card's . @@ -54,21 +82,14 @@ public static void Register(string protocolBinding, Func - /// - /// Selection follows spec Section 8.3: the agent's - /// order is respected (first entry is preferred), filtered to bindings listed in - /// . This means the agent's preference - /// wins when multiple bindings are mutually supported. - /// public static IA2AClient Create(AgentCard agentCard, HttpClient? httpClient = null, A2AClientOptions? options = null) { ArgumentNullException.ThrowIfNull(agentCard); options ??= new A2AClientOptions(); + var preferredSet = new HashSet(options.PreferredBindings, StringComparer.OrdinalIgnoreCase); - // Walk agent's interfaces in declared preference order (spec Section 8.3.1), - // selecting the first one the client also supports. foreach (var agentInterface in agentCard.SupportedInterfaces) { if (!preferredSet.Contains(agentInterface.ProtocolBinding)) @@ -97,4 +118,100 @@ public static IA2AClient Create(AgentCard agentCard, HttpClient? httpClient = nu $"No supported interface matches the preferred protocol bindings. Requested: [{requested}]. Available: [{available}].", A2AErrorCode.InvalidRequest); } + + /// + /// Creates an from a raw agent card JSON string. Detects the + /// protocol version from the card structure: cards with supportedInterfaces are + /// treated as v1.0 and routed through ; + /// all other cards are passed to a registered fallback factory. + /// + /// The raw JSON string of the agent card. + /// The base URL of the agent's hosting service, used as fallback when the card does not specify a URL. + /// Optional HTTP client to use for requests. + /// Optional client options controlling binding preference and fallback behavior. + /// An configured for the appropriate protocol version and binding. + /// + /// Thrown when the card does not declare supportedInterfaces and no fallback factory is available. + /// + public static IA2AClient Create(string agentCardJson, Uri baseUrl, HttpClient? httpClient = null, A2AClientOptions? options = null) + { + ArgumentNullException.ThrowIfNull(agentCardJson); + ArgumentNullException.ThrowIfNull(baseUrl); + + using var doc = JsonDocument.Parse(agentCardJson); + var root = doc.RootElement; + + if (root.TryGetProperty("supportedInterfaces", out var interfaces) && + interfaces.ValueKind == JsonValueKind.Array && + interfaces.GetArrayLength() > 0) + { + var agentCard = BuildAgentCard(root, interfaces, baseUrl); + return Create(agentCard, httpClient, options); + } + + var fallback = options?.FallbackFactory ?? s_fallbackFactory; + if (fallback is not null) + { + return fallback(agentCardJson, baseUrl, httpClient); + } + + throw new A2AException( + "Agent card does not declare supportedInterfaces and no fallback factory is registered. " + + "To support older protocol versions, register a fallback with A2AClientFactory.RegisterFallback.", + A2AErrorCode.InvalidRequest); + } + + /// + /// Fetches the agent card from the well-known URL and creates an appropriate . + /// + /// The base URL of the agent's hosting service. + /// Optional HTTP client to use for requests. + /// Optional client options controlling the agent card path, binding preference, and fallback behavior. + /// A cancellation token. + /// An configured for the appropriate protocol version and binding. + public static async Task CreateAsync( + Uri baseUrl, + HttpClient? httpClient = null, + A2AClientOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(baseUrl); + + options ??= new A2AClientOptions(); + var http = httpClient ?? s_sharedClient; + + var cardUri = new Uri(baseUrl, options.AgentCardPath); + + using var request = new HttpRequestMessage(HttpMethod.Get, cardUri); + request.Headers.TryAddWithoutValidation("A2A-Version", "1.0"); + + using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + return Create(json, baseUrl, httpClient, options); + } + + private static AgentCard BuildAgentCard(JsonElement root, JsonElement interfaces, Uri baseUrl) + { + var agentCard = new AgentCard + { + Name = root.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? "" : "", + Description = root.TryGetProperty("description", out var descProp) ? descProp.GetString() ?? "" : "", + SupportedInterfaces = [], + }; + + foreach (var iface in interfaces.EnumerateArray()) + { + agentCard.SupportedInterfaces.Add(new AgentInterface + { + Url = iface.TryGetProperty("url", out var urlProp) ? urlProp.GetString() ?? baseUrl.ToString() : baseUrl.ToString(), + ProtocolBinding = iface.TryGetProperty("protocolBinding", out var bindingProp) ? bindingProp.GetString() ?? "" : "", + ProtocolVersion = iface.TryGetProperty("protocolVersion", out var verProp) ? verProp.GetString() ?? "" : "", + }); + } + + return agentCard; + } } diff --git a/src/A2A/Client/A2AClientOptions.cs b/src/A2A/Client/A2AClientOptions.cs index b3d88eef..6d6ec61d 100644 --- a/src/A2A/Client/A2AClientOptions.cs +++ b/src/A2A/Client/A2AClientOptions.cs @@ -13,4 +13,22 @@ public sealed class A2AClientOptions /// The default preference is HTTP+JSON first, with JSON-RPC as fallback. /// public IList PreferredBindings { get; set; } = [ProtocolBindingNames.HttpJson, ProtocolBindingNames.JsonRpc]; + + /// + /// Gets or sets the path used to fetch the agent card. + /// Default is /.well-known/agent-card.json. + /// Only used by . + /// + public string AgentCardPath { get; set; } = "/.well-known/agent-card.json"; + + /// + /// Gets or sets a per-call fallback factory for agent cards that do not declare + /// supportedInterfaces. When set, this takes priority over the global fallback + /// registered via . + /// + /// + /// The delegate receives the raw agent card JSON, the base URL, and an optional , + /// and should return an appropriate for the agent's protocol version. + /// + public Func? FallbackFactory { get; set; } } diff --git a/tests/A2A.V0_3Compat.UnitTests/A2A.V0_3Compat.UnitTests.csproj b/tests/A2A.V0_3Compat.UnitTests/A2A.V0_3Compat.UnitTests.csproj new file mode 100644 index 00000000..7750bed2 --- /dev/null +++ b/tests/A2A.V0_3Compat.UnitTests/A2A.V0_3Compat.UnitTests.csproj @@ -0,0 +1,31 @@ + + + + net10.0;net8.0 + enable + enable + + false + true + $(NoWarn);IDE1006 + A2A.V0_3Compat.UnitTests + + + + + + + + + + + + + + + + + + + + diff --git a/tests/A2A.V0_3Compat.UnitTests/A2AClientFactoryTests.cs b/tests/A2A.V0_3Compat.UnitTests/A2AClientFactoryTests.cs new file mode 100644 index 00000000..f2797727 --- /dev/null +++ b/tests/A2A.V0_3Compat.UnitTests/A2AClientFactoryTests.cs @@ -0,0 +1,153 @@ +namespace A2A.V0_3Compat.UnitTests; + +public class A2AClientFactoryTests : IDisposable +{ + public void Dispose() + { + A2AClientFactory.ClearFallback(); + GC.SuppressFinalize(this); + } + + [Fact] + public void Create_WithV10Card_ReturnsA2AClient() + { + var cardJson = """ + { + "name": "Test Agent", + "description": "A test agent", + "version": "1.0", + "supportedInterfaces": [{ "url": "http://localhost/a2a", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" }], + "capabilities": { "streaming": true }, + "skills": [], + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"] + } + """; + + var client = A2AClientFactory.Create(cardJson, new Uri("http://localhost")); + + Assert.IsType(client); + } + + [Fact] + public void Create_WithV03Card_AndGlobalFallback_ReturnsV03Adapter() + { + V03FallbackRegistration.Register(); + + var cardJson = """ + { + "name": "Legacy Agent", + "description": "A legacy agent", + "version": "1.0", + "url": "http://localhost/a2a", + "protocolVersion": "0.3.0", + "capabilities": { "streaming": true }, + "skills": [], + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"], + "preferredTransport": "jsonrpc" + } + """; + + var client = A2AClientFactory.Create(cardJson, new Uri("http://localhost")); + + Assert.IsNotType(client); + Assert.IsAssignableFrom(client); + } + + [Fact] + public void Create_WithV03Card_AndPerCallFallback_ReturnsV03Adapter() + { + var cardJson = """ + { + "name": "Legacy Agent", + "description": "A legacy agent", + "version": "1.0", + "url": "http://localhost/a2a", + "protocolVersion": "0.3.0", + "capabilities": { "streaming": true }, + "skills": [], + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"], + "preferredTransport": "jsonrpc" + } + """; + + var options = new A2AClientOptions + { + FallbackFactory = V03FallbackRegistration.CreateV03Client, + }; + + var client = A2AClientFactory.Create(cardJson, new Uri("http://localhost"), options: options); + + Assert.IsNotType(client); + Assert.IsAssignableFrom(client); + } + + [Fact] + public void Create_WithV03Card_NoFallback_Throws() + { + var cardJson = """ + { + "name": "Legacy Agent", + "description": "A legacy agent", + "version": "1.0", + "url": "http://localhost/a2a", + "protocolVersion": "0.3.0", + "capabilities": {}, + "skills": [], + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"], + "preferredTransport": "jsonrpc" + } + """; + + Assert.Throws(() => + A2AClientFactory.Create(cardJson, new Uri("http://localhost"))); + } + + [Fact] + public void Create_WithV10Card_UsesInterfaceUrl() + { + var cardJson = """ + { + "name": "Test Agent", + "description": "An agent with interface URL", + "version": "1.0", + "supportedInterfaces": [{ "url": "http://specific-host/a2a", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" }], + "capabilities": {}, + "skills": [] + } + """; + + var client = A2AClientFactory.Create(cardJson, new Uri("http://fallback-host")); + + Assert.IsType(client); + } + + [Fact] + public void Create_WithEmptySupportedInterfaces_UsesPerCallFallback() + { + var cardJson = """ + { + "name": "Ambiguous Agent", + "description": "An agent with empty supportedInterfaces", + "version": "1.0", + "supportedInterfaces": [], + "url": "http://localhost/a2a", + "capabilities": {}, + "skills": [] + } + """; + + var options = new A2AClientOptions + { + FallbackFactory = V03FallbackRegistration.CreateV03Client, + }; + + var client = A2AClientFactory.Create(cardJson, new Uri("http://localhost"), options: options); + + Assert.IsNotType(client); + Assert.IsAssignableFrom(client); + } +} diff --git a/tests/A2A.V0_3Compat.UnitTests/A2AClientVersionHeaderTests.cs b/tests/A2A.V0_3Compat.UnitTests/A2AClientVersionHeaderTests.cs new file mode 100644 index 00000000..8f736a0b --- /dev/null +++ b/tests/A2A.V0_3Compat.UnitTests/A2AClientVersionHeaderTests.cs @@ -0,0 +1,98 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace A2A.V0_3Compat.UnitTests; + +public class A2AClientVersionHeaderTests +{ + [Fact] + public async Task SendMessageAsync_SendsVersionHeader() + { + // Arrange + HttpRequestMessage? capturedRequest = null; + + var responseResult = new A2A.SendMessageResponse + { + Message = new A2A.Message { MessageId = "id-1", Role = A2A.Role.User, Parts = [] }, + }; + var jsonResponse = new A2A.JsonRpcResponse + { + Id = new A2A.JsonRpcId("test-id"), + Result = JsonSerializer.SerializeToNode(responseResult, A2A.A2AJsonUtilities.DefaultOptions), + }; + var responseContent = JsonSerializer.Serialize(jsonResponse, A2A.A2AJsonUtilities.DefaultOptions); + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent, Encoding.UTF8, "application/json"), + }; + + var handler = new MockHttpMessageHandler(response, req => capturedRequest = req); + var httpClient = new HttpClient(handler); + var client = new A2A.A2AClient(new Uri("http://localhost"), httpClient); + + // Act + await client.SendMessageAsync(new A2A.SendMessageRequest + { + Message = new A2A.Message + { + MessageId = "m1", + Role = A2A.Role.User, + Parts = [A2A.Part.FromText("hi")], + }, + }); + + // Assert + Assert.NotNull(capturedRequest); + Assert.True(capturedRequest.Headers.Contains("A2A-Version")); + Assert.Equal("1.0", capturedRequest.Headers.GetValues("A2A-Version").Single()); + } + + [Fact] + public async Task SendStreamingMessageAsync_SendsVersionHeader() + { + // Arrange + HttpRequestMessage? capturedRequest = null; + + var streamResult = new A2A.StreamResponse + { + Message = new A2A.Message { MessageId = "id-1", Role = A2A.Role.Agent, Parts = [A2A.Part.FromText("hello")] }, + }; + var jsonResponse = new A2A.JsonRpcResponse + { + Id = new A2A.JsonRpcId("test-id"), + Result = JsonSerializer.SerializeToNode(streamResult, A2A.A2AJsonUtilities.DefaultOptions), + }; + var rpcJson = JsonSerializer.Serialize(jsonResponse, A2A.A2AJsonUtilities.DefaultOptions); + var ssePayload = $"event: message\ndata: {rpcJson}\n\n"; + + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(ssePayload, Encoding.UTF8, "text/event-stream"), + }; + + var handler = new MockHttpMessageHandler(response, req => capturedRequest = req); + var httpClient = new HttpClient(handler); + var client = new A2A.A2AClient(new Uri("http://localhost"), httpClient); + + // Act — consume the stream + await foreach (var _ in client.SendStreamingMessageAsync(new A2A.SendMessageRequest + { + Message = new A2A.Message + { + MessageId = "m2", + Role = A2A.Role.User, + Parts = [A2A.Part.FromText("stream me")], + }, + })) + { + // drain + } + + // Assert + Assert.NotNull(capturedRequest); + Assert.True(capturedRequest.Headers.Contains("A2A-Version")); + Assert.Equal("1.0", capturedRequest.Headers.GetValues("A2A-Version").Single()); + } +} diff --git a/tests/A2A.V0_3Compat.UnitTests/MockHttpMessageHandler.cs b/tests/A2A.V0_3Compat.UnitTests/MockHttpMessageHandler.cs new file mode 100644 index 00000000..530858ab --- /dev/null +++ b/tests/A2A.V0_3Compat.UnitTests/MockHttpMessageHandler.cs @@ -0,0 +1,19 @@ +namespace A2A.V0_3Compat.UnitTests; + +public class MockHttpMessageHandler : HttpMessageHandler +{ + private readonly HttpResponseMessage _response; + private readonly Action? _capture; + + public MockHttpMessageHandler(HttpResponseMessage response, Action? capture = null) + { + _response = response; + _capture = capture; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + _capture?.Invoke(request); + return Task.FromResult(_response); + } +} diff --git a/tests/A2A.V0_3Compat.UnitTests/V03TypeConverterTests.cs b/tests/A2A.V0_3Compat.UnitTests/V03TypeConverterTests.cs new file mode 100644 index 00000000..a14ac0e4 --- /dev/null +++ b/tests/A2A.V0_3Compat.UnitTests/V03TypeConverterTests.cs @@ -0,0 +1,413 @@ +using System.Text.Json; + +using V03 = A2A.V0_3; + +namespace A2A.V0_3Compat.UnitTests; + +public class V03TypeConverterTests +{ + // ──── Role conversion ──── + + [Fact] + public void ToV03Role_ConvertsUserRole() + { + var result = V03TypeConverter.ToV03Role(A2A.Role.User); + Assert.Equal(V03.MessageRole.User, result); + } + + [Fact] + public void ToV03Role_ConvertsAgentRole() + { + var result = V03TypeConverter.ToV03Role(A2A.Role.Agent); + Assert.Equal(V03.MessageRole.Agent, result); + } + + [Fact] + public void ToV1Role_ConvertsUserRole() + { + var result = V03TypeConverter.ToV1Role(V03.MessageRole.User); + Assert.Equal(A2A.Role.User, result); + } + + [Fact] + public void ToV1Role_ConvertsAgentRole() + { + var result = V03TypeConverter.ToV1Role(V03.MessageRole.Agent); + Assert.Equal(A2A.Role.Agent, result); + } + + // ──── State conversion ──── + + [Fact] + public void ToV1State_ConvertsAllStates() + { + Assert.Equal(A2A.TaskState.Submitted, V03TypeConverter.ToV1State(V03.TaskState.Submitted)); + Assert.Equal(A2A.TaskState.Working, V03TypeConverter.ToV1State(V03.TaskState.Working)); + Assert.Equal(A2A.TaskState.Completed, V03TypeConverter.ToV1State(V03.TaskState.Completed)); + Assert.Equal(A2A.TaskState.Failed, V03TypeConverter.ToV1State(V03.TaskState.Failed)); + Assert.Equal(A2A.TaskState.Canceled, V03TypeConverter.ToV1State(V03.TaskState.Canceled)); + Assert.Equal(A2A.TaskState.InputRequired, V03TypeConverter.ToV1State(V03.TaskState.InputRequired)); + Assert.Equal(A2A.TaskState.Rejected, V03TypeConverter.ToV1State(V03.TaskState.Rejected)); + Assert.Equal(A2A.TaskState.AuthRequired, V03TypeConverter.ToV1State(V03.TaskState.AuthRequired)); + Assert.Equal(A2A.TaskState.Unspecified, V03TypeConverter.ToV1State(V03.TaskState.Unknown)); + } + + // ──── Part conversion: v1 → v0.3 ──── + + [Fact] + public void ToV03Part_ConvertsTextPart() + { + var v1Part = A2A.Part.FromText("hello"); + + var v03Part = V03TypeConverter.ToV03Part(v1Part); + + var textPart = Assert.IsType(v03Part); + Assert.Equal("hello", textPart.Text); + } + + [Fact] + public void ToV03Part_ConvertsRawToBytesFilePart() + { + var bytes = new byte[] { 1, 2, 3 }; + var v1Part = A2A.Part.FromRaw(bytes, "application/octet-stream", "data.bin"); + + var v03Part = V03TypeConverter.ToV03Part(v1Part); + + var filePart = Assert.IsType(v03Part); + Assert.NotNull(filePart.File); + Assert.Equal(Convert.ToBase64String(bytes), filePart.File.Bytes); + Assert.Equal("application/octet-stream", filePart.File.MimeType); + Assert.Equal("data.bin", filePart.File.Name); + } + + [Fact] + public void ToV03Part_ConvertsUrlToUriFilePart() + { + var v1Part = A2A.Part.FromUrl("https://example.com/file.pdf", "application/pdf", "file.pdf"); + + var v03Part = V03TypeConverter.ToV03Part(v1Part); + + var filePart = Assert.IsType(v03Part); + Assert.NotNull(filePart.File); + Assert.Equal(new Uri("https://example.com/file.pdf"), filePart.File.Uri); + Assert.Equal("application/pdf", filePart.File.MimeType); + Assert.Equal("file.pdf", filePart.File.Name); + } + + [Fact] + public void ToV03Part_ConvertsDataPart() + { + var json = JsonSerializer.SerializeToElement(new { key = "value" }); + var v1Part = A2A.Part.FromData(json); + + var v03Part = V03TypeConverter.ToV03Part(v1Part); + + var dataPart = Assert.IsType(v03Part); + Assert.True(dataPart.Data.ContainsKey("key")); + Assert.Equal("value", dataPart.Data["key"].GetString()); + } + + // ──── Part conversion: v0.3 → v1 ──── + + [Fact] + public void ToV1Part_ConvertsTextPart() + { + var v03Part = new V03.TextPart { Text = "world" }; + + var v1Part = V03TypeConverter.ToV1Part(v03Part); + + Assert.Equal(A2A.PartContentCase.Text, v1Part.ContentCase); + Assert.Equal("world", v1Part.Text); + } + + [Fact] + public void ToV1Part_ConvertsBytesFilePartToRaw() + { + var bytes = new byte[] { 10, 20, 30 }; + var v03Part = new V03.FilePart + { + File = new V03.FileContent(Convert.ToBase64String(bytes)) + { + MimeType = "image/png", + Name = "image.png", + }, + }; + + var v1Part = V03TypeConverter.ToV1Part(v03Part); + + Assert.Equal(A2A.PartContentCase.Raw, v1Part.ContentCase); + Assert.Equal(bytes, v1Part.Raw); + Assert.Equal("image/png", v1Part.MediaType); + Assert.Equal("image.png", v1Part.Filename); + } + + [Fact] + public void ToV1Part_ConvertsUriFilePartToUrl() + { + var v03Part = new V03.FilePart + { + File = new V03.FileContent(new Uri("https://example.com/doc.txt")) + { + MimeType = "text/plain", + Name = "doc.txt", + }, + }; + + var v1Part = V03TypeConverter.ToV1Part(v03Part); + + Assert.Equal(A2A.PartContentCase.Url, v1Part.ContentCase); + Assert.Equal("https://example.com/doc.txt", v1Part.Url); + Assert.Equal("text/plain", v1Part.MediaType); + Assert.Equal("doc.txt", v1Part.Filename); + } + + [Fact] + public void ToV1Part_ConvertsDataPart() + { + var data = new Dictionary + { + ["foo"] = JsonSerializer.SerializeToElement("bar"), + }; + var v03Part = new V03.DataPart { Data = data }; + + var v1Part = V03TypeConverter.ToV1Part(v03Part); + + Assert.Equal(A2A.PartContentCase.Data, v1Part.ContentCase); + Assert.NotNull(v1Part.Data); + Assert.Equal(JsonValueKind.Object, v1Part.Data.Value.ValueKind); + Assert.Equal("bar", v1Part.Data.Value.GetProperty("foo").GetString()); + } + + // ──── Message conversion ──── + + [Fact] + public void ToV03Message_ConvertsAllFields() + { + var v1Message = new A2A.Message + { + Role = A2A.Role.Agent, + Parts = [A2A.Part.FromText("test")], + MessageId = "msg-1", + ContextId = "ctx-1", + TaskId = "task-1", + ReferenceTaskIds = ["ref-1"], + Extensions = ["ext-1"], + }; + + var v03Message = V03TypeConverter.ToV03Message(v1Message); + + Assert.Equal(V03.MessageRole.Agent, v03Message.Role); + Assert.Single(v03Message.Parts); + Assert.IsType(v03Message.Parts[0]); + Assert.Equal("msg-1", v03Message.MessageId); + Assert.Equal("ctx-1", v03Message.ContextId); + Assert.Equal("task-1", v03Message.TaskId); + Assert.Equal(["ref-1"], v03Message.ReferenceTaskIds); + Assert.Equal(["ext-1"], v03Message.Extensions); + } + + [Fact] + public void ToV1Message_ConvertsAllFields() + { + var v03Message = new V03.AgentMessage + { + Role = V03.MessageRole.User, + Parts = [new V03.TextPart { Text = "test" }], + MessageId = "msg-2", + ContextId = "ctx-2", + TaskId = "task-2", + ReferenceTaskIds = ["ref-2"], + Extensions = ["ext-2"], + }; + + var v1Message = V03TypeConverter.ToV1Message(v03Message); + + Assert.Equal(A2A.Role.User, v1Message.Role); + Assert.Single(v1Message.Parts); + Assert.Equal("test", v1Message.Parts[0].Text); + Assert.Equal("msg-2", v1Message.MessageId); + Assert.Equal("ctx-2", v1Message.ContextId); + Assert.Equal("task-2", v1Message.TaskId); + Assert.Equal(["ref-2"], v1Message.ReferenceTaskIds); + Assert.Equal(["ext-2"], v1Message.Extensions); + } + + // ──── Task conversion ──── + + [Fact] + public void ToV1Task_ConvertsAllFields() + { + var v03Task = new V03.AgentTask + { + Id = "t-1", + ContextId = "c-1", + Status = new V03.AgentTaskStatus + { + State = V03.TaskState.Working, + Timestamp = DateTimeOffset.Parse("2025-01-01T00:00:00Z", System.Globalization.CultureInfo.InvariantCulture), + }, + History = + [ + new V03.AgentMessage + { + Role = V03.MessageRole.User, + Parts = [new V03.TextPart { Text = "hi" }], + MessageId = "h-1", + }, + ], + Artifacts = + [ + new V03.Artifact + { + ArtifactId = "a-1", + Name = "artifact", + Parts = [new V03.TextPart { Text = "content" }], + }, + ], + }; + + var v1Task = V03TypeConverter.ToV1Task(v03Task); + + Assert.Equal("t-1", v1Task.Id); + Assert.Equal("c-1", v1Task.ContextId); + Assert.Equal(A2A.TaskState.Working, v1Task.Status.State); + Assert.NotNull(v1Task.History); + Assert.Single(v1Task.History); + Assert.Equal("hi", v1Task.History[0].Parts[0].Text); + Assert.NotNull(v1Task.Artifacts); + Assert.Single(v1Task.Artifacts); + Assert.Equal("a-1", v1Task.Artifacts[0].ArtifactId); + } + + // ──── Response conversion ──── + + [Fact] + public void ToV1Response_ConvertsTaskResponse() + { + var v03Task = new V03.AgentTask + { + Id = "t-1", + ContextId = "c-1", + Status = new V03.AgentTaskStatus { State = V03.TaskState.Completed }, + }; + + var v1Response = V03TypeConverter.ToV1Response(v03Task); + + Assert.Equal(A2A.SendMessageResponseCase.Task, v1Response.PayloadCase); + Assert.NotNull(v1Response.Task); + Assert.Equal("t-1", v1Response.Task.Id); + } + + [Fact] + public void ToV1Response_ConvertsMessageResponse() + { + var v03Message = new V03.AgentMessage + { + Role = V03.MessageRole.Agent, + Parts = [new V03.TextPart { Text = "reply" }], + MessageId = "msg-r", + }; + + var v1Response = V03TypeConverter.ToV1Response(v03Message); + + Assert.Equal(A2A.SendMessageResponseCase.Message, v1Response.PayloadCase); + Assert.NotNull(v1Response.Message); + Assert.Equal("reply", v1Response.Message.Parts[0].Text); + } + + // ──── Stream response conversion ──── + + [Fact] + public void ToV1StreamResponse_ConvertsTaskEvent() + { + var v03Task = new V03.AgentTask + { + Id = "t-1", + ContextId = "c-1", + Status = new V03.AgentTaskStatus { State = V03.TaskState.Submitted }, + }; + + var v1Stream = V03TypeConverter.ToV1StreamResponse(v03Task); + + Assert.Equal(A2A.StreamResponseCase.Task, v1Stream.PayloadCase); + Assert.NotNull(v1Stream.Task); + Assert.Equal("t-1", v1Stream.Task.Id); + } + + [Fact] + public void ToV1StreamResponse_ConvertsMessageEvent() + { + var v03Message = new V03.AgentMessage + { + Role = V03.MessageRole.Agent, + Parts = [new V03.TextPart { Text = "streaming" }], + MessageId = "msg-s", + }; + + var v1Stream = V03TypeConverter.ToV1StreamResponse(v03Message); + + Assert.Equal(A2A.StreamResponseCase.Message, v1Stream.PayloadCase); + Assert.NotNull(v1Stream.Message); + Assert.Equal("streaming", v1Stream.Message.Parts[0].Text); + } + + [Fact] + public void ToV1StreamResponse_ConvertsStatusUpdateEvent() + { + var v03StatusUpdate = new V03.TaskStatusUpdateEvent + { + TaskId = "t-2", + ContextId = "c-2", + Status = new V03.AgentTaskStatus + { + State = V03.TaskState.Working, + Message = new V03.AgentMessage + { + Role = V03.MessageRole.Agent, + Parts = [new V03.TextPart { Text = "working..." }], + MessageId = "status-msg", + }, + }, + Final = true, + }; + + var v1Stream = V03TypeConverter.ToV1StreamResponse(v03StatusUpdate); + + Assert.Equal(A2A.StreamResponseCase.StatusUpdate, v1Stream.PayloadCase); + Assert.NotNull(v1Stream.StatusUpdate); + Assert.Equal("t-2", v1Stream.StatusUpdate.TaskId); + Assert.Equal("c-2", v1Stream.StatusUpdate.ContextId); + Assert.Equal(A2A.TaskState.Working, v1Stream.StatusUpdate.Status.State); + Assert.NotNull(v1Stream.StatusUpdate.Status.Message); + Assert.Equal("working...", v1Stream.StatusUpdate.Status.Message.Parts[0].Text); + } + + [Fact] + public void ToV1StreamResponse_ConvertsArtifactUpdateEvent() + { + var v03ArtifactUpdate = new V03.TaskArtifactUpdateEvent + { + TaskId = "t-3", + ContextId = "c-3", + Artifact = new V03.Artifact + { + ArtifactId = "art-1", + Name = "output", + Parts = [new V03.TextPart { Text = "artifact content" }], + }, + Append = true, + LastChunk = false, + }; + + var v1Stream = V03TypeConverter.ToV1StreamResponse(v03ArtifactUpdate); + + Assert.Equal(A2A.StreamResponseCase.ArtifactUpdate, v1Stream.PayloadCase); + Assert.NotNull(v1Stream.ArtifactUpdate); + Assert.Equal("t-3", v1Stream.ArtifactUpdate.TaskId); + Assert.Equal("c-3", v1Stream.ArtifactUpdate.ContextId); + Assert.Equal("art-1", v1Stream.ArtifactUpdate.Artifact.ArtifactId); + Assert.Equal("output", v1Stream.ArtifactUpdate.Artifact.Name); + Assert.True(v1Stream.ArtifactUpdate.Append); + Assert.False(v1Stream.ArtifactUpdate.LastChunk); + } +} From a3030a49d57e5b3e8dcca2304f94b28bc3aafd7c Mon Sep 17 00:00:00 2001 From: "Shanshan Xu (from Dev Box)" Date: Thu, 26 Mar 2026 22:12:28 +0800 Subject: [PATCH 02/10] fix(V03ServerProcessor): route v1.0 method names before V03 deserialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V03.JsonRpcRequestConverter rejects v1.0 method names (e.g. SendMessage, GetTask, SendStreamingMessage) with MethodNotFound during deserialization, making the default passthrough case in HandleSingleAsync unreachable. Fix: parse the request body once via JsonDocument to peek at the method field, then route to a new HandleV1RequestAsync path if the method is not a recognized v0.3 name. HandleV1RequestAsync bypasses the V03 deserializer entirely and delegates directly to A2AJsonRpcProcessor. Also: - Promote A2AJsonRpcProcessor.SingleResponseAsync and StreamResponse to public so V03ServerProcessor can call them - Add MapAgentCardGetWithV03Compat extension: serves AgentCard in v0.3 or v1.0 format based on A2A-Version header negotiation - Add V03TypeConverter.ToV03AgentCard for v1.0→v0.3 AgentCard conversion - Bump version to 1.0.0-preview3 Co-Authored-By: Claude Sonnet 4.6 --- src/A2A.AspNetCore/A2AJsonRpcProcessor.cs | 22 ++- .../V03ServerCompatEndpointExtensions.cs | 62 +++++++++ src/A2A.V0_3Compat/V03ServerProcessor.cs | 125 +++++++++++++++--- src/A2A.V0_3Compat/V03TypeConverter.cs | 52 ++++++++ src/Directory.Build.props | 2 +- 5 files changed, 242 insertions(+), 21 deletions(-) diff --git a/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs b/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs index 44f6588a..c0d39a56 100644 --- a/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs +++ b/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs @@ -55,7 +55,16 @@ internal static async Task ProcessRequestAsync(IA2ARequestHandler reque } } - internal static async Task SingleResponseAsync(IA2ARequestHandler requestHandler, JsonRpcId requestId, string method, JsonElement? parameters, CancellationToken cancellationToken) + /// + /// Handles a single (non-streaming) JSON-RPC request with a v1.0 method name and parameters. + /// + /// The v1.0 A2A request handler. + /// The JSON-RPC request ID. + /// The JSON-RPC method name. + /// The JSON-RPC parameters element. + /// The cancellation token. + /// A containing the response. + public static async Task SingleResponseAsync(IA2ARequestHandler requestHandler, JsonRpcId requestId, string method, JsonElement? parameters, CancellationToken cancellationToken) { using var activity = A2AAspNetCoreDiagnostics.Source.StartActivity($"SingleResponse/{method}", ActivityKind.Server); activity?.SetTag("request.id", requestId.ToString()); @@ -186,7 +195,16 @@ private static T DeserializeAndValidate(JsonElement jsonParamValue) where T : return parms; } - internal static IResult StreamResponse(IA2ARequestHandler requestHandler, JsonRpcId requestId, string method, JsonElement? parameters, CancellationToken cancellationToken) + /// + /// Handles a streaming JSON-RPC request with a v1.0 method name and parameters. + /// + /// The v1.0 A2A request handler. + /// The JSON-RPC request ID. + /// The JSON-RPC method name. + /// The JSON-RPC parameters element. + /// The cancellation token. + /// An that streams the response events. + public static IResult StreamResponse(IA2ARequestHandler requestHandler, JsonRpcId requestId, string method, JsonElement? parameters, CancellationToken cancellationToken) { using var activity = A2AAspNetCoreDiagnostics.Source.StartActivity("StreamResponse", ActivityKind.Server); activity?.SetTag("request.id", requestId.ToString()); diff --git a/src/A2A.V0_3Compat/V03ServerCompatEndpointExtensions.cs b/src/A2A.V0_3Compat/V03ServerCompatEndpointExtensions.cs index 3d854097..7f03e769 100644 --- a/src/A2A.V0_3Compat/V03ServerCompatEndpointExtensions.cs +++ b/src/A2A.V0_3Compat/V03ServerCompatEndpointExtensions.cs @@ -40,4 +40,66 @@ public static IEndpointConventionBuilder MapA2AWithV03Compat( return routeGroup; } + + /// + /// Maps agent card discovery endpoints that serve the agent card in v0.3 or v1.0 format + /// based on the A2A-Version request header, supporting both client versions simultaneously. + /// Registers both GET {path}/ and GET {path}/.well-known/agent-card.json. + /// + /// + /// Format negotiation is based on the A2A-Version request header: + /// + /// + /// GET {path}/: returns v1.0 by default; returns v0.3 only when A2A-Version: 0.3 is present. + /// + /// + /// GET {path}/.well-known/agent-card.json: returns v0.3 by default (backward compatibility for + /// v0.3 clients that do not send the header); returns v1.0 when A2A-Version: 1.0 is present + /// (as sent by ). + /// + /// + /// Use this instead of the host's v1.0 agent card method during a v0.3-to-v1.0 migration + /// period. Once all clients have upgraded to v1.0, replace this call with the host's v1.0 + /// equivalent to remove the header-based negotiation. + /// + /// The endpoint route builder. + /// A factory that returns the v1.0 agent card to serve or convert. + /// The route prefix for the agent card endpoints. + /// An endpoint convention builder for further configuration. + [RequiresDynamicCode("MapAgentCardGetWithV03Compat uses runtime reflection for route binding. For AOT-compatible usage, use a source-generated host.")] + [RequiresUnreferencedCode("MapAgentCardGetWithV03Compat may perform reflection on types that are not preserved by trimming.")] + public static IEndpointConventionBuilder MapAgentCardGetWithV03Compat( + this IEndpointRouteBuilder endpoints, + Func> getAgentCardAsync, + [StringSyntax("Route")] string path = "") + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(getAgentCardAsync); + + var routeGroup = endpoints.MapGroup(path); + + // v1.0 clients use GET / — negotiate format via A2A-Version header + routeGroup.MapGet(string.Empty, async (HttpRequest request) => + { + var v1Card = await getAgentCardAsync(); + var version = request.Headers["A2A-Version"].FirstOrDefault(); + return version == "0.3" + ? Results.Ok(V03TypeConverter.ToV03AgentCard(v1Card)) + : Results.Ok(v1Card); + }); + + // Both v0.3 and v1.0 clients use GET .well-known/agent-card.json. + // v1.0 clients (A2AClientFactory.CreateAsync) send A2A-Version: 1.0; return v1.0 format. + // v0.3 clients send no header; default to v0.3 format for backward compatibility. + routeGroup.MapGet(".well-known/agent-card.json", async (HttpRequest request, CancellationToken ct) => + { + var v1Card = await getAgentCardAsync(); + var version = request.Headers["A2A-Version"].FirstOrDefault(); + return version == "1.0" + ? Results.Ok(v1Card) + : Results.Ok(V03TypeConverter.ToV03AgentCard(v1Card)); + }); + + return routeGroup; + } } diff --git a/src/A2A.V0_3Compat/V03ServerProcessor.cs b/src/A2A.V0_3Compat/V03ServerProcessor.cs index fd264d58..906e5923 100644 --- a/src/A2A.V0_3Compat/V03ServerProcessor.cs +++ b/src/A2A.V0_3Compat/V03ServerProcessor.cs @@ -1,7 +1,9 @@ namespace A2A.V0_3Compat; +using A2A.AspNetCore; using Microsoft.AspNetCore.Http; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using V03 = A2A.V0_3; @@ -27,40 +29,113 @@ public static async Task ProcessRequestAsync( ArgumentNullException.ThrowIfNull(requestHandler); ArgumentNullException.ThrowIfNull(request); - V03.JsonRpcRequest? rpcRequest = null; - + // Parse body once to peek at "method" and route v0.3 vs v1.0 before full validation. + // V03.JsonRpcRequestConverter rejects v1.0 method names, so we must detect them early. + JsonDocument doc; try { - rpcRequest = (V03.JsonRpcRequest?)await JsonSerializer.DeserializeAsync( - request.Body, - V03.A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(V03.JsonRpcRequest)), - cancellationToken).ConfigureAwait(false); + doc = await JsonDocument.ParseAsync(request.Body, cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + catch (JsonException) + { + return MakeErrorResult(default, V03.JsonRpcResponse.ParseErrorResponse); + } + + using (doc) + { + var root = doc.RootElement; + + if (!root.TryGetProperty("method", out var methodProp) || + methodProp.ValueKind != JsonValueKind.String) + { + return MakeErrorResult(default, V03.JsonRpcResponse.ParseErrorResponse); + } - if (rpcRequest is null) + var method = methodProp.GetString() ?? string.Empty; + + // v1.0 method names: bypass V03 validator, delegate directly to v1.0 processor. + if (!V03.A2AMethods.IsValidMethod(method)) + { + return await HandleV1RequestAsync(requestHandler, root, method, cancellationToken) + .ConfigureAwait(false); + } + + // v0.3 method names: deserialize with full V03 validation. + V03.JsonRpcRequest? rpcRequest = null; + try + { + var typeInfo = (JsonTypeInfo)V03.A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(V03.JsonRpcRequest)); + rpcRequest = JsonSerializer.Deserialize(root, typeInfo); + + if (rpcRequest is null) + { + return MakeErrorResult(default, V03.JsonRpcResponse.ParseErrorResponse); + } + + if (V03.A2AMethods.IsStreamingMethod(rpcRequest.Method)) + { + return HandleStreaming(requestHandler, rpcRequest, cancellationToken); + } + + return await HandleSingleAsync(requestHandler, rpcRequest, cancellationToken).ConfigureAwait(false); + } + catch (A2AException ex) + { + var id = rpcRequest?.Id ?? default; + return MakeV03ErrorResult(id, ex); + } + catch (JsonException) { return MakeErrorResult(default, V03.JsonRpcResponse.ParseErrorResponse); } + catch (Exception) + { + var id = rpcRequest?.Id ?? default; + return MakeErrorResult(id, V03.JsonRpcResponse.InternalErrorResponse); + } + } + } - if (V03.A2AMethods.IsStreamingMethod(rpcRequest.Method)) + // Handles v1.0 method names routed directly from ProcessRequestAsync. + // Extracts id and params from the already-parsed JsonElement and delegates to the v1.0 processor. + private static async Task HandleV1RequestAsync( + IA2ARequestHandler handler, + JsonElement root, + string method, + CancellationToken ct) + { + var id = root.TryGetProperty("id", out var idEl) + ? idEl.ValueKind switch { - return HandleStreaming(requestHandler, rpcRequest, cancellationToken); + JsonValueKind.String => new JsonRpcId(idEl.GetString()), + JsonValueKind.Number when idEl.TryGetInt64(out var n) => new JsonRpcId(n), + _ => new JsonRpcId((string?)null) } + : new JsonRpcId((string?)null); - return await HandleSingleAsync(requestHandler, rpcRequest, cancellationToken).ConfigureAwait(false); + JsonElement? paramsEl = null; + if (root.TryGetProperty("params", out var p) && p.ValueKind == JsonValueKind.Object) + { + paramsEl = p.Clone(); } - catch (A2AException ex) + + try { - var id = rpcRequest?.Id ?? default; - return MakeV03ErrorResult(id, ex); + if (A2AMethods.IsStreamingMethod(method)) + { + return A2AJsonRpcProcessor.StreamResponse(handler, id, method, paramsEl, ct); + } + return await A2AJsonRpcProcessor.SingleResponseAsync(handler, id, method, paramsEl, ct) + .ConfigureAwait(false); } - catch (JsonException) + catch (A2AException ex) { - return MakeErrorResult(default, V03.JsonRpcResponse.ParseErrorResponse); + return new JsonRpcResponseResult(JsonRpcResponse.CreateJsonRpcErrorResponse(id, ex)); } catch (Exception) { - var id = rpcRequest?.Id ?? default; - return MakeErrorResult(id, V03.JsonRpcResponse.InternalErrorResponse); + return new JsonRpcResponseResult(JsonRpcResponse.InternalErrorResponse(id, "An internal error occurred.")); } } @@ -136,7 +211,16 @@ private static async Task HandleSingleAsync( } default: - return MakeErrorResult(rpcRequest.Id, V03.JsonRpcResponse.MethodNotFoundResponse); + { + // Unrecognized v0.3 method — delegate to v1.0 processor. + // This handles v1.0 clients sending v1.0 method names (e.g. "SendMessage"). + var v1Id = ToV1Id(rpcRequest.Id); + if (A2AMethods.IsStreamingMethod(rpcRequest.Method)) + { + return A2AJsonRpcProcessor.StreamResponse(handler, v1Id, rpcRequest.Method, rpcRequest.Params, ct); + } + return await A2AJsonRpcProcessor.SingleResponseAsync(handler, v1Id, rpcRequest.Method, rpcRequest.Params, ct).ConfigureAwait(false); + } } } @@ -201,4 +285,9 @@ private static V03JsonRpcResponseResult MakeErrorResult(V03.JsonRpcId id, Func new(V03.JsonRpcResponse.CreateJsonRpcErrorResponse(id, new V03.A2AException(ex.Message, (V03.A2AErrorCode)(int)ex.ErrorCode))); + + private static JsonRpcId ToV1Id(V03.JsonRpcId v03Id) => + v03Id.IsString ? new JsonRpcId(v03Id.AsString()) : + v03Id.IsNumber ? new JsonRpcId(v03Id.AsNumber()!.Value) : + new JsonRpcId((string?)null); } diff --git a/src/A2A.V0_3Compat/V03TypeConverter.cs b/src/A2A.V0_3Compat/V03TypeConverter.cs index c0b85196..e3e91f9f 100644 --- a/src/A2A.V0_3Compat/V03TypeConverter.cs +++ b/src/A2A.V0_3Compat/V03TypeConverter.cs @@ -7,6 +7,58 @@ namespace A2A.V0_3Compat; /// Bidirectional type conversion between A2A v1.0 and v0.3 models. internal static class V03TypeConverter { + // ──── v1.0 AgentCard → v0.3 AgentCard ──── + + /// Converts a v1.0 AgentCard to a v0.3 AgentCard for serving to v0.3 clients. + /// The v1.0 agent card to convert. + /// A v0.3 agent card that v0.3 clients can parse. + internal static V03.AgentCard ToV03AgentCard(A2A.AgentCard v1Card) + { + // The v0.3 AgentCard requires a top-level URL; extract it from the first supported interface. + var primaryInterface = v1Card.SupportedInterfaces.FirstOrDefault(); + var url = primaryInterface?.Url ?? string.Empty; + var transport = primaryInterface?.ProtocolBinding is { } binding + ? new V03.AgentTransport(binding) + : V03.AgentTransport.JsonRpc; + + return new V03.AgentCard + { + Name = v1Card.Name, + Description = v1Card.Description, + Version = v1Card.Version, + Url = url, + ProtocolVersion = "0.3.0", + PreferredTransport = transport, + DocumentationUrl = v1Card.DocumentationUrl, + IconUrl = v1Card.IconUrl, + Provider = v1Card.Provider is { } p ? new V03.AgentProvider + { + Organization = p.Organization, + Url = p.Url, + } : null, + Capabilities = new V03.AgentCapabilities + { + Streaming = v1Card.Capabilities.Streaming ?? false, + PushNotifications = v1Card.Capabilities.PushNotifications ?? false, + }, + DefaultInputModes = v1Card.DefaultInputModes, + DefaultOutputModes = v1Card.DefaultOutputModes, + Skills = v1Card.Skills.Select(ToV03AgentSkill).ToList(), + SupportsAuthenticatedExtendedCard = v1Card.Capabilities.ExtendedAgentCard ?? false, + }; + } + + private static V03.AgentSkill ToV03AgentSkill(A2A.AgentSkill skill) => new() + { + Id = skill.Id, + Name = skill.Name, + Description = skill.Description, + Tags = skill.Tags, + Examples = skill.Examples, + InputModes = skill.InputModes, + OutputModes = skill.OutputModes, + }; + // ──── v1.0 → v0.3 (request conversion) ──── /// Converts a v1.0 send message request to v0.3 message send params. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 03e810be..2168bf1e 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ - 1.0.0-preview2 + 1.0.0-preview3 Community Contributors Apache-2.0 https://github.com/a2aproject/a2a-dotnet From aa0207c766f7ec60bccfe9191e729f727e0b2fc8 Mon Sep 17 00:00:00 2001 From: "Shanshan Xu (from Dev Box)" Date: Thu, 26 Mar 2026 22:37:35 +0800 Subject: [PATCH 03/10] refactor(V03ServerProcessor): delegate pre-flight to A2AJsonRpcProcessor.CheckPreflight Extract version header validation from A2AJsonRpcProcessor.ProcessRequestAsync into a new public static CheckPreflight method. V03ServerProcessor now calls CheckPreflight instead of duplicating the logic, making it a true extension of the v1.0 processor rather than a parallel implementation. Any future protocol-level checks added to CheckPreflight automatically apply to both the v1.0 and v0.3 compat paths. Bump version to 1.0.0-preview4. Co-Authored-By: Claude Sonnet 4.6 --- src/A2A.AspNetCore/A2AJsonRpcProcessor.cs | 17 +++++++++++++++-- src/A2A.V0_3Compat/V03ServerProcessor.cs | 5 +++++ src/Directory.Build.props | 2 +- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs b/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs index c0d39a56..6e4018ab 100644 --- a/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs +++ b/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs @@ -10,9 +10,15 @@ namespace A2A.AspNetCore; /// public static class A2AJsonRpcProcessor { - internal static async Task ProcessRequestAsync(IA2ARequestHandler requestHandler, HttpRequest request, CancellationToken cancellationToken) + /// + /// Validates protocol-level preconditions on the incoming HTTP request before body parsing. + /// Returns a non-null error response if the request should be rejected, + /// or null if the request may proceed. + /// Call this at the top of any request processor that wraps or extends this class. + /// + public static IResult? CheckPreflight(HttpRequest request) { - // Version negotiation: check A2A-Version header + ArgumentNullException.ThrowIfNull(request); var version = request.Headers["A2A-Version"].FirstOrDefault(); if (!string.IsNullOrEmpty(version) && version != "1.0" && version != "0.3") { @@ -22,6 +28,13 @@ internal static async Task ProcessRequestAsync(IA2ARequestHandler reque $"Protocol version '{version}' is not supported. Supported versions: 0.3, 1.0", A2AErrorCode.VersionNotSupported))); } + return null; + } + + internal static async Task ProcessRequestAsync(IA2ARequestHandler requestHandler, HttpRequest request, CancellationToken cancellationToken) + { + var preflightResult = CheckPreflight(request); + if (preflightResult != null) return preflightResult; using var activity = A2AAspNetCoreDiagnostics.Source.StartActivity("HandleA2ARequest", ActivityKind.Server); diff --git a/src/A2A.V0_3Compat/V03ServerProcessor.cs b/src/A2A.V0_3Compat/V03ServerProcessor.cs index 906e5923..371e3e11 100644 --- a/src/A2A.V0_3Compat/V03ServerProcessor.cs +++ b/src/A2A.V0_3Compat/V03ServerProcessor.cs @@ -29,6 +29,11 @@ public static async Task ProcessRequestAsync( ArgumentNullException.ThrowIfNull(requestHandler); ArgumentNullException.ThrowIfNull(request); + // Delegate pre-flight checks to A2AJsonRpcProcessor — single source of truth. + // Any new protocol-level checks added there automatically apply here. + var preflightResult = A2AJsonRpcProcessor.CheckPreflight(request); + if (preflightResult != null) return preflightResult; + // Parse body once to peek at "method" and route v0.3 vs v1.0 before full validation. // V03.JsonRpcRequestConverter rejects v1.0 method names, so we must detect them early. JsonDocument doc; diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 2168bf1e..266c0442 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ - 1.0.0-preview3 + 1.0.0-preview4 Community Contributors Apache-2.0 https://github.com/a2aproject/a2a-dotnet From ffe4625065fc36cc58baa656d4a0357d7413a01c Mon Sep 17 00:00:00 2001 From: "Shanshan Xu (from Dev Box)" Date: Thu, 26 Mar 2026 22:44:02 +0800 Subject: [PATCH 04/10] fix(A2AJsonRpcProcessor): add missing param doc to CheckPreflight Co-Authored-By: Claude Sonnet 4.6 --- src/A2A.AspNetCore/A2AJsonRpcProcessor.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs b/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs index 6e4018ab..87815838 100644 --- a/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs +++ b/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs @@ -16,6 +16,7 @@ public static class A2AJsonRpcProcessor /// or null if the request may proceed. /// Call this at the top of any request processor that wraps or extends this class. /// + /// The incoming HTTP request. public static IResult? CheckPreflight(HttpRequest request) { ArgumentNullException.ThrowIfNull(request); From b93aeb427c81bf05243c813898202d9ae796286d Mon Sep 17 00:00:00 2001 From: "Shanshan Xu (from Dev Box)" Date: Thu, 26 Mar 2026 23:24:24 +0800 Subject: [PATCH 05/10] test(V03ServerProcessor): add unit tests for v0.3/v1.0 routing and CheckPreflight delegation Tests for commit 49d5f02 (v1.0 method bypass) and 06c0417 (CheckPreflight delegation): - v1.0 method names (SendMessage, GetTask) route via HandleV1RequestAsync, bypass V03 deserializer - unsupported A2A-Version header returns -32009 before body is parsed - v0.3 method names (message/send, tasks/get, tasks/cancel) translate and return v0.3 wire format - v0.3 response uses lowercase state, not TASK_STATE_* - malformed JSON and missing method field return parse error Co-Authored-By: Claude Sonnet 4.6 --- .../V03ServerProcessorTests.cs | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 tests/A2A.V0_3Compat.UnitTests/V03ServerProcessorTests.cs diff --git a/tests/A2A.V0_3Compat.UnitTests/V03ServerProcessorTests.cs b/tests/A2A.V0_3Compat.UnitTests/V03ServerProcessorTests.cs new file mode 100644 index 00000000..e3500e7b --- /dev/null +++ b/tests/A2A.V0_3Compat.UnitTests/V03ServerProcessorTests.cs @@ -0,0 +1,250 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using System.Text; +using System.Text.Json; + +namespace A2A.V0_3Compat.UnitTests; + +/// +/// Tests for : +/// - v0.3 method names are translated to v1.0 and responses are converted back to v0.3 wire format +/// - v1.0 method names bypass the V03 deserializer and route directly to the v1.0 processor +/// - Unsupported A2A-Version header returns -32009 (delegated to A2AJsonRpcProcessor.CheckPreflight) +/// +public class V03ServerProcessorTests +{ + // ── Version negotiation ────────────────────────────────────────────────── + + [Fact] + public async Task ProcessRequestAsync_UnsupportedVersionHeader_Returns32009() + { + // Tests commit 06c0417: CheckPreflight delegation — V03ServerProcessor must return + // -32009 for unsupported versions without touching the request body. + var handler = CreateRequestHandler(); + var request = CreateHttpRequest( + """{"jsonrpc":"2.0","method":"message/send","id":1,"params":{"message":{"kind":"agentMessage","messageId":"m1","role":"user","parts":[{"kind":"text","text":"hi"}]}}}""", + version: "99.0"); + + var result = await V03ServerProcessor.ProcessRequestAsync(handler, request, CancellationToken.None); + + using var body = await ExecuteAndParseJson(result); + Assert.Equal(-32009, body.RootElement.GetProperty("error").GetProperty("code").GetInt32()); + } + + [Fact] + public async Task ProcessRequestAsync_V03VersionHeader_V03Body_Succeeds() + { + // A2A-Version: 0.3 is accepted; v0.3 body must be processed normally (no -32009). + var handler = CreateRequestHandler(); + var request = CreateHttpRequest( + """{"jsonrpc":"2.0","method":"message/send","id":1,"params":{"message":{"kind":"agentMessage","messageId":"m1","role":"user","parts":[{"kind":"text","text":"hi"}]}}}""", + version: "0.3"); + + var result = await V03ServerProcessor.ProcessRequestAsync(handler, request, CancellationToken.None); + + using var body = await ExecuteAndParseJson(result); + Assert.False(body.RootElement.TryGetProperty("error", out _), + "Expected no error for A2A-Version: 0.3 with v0.3 body"); + } + + [Fact] + public async Task ProcessRequestAsync_V1VersionHeader_V1Method_Succeeds() + { + // A2A-Version: 1.0 is accepted; v1.0 method must be routed to the v1.0 processor. + var handler = CreateRequestHandler(); + var request = CreateHttpRequest( + """{"jsonrpc":"2.0","method":"SendMessage","id":1,"params":{"message":{"messageId":"m1","role":"ROLE_USER","parts":[{"text":"hi"}]}}}""", + version: "1.0"); + + var result = await V03ServerProcessor.ProcessRequestAsync(handler, request, CancellationToken.None); + + using var body = await ExecuteAndParseJson(result); + Assert.False(body.RootElement.TryGetProperty("error", out _), + "Expected no error for A2A-Version: 1.0 with v1.0 method"); + } + + // ── v0.3 method routing ────────────────────────────────────────────────── + + [Fact] + public async Task ProcessRequestAsync_V03MessageSend_ReturnsV03WireFormat() + { + // v0.3 'message/send' must be translated and the response returned in v0.3 wire format: + // - result is at the top level (not result.task) + // - state uses kebab-case / lowercase, NOT SCREAMING_SNAKE_CASE + var handler = CreateRequestHandler(); + var request = CreateHttpRequest( + """{"jsonrpc":"2.0","method":"message/send","id":1,"params":{"message":{"kind":"agentMessage","messageId":"m1","role":"user","parts":[{"kind":"text","text":"Who is my manager?"}]}}}"""); + + var result = await V03ServerProcessor.ProcessRequestAsync(handler, request, CancellationToken.None); + + using var body = await ExecuteAndParseJson(result); + var rawText = body.RootElement.GetRawText(); + Assert.True(body.RootElement.TryGetProperty("result", out _), + "Expected 'result' at top level for v0.3 message/send"); + Assert.False(body.RootElement.TryGetProperty("error", out _), + "Expected no error for valid v0.3 message/send"); + Assert.DoesNotContain("TASK_STATE_", rawText); + } + + [Fact] + public async Task ProcessRequestAsync_V03TasksGet_NonExistentTask_ReturnsTaskNotFoundError() + { + var handler = CreateRequestHandler(); + var request = CreateHttpRequest( + """{"jsonrpc":"2.0","method":"tasks/get","id":1,"params":{"id":"nonexistent-task-v03"}}"""); + + var result = await V03ServerProcessor.ProcessRequestAsync(handler, request, CancellationToken.None); + + using var body = await ExecuteAndParseJson(result); + Assert.True(body.RootElement.TryGetProperty("error", out var errorEl), + "Expected error for nonexistent task"); + Assert.Equal(-32001, errorEl.GetProperty("code").GetInt32()); // TaskNotFound + } + + [Fact] + public async Task ProcessRequestAsync_V03TasksCancel_NonExistentTask_ReturnsTaskNotFoundError() + { + var handler = CreateRequestHandler(); + var request = CreateHttpRequest( + """{"jsonrpc":"2.0","method":"tasks/cancel","id":1,"params":{"id":"nonexistent-task-v03"}}"""); + + var result = await V03ServerProcessor.ProcessRequestAsync(handler, request, CancellationToken.None); + + using var body = await ExecuteAndParseJson(result); + Assert.True(body.RootElement.TryGetProperty("error", out var errorEl), + "Expected error for nonexistent task"); + Assert.Equal(-32001, errorEl.GetProperty("code").GetInt32()); // TaskNotFound + } + + // ── v1.0 method routing through V03ServerProcessor ────────────────────── + + [Fact] + public async Task ProcessRequestAsync_V1MethodName_RoutesDirectlyToV1Processor() + { + // Tests commit 49d5f02: v1.0 method names must bypass the V03 deserializer + // (which rejects them) and route via HandleV1RequestAsync to the v1.0 processor. + // Without the fix this returns -32601 (method not found) or a deserialization error. + var handler = CreateRequestHandler(); + var request = CreateHttpRequest( + """{"jsonrpc":"2.0","method":"SendMessage","id":1,"params":{"message":{"messageId":"m1","role":"ROLE_USER","parts":[{"text":"hi"}]}}}"""); + + var result = await V03ServerProcessor.ProcessRequestAsync(handler, request, CancellationToken.None); + + using var body = await ExecuteAndParseJson(result); + var rawText = body.RootElement.GetRawText(); + Assert.False(body.RootElement.TryGetProperty("error", out _), + "v1.0 SendMessage through V03ServerProcessor must succeed"); + Assert.Contains("TASK_STATE_", rawText); + } + + [Fact] + public async Task ProcessRequestAsync_V1GetTaskMethod_RoutesDirectlyToV1Processor() + { + var handler = CreateRequestHandler(); + var request = CreateHttpRequest( + """{"jsonrpc":"2.0","method":"GetTask","id":1,"params":{"id":"nonexistent-task"}}"""); + + var result = await V03ServerProcessor.ProcessRequestAsync(handler, request, CancellationToken.None); + + using var body = await ExecuteAndParseJson(result); + // v1.0 GetTask for nonexistent task returns -32001 in v1.0 wire format + Assert.True(body.RootElement.TryGetProperty("error", out var errorEl), + "Expected error for nonexistent task via v1.0 GetTask"); + Assert.Equal(-32001, errorEl.GetProperty("code").GetInt32()); // TaskNotFound + } + + // ── Malformed requests ─────────────────────────────────────────────────── + + [Fact] + public async Task ProcessRequestAsync_InvalidJson_ReturnsParseError() + { + var handler = CreateRequestHandler(); + var request = CreateHttpRequest("this is not valid json"); + + var result = await V03ServerProcessor.ProcessRequestAsync(handler, request, CancellationToken.None); + + using var body = await ExecuteAndParseJson(result); + Assert.True(body.RootElement.TryGetProperty("error", out var errorEl)); + Assert.Equal(-32700, errorEl.GetProperty("code").GetInt32()); // ParseError + } + + [Fact] + public async Task ProcessRequestAsync_MissingMethodField_ReturnsParseError() + { + var handler = CreateRequestHandler(); + var request = CreateHttpRequest("""{"jsonrpc":"2.0","id":1,"params":{}}"""); + + var result = await V03ServerProcessor.ProcessRequestAsync(handler, request, CancellationToken.None); + + using var body = await ExecuteAndParseJson(result); + Assert.True(body.RootElement.TryGetProperty("error", out _), + "Request missing 'method' field must return an error"); + } + + [Fact] + public async Task ProcessRequestAsync_UnknownV03Method_ReturnsMethodNotFoundError() + { + var handler = CreateRequestHandler(); + var request = CreateHttpRequest( + """{"jsonrpc":"2.0","method":"tasks/unknown","id":1,"params":{}}"""); + + var result = await V03ServerProcessor.ProcessRequestAsync(handler, request, CancellationToken.None); + + using var body = await ExecuteAndParseJson(result); + Assert.True(body.RootElement.TryGetProperty("error", out var errorEl)); + Assert.Equal(-32601, errorEl.GetProperty("code").GetInt32()); // MethodNotFound + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private static A2AServer CreateRequestHandler() + { + var notifier = new ChannelEventNotifier(); + var store = new InMemoryTaskStore(); + var agentHandler = new MinimalAgentHandler(); + return new A2AServer(agentHandler, store, notifier, NullLogger.Instance); + } + + private static HttpRequest CreateHttpRequest(string json, string? version = null) + { + var context = new DefaultHttpContext(); + var bytes = Encoding.UTF8.GetBytes(json); + context.Request.Body = new MemoryStream(bytes); + context.Request.ContentType = "application/json"; + if (version is not null) + context.Request.Headers["A2A-Version"] = version; + return context.Request; + } + + private static async Task ExecuteAndParseJson(IResult result) + { + var context = new DefaultHttpContext(); + var ms = new MemoryStream(); + context.Response.Body = ms; + await result.ExecuteAsync(context); + ms.Position = 0; + return await JsonDocument.ParseAsync(ms); + } + + private sealed class MinimalAgentHandler : IAgentHandler + { + public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + var task = new AgentTask + { + Id = context.TaskId, + ContextId = context.ContextId, + Status = new TaskStatus { State = TaskState.Submitted }, + History = [context.Message], + }; + await eventQueue.EnqueueTaskAsync(task, cancellationToken); + eventQueue.Complete(); + } + + public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId); + await updater.CancelAsync(cancellationToken); + } + } +} From 11b45b8b21c370980af27d7f944ae3665cca2711 Mon Sep 17 00:00:00 2001 From: "Shanshan Xu (from Dev Box)" Date: Thu, 26 Mar 2026 23:32:35 +0800 Subject: [PATCH 06/10] fix: add A2A.V0_3Compat projects to solution so CI runs their tests A2A.V0_3Compat and A2A.V0_3Compat.UnitTests were missing from A2A.slnx, so dotnet test run by CI skipped all 41 compat unit tests. Co-Authored-By: Claude Sonnet 4.6 --- A2A.slnx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/A2A.slnx b/A2A.slnx index 07be1f60..0fb73752 100644 --- a/A2A.slnx +++ b/A2A.slnx @@ -20,6 +20,7 @@ + @@ -33,5 +34,6 @@ + From 7c54c044d1116eba1ed6607ee5322387b74e97bd Mon Sep 17 00:00:00 2001 From: "Shanshan Xu (from Dev Box)" Date: Mon, 30 Mar 2026 23:50:52 +0800 Subject: [PATCH 07/10] Adopt version-aware binding registry from darrelmiller/issue-331-version-negotiation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace RegisterFallback/ClearFallback with Register(binding, version, factory) + Unregister() - Add BindingKey(binding, version) composite key to ConcurrentDictionary - Add legacy card synthesis in Create(string, ...) — no fallback delegate needed - Add NormalizeMajorMinor() to handle "0.3.0" → "0.3" normalization - Update V03FallbackRegistration.Register() to use new API - Simplify CreateV03Client signature to (Uri, HttpClient?) - Remove A2AClientOptions.FallbackFactory - Update tests accordingly Co-Authored-By: Claude Sonnet 4.6 --- src/A2A.V0_3Compat/A2AClientFactory.cs | 35 +--- src/A2A/Client/A2AClientFactory.cs | 173 +++++++++++++----- src/A2A/Client/A2AClientOptions.cs | 11 -- .../A2AClientFactoryTests.cs | 74 ++++---- 4 files changed, 165 insertions(+), 128 deletions(-) diff --git a/src/A2A.V0_3Compat/A2AClientFactory.cs b/src/A2A.V0_3Compat/A2AClientFactory.cs index 095399e7..e40aa609 100644 --- a/src/A2A.V0_3Compat/A2AClientFactory.cs +++ b/src/A2A.V0_3Compat/A2AClientFactory.cs @@ -1,51 +1,32 @@ namespace A2A.V0_3Compat; -using System.Text.Json; - using V03 = A2A.V0_3; /// -/// Registers a v0.3 compatibility fallback with . +/// Registers v0.3 protocol support with . /// Once registered, the factory automatically creates a v0.3 adapter for agents -/// whose agent card does not contain supportedInterfaces. +/// whose agent card declares protocol version 0.3. /// public static class V03FallbackRegistration { /// - /// Registers the v0.3 fallback globally with . + /// Registers v0.3 JSON-RPC support with . /// Call this once at application startup. /// public static void Register() { - A2AClientFactory.RegisterFallback(CreateV03Client); + A2AClientFactory.Register(ProtocolBindingNames.JsonRpc, "0.3", CreateV03Client); } /// - /// Creates an from a v0.3-shaped agent card JSON string. - /// Can be used directly as a delegate - /// or via for global registration. + /// Creates an wrapping a v0.3 JSON-RPC client. /// - /// The raw JSON string of the agent card. - /// The base URL of the agent, used as fallback when the card does not specify a URL. + /// The agent's endpoint URL. /// Optional HTTP client to use for requests. /// An wrapping a v0.3 client. - public static IA2AClient CreateV03Client(string agentCardJson, Uri baseUrl, HttpClient? httpClient) + public static IA2AClient CreateV03Client(Uri url, HttpClient? httpClient) { - using var doc = JsonDocument.Parse(agentCardJson); - var root = doc.RootElement; - - string url; - if (root.TryGetProperty("url", out var urlProp) && - urlProp.ValueKind == JsonValueKind.String) - { - url = urlProp.GetString()!; - } - else - { - url = baseUrl.ToString(); - } - - var v03Client = new V03.A2AClient(new Uri(url), httpClient); + var v03Client = new V03.A2AClient(url, httpClient); return new V03ClientAdapter(v03Client); } } diff --git a/src/A2A/Client/A2AClientFactory.cs b/src/A2A/Client/A2AClientFactory.cs index c128b92e..1c4d0c10 100644 --- a/src/A2A/Client/A2AClientFactory.cs +++ b/src/A2A/Client/A2AClientFactory.cs @@ -7,65 +7,75 @@ namespace A2A; /// Factory for creating instances. /// Supports creating clients from a parsed , a raw agent card JSON string, /// or by fetching the agent card from a well-known URL. When given raw JSON, the factory detects -/// the protocol version and delegates to a registered fallback for non-v1.0 agent cards. +/// the protocol version and delegates to the appropriate registered binding. /// /// -/// The factory ships with built-in support for and -/// . Additional bindings (including -/// and custom bindings) can be registered via -/// . To support older protocol versions (e.g. v0.3), register a fallback -/// via or set . +/// The factory ships with built-in support for v1.0 and +/// . Additional bindings, protocol versions (e.g. v0.3), +/// and custom bindings can be registered via . /// public static class A2AClientFactory { - private static readonly ConcurrentDictionary> s_bindings = new(StringComparer.OrdinalIgnoreCase) + private static readonly ConcurrentDictionary> s_bindings = new() { - [ProtocolBindingNames.HttpJson] = (url, httpClient) => new A2AHttpJsonClient(url, httpClient), - [ProtocolBindingNames.JsonRpc] = (url, httpClient) => new A2AClient(url, httpClient), + [new(ProtocolBindingNames.HttpJson, "1.0")] = (url, httpClient) => new A2AHttpJsonClient(url, httpClient), + [new(ProtocolBindingNames.JsonRpc, "1.0")] = (url, httpClient) => new A2AClient(url, httpClient), }; - private static Func? s_fallbackFactory; - private static readonly HttpClient s_sharedClient = new(); /// - /// Registers a custom protocol binding so the factory can create clients for it. + /// Registers a client factory for a specific protocol binding and version. /// /// - /// The protocol binding name (e.g. "GRPC"). Matching is case-insensitive. + /// The protocol binding name (e.g. "JSONRPC", "HTTP+JSON", "GRPC"). + /// Matching is case-insensitive. + /// + /// + /// The protocol version (e.g. "1.0", "0.3"). Use major.minor format. /// /// /// A delegate that creates an given the interface URL and an optional . /// /// - /// Thrown when or is . + /// Thrown when , , + /// or is . /// - public static void Register(string protocolBinding, Func clientFactory) + public static void Register(string protocolBinding, string protocolVersion, Func clientFactory) { ArgumentNullException.ThrowIfNull(protocolBinding); + ArgumentNullException.ThrowIfNull(protocolVersion); ArgumentNullException.ThrowIfNull(clientFactory); - s_bindings[protocolBinding] = clientFactory; + s_bindings[new(protocolBinding, protocolVersion)] = clientFactory; } /// - /// Registers a global fallback factory for agent cards that do not declare - /// supportedInterfaces (e.g. v0.3 agents). This can be overridden per-call - /// via . + /// Registers a client factory for a specific protocol binding at version 1.0. /// - /// - /// A delegate that creates an from the raw agent card JSON, - /// the agent's base URL, and an optional . + /// + /// The protocol binding name (e.g. "GRPC"). Matching is case-insensitive. /// - public static void RegisterFallback(Func fallbackFactory) - { - ArgumentNullException.ThrowIfNull(fallbackFactory); - s_fallbackFactory = fallbackFactory; - } + /// + /// A delegate that creates an given the interface URL and an optional . + /// + /// + /// Thrown when or is . + /// + public static void Register(string protocolBinding, Func clientFactory) + => Register(protocolBinding, "1.0", clientFactory); /// - /// Clears any previously registered global fallback factory. + /// Removes a previously registered client factory for a specific protocol binding and version. /// - public static void ClearFallback() => s_fallbackFactory = null; + /// The protocol binding name. + /// The protocol version. + /// if the binding was removed; if it was not found. + public static bool Unregister(string protocolBinding, string protocolVersion) + { + ArgumentNullException.ThrowIfNull(protocolBinding); + ArgumentNullException.ThrowIfNull(protocolVersion); + return s_bindings.TryRemove(new(protocolBinding, protocolVersion), out _); + } /// /// Creates an from an by selecting the @@ -82,14 +92,21 @@ public static void RegisterFallback(Func f /// Thrown when no supported interface in the agent card matches the preferred bindings, /// or when a matched binding has no registered client factory. /// + /// + /// Selection follows spec Section 8.3: the agent's + /// order is respected (first entry is preferred), filtered to bindings listed in + /// . This means the agent's preference + /// wins when multiple bindings are mutually supported. + /// public static IA2AClient Create(AgentCard agentCard, HttpClient? httpClient = null, A2AClientOptions? options = null) { ArgumentNullException.ThrowIfNull(agentCard); options ??= new A2AClientOptions(); - var preferredSet = new HashSet(options.PreferredBindings, StringComparer.OrdinalIgnoreCase); + // Walk agent's interfaces in declared preference order (spec Section 8.3.1), + // selecting the first one the client also supports. foreach (var agentInterface in agentCard.SupportedInterfaces) { if (!preferredSet.Contains(agentInterface.ProtocolBinding)) @@ -98,19 +115,22 @@ public static IA2AClient Create(AgentCard agentCard, HttpClient? httpClient = nu } var url = new Uri(agentInterface.Url); + var version = NormalizeMajorMinor(agentInterface.ProtocolVersion); + var key = new BindingKey(agentInterface.ProtocolBinding, version); - if (s_bindings.TryGetValue(agentInterface.ProtocolBinding, out var factory)) + if (s_bindings.TryGetValue(key, out var factory)) { return factory(url, httpClient); } throw new A2AException( - $"Protocol binding '{agentInterface.ProtocolBinding}' matched an agent interface but has no registered client factory. Call A2AClientFactory.Register to add one.", + $"Protocol binding '{agentInterface.ProtocolBinding}' version '{version}' matched an agent interface but has no registered client factory. " + + $"Call A2AClientFactory.Register(\"{agentInterface.ProtocolBinding}\", \"{version}\", factory) to add one.", A2AErrorCode.InvalidRequest); } var available = agentCard.SupportedInterfaces.Count > 0 - ? string.Join(", ", agentCard.SupportedInterfaces.Select(i => i.ProtocolBinding)) + ? string.Join(", ", agentCard.SupportedInterfaces.Select(i => $"{i.ProtocolBinding}/{i.ProtocolVersion}")) : "none"; var requested = string.Join(", ", options.PreferredBindings); @@ -122,16 +142,17 @@ public static IA2AClient Create(AgentCard agentCard, HttpClient? httpClient = nu /// /// Creates an from a raw agent card JSON string. Detects the /// protocol version from the card structure: cards with supportedInterfaces are - /// treated as v1.0 and routed through ; - /// all other cards are passed to a registered fallback factory. + /// parsed and routed through ; + /// cards without supportedInterfaces (e.g. v0.3) are handled by synthesizing an + /// interface from the card's url and protocolVersion fields. /// /// The raw JSON string of the agent card. /// The base URL of the agent's hosting service, used as fallback when the card does not specify a URL. /// Optional HTTP client to use for requests. - /// Optional client options controlling binding preference and fallback behavior. + /// Optional client options controlling binding preference. /// An configured for the appropriate protocol version and binding. /// - /// Thrown when the card does not declare supportedInterfaces and no fallback factory is available. + /// Thrown when no matching binding+version is registered for the agent card. /// public static IA2AClient Create(string agentCardJson, Uri baseUrl, HttpClient? httpClient = null, A2AClientOptions? options = null) { @@ -149,16 +170,27 @@ public static IA2AClient Create(string agentCardJson, Uri baseUrl, HttpClient? h return Create(agentCard, httpClient, options); } - var fallback = options?.FallbackFactory ?? s_fallbackFactory; - if (fallback is not null) + // Legacy card without supportedInterfaces — synthesize an interface + // from the card's url, protocolVersion, and preferredTransport fields. + var agentUrl = (root.TryGetProperty("url", out var urlProp) && urlProp.ValueKind == JsonValueKind.String + ? urlProp.GetString() : null) ?? baseUrl.ToString(); + + var version = root.TryGetProperty("protocolVersion", out var verProp) && verProp.ValueKind == JsonValueKind.String + ? NormalizeMajorMinor(verProp.GetString() ?? "0.3") + : "0.3"; + + var binding = root.TryGetProperty("preferredTransport", out var transportProp) && transportProp.ValueKind == JsonValueKind.String + ? MapLegacyTransport(transportProp.GetString()) + : ProtocolBindingNames.JsonRpc; + + var legacyCard = new AgentCard { - return fallback(agentCardJson, baseUrl, httpClient); - } + Name = root.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? "" : "", + Description = root.TryGetProperty("description", out var descProp) ? descProp.GetString() ?? "" : "", + SupportedInterfaces = [new AgentInterface { Url = agentUrl, ProtocolBinding = binding, ProtocolVersion = version }], + }; - throw new A2AException( - "Agent card does not declare supportedInterfaces and no fallback factory is registered. " + - "To support older protocol versions, register a fallback with A2AClientFactory.RegisterFallback.", - A2AErrorCode.InvalidRequest); + return Create(legacyCard, httpClient, options ?? new A2AClientOptions { PreferredBindings = [binding] }); } /// @@ -166,7 +198,7 @@ public static IA2AClient Create(string agentCardJson, Uri baseUrl, HttpClient? h /// /// The base URL of the agent's hosting service. /// Optional HTTP client to use for requests. - /// Optional client options controlling the agent card path, binding preference, and fallback behavior. + /// Optional client options controlling the agent card path and binding preference. /// A cancellation token. /// An configured for the appropriate protocol version and binding. public static async Task CreateAsync( @@ -193,6 +225,39 @@ public static async Task CreateAsync( return Create(json, baseUrl, httpClient, options); } + /// + /// Normalizes a version string to major.minor format (e.g. "0.3.0" → "0.3", "1.0" → "1.0"). + /// + /// The version string to normalize, or . + private static string NormalizeMajorMinor(string? version) + { + if (string.IsNullOrEmpty(version)) + { + return string.Empty; + } + + var dotIndex = version.IndexOf('.'); + if (dotIndex < 0) + { + return version; + } + + var secondDot = version.IndexOf('.', dotIndex + 1); + return secondDot < 0 ? version : version.Substring(0, secondDot); + } + + /// + /// Maps legacy v0.3 preferredTransport values to protocol binding names. + /// + /// The legacy transport name to map. + private static string MapLegacyTransport(string? transport) => + transport?.ToUpperInvariant() switch + { + "JSONRPC" => ProtocolBindingNames.JsonRpc, + "SSE" => ProtocolBindingNames.JsonRpc, // v0.3 SSE used JSON-RPC underneath + _ => ProtocolBindingNames.JsonRpc, + }; + private static AgentCard BuildAgentCard(JsonElement root, JsonElement interfaces, Uri baseUrl) { var agentCard = new AgentCard @@ -206,7 +271,8 @@ private static AgentCard BuildAgentCard(JsonElement root, JsonElement interfaces { agentCard.SupportedInterfaces.Add(new AgentInterface { - Url = iface.TryGetProperty("url", out var urlProp) ? urlProp.GetString() ?? baseUrl.ToString() : baseUrl.ToString(), + Url = (iface.TryGetProperty("url", out var urlProp) && urlProp.ValueKind == JsonValueKind.String + ? urlProp.GetString() : null) ?? baseUrl.ToString(), ProtocolBinding = iface.TryGetProperty("protocolBinding", out var bindingProp) ? bindingProp.GetString() ?? "" : "", ProtocolVersion = iface.TryGetProperty("protocolVersion", out var verProp) ? verProp.GetString() ?? "" : "", }); @@ -214,4 +280,17 @@ private static AgentCard BuildAgentCard(JsonElement root, JsonElement interfaces return agentCard; } + + /// Composite key for binding+version lookup with case-insensitive binding matching. + /// The protocol binding name. + /// The protocol version. + private readonly record struct BindingKey(string Binding, string Version) : IEquatable + { + public bool Equals(BindingKey other) => + string.Equals(Binding, other.Binding, StringComparison.OrdinalIgnoreCase) && + string.Equals(Version, other.Version, StringComparison.Ordinal); + + public override int GetHashCode() => + HashCode.Combine(StringComparer.OrdinalIgnoreCase.GetHashCode(Binding), StringComparer.Ordinal.GetHashCode(Version)); + } } diff --git a/src/A2A/Client/A2AClientOptions.cs b/src/A2A/Client/A2AClientOptions.cs index 6d6ec61d..2a0bbf54 100644 --- a/src/A2A/Client/A2AClientOptions.cs +++ b/src/A2A/Client/A2AClientOptions.cs @@ -20,15 +20,4 @@ public sealed class A2AClientOptions /// Only used by . /// public string AgentCardPath { get; set; } = "/.well-known/agent-card.json"; - - /// - /// Gets or sets a per-call fallback factory for agent cards that do not declare - /// supportedInterfaces. When set, this takes priority over the global fallback - /// registered via . - /// - /// - /// The delegate receives the raw agent card JSON, the base URL, and an optional , - /// and should return an appropriate for the agent's protocol version. - /// - public Func? FallbackFactory { get; set; } } diff --git a/tests/A2A.V0_3Compat.UnitTests/A2AClientFactoryTests.cs b/tests/A2A.V0_3Compat.UnitTests/A2AClientFactoryTests.cs index f2797727..197cd15e 100644 --- a/tests/A2A.V0_3Compat.UnitTests/A2AClientFactoryTests.cs +++ b/tests/A2A.V0_3Compat.UnitTests/A2AClientFactoryTests.cs @@ -1,13 +1,7 @@ namespace A2A.V0_3Compat.UnitTests; -public class A2AClientFactoryTests : IDisposable +public class A2AClientFactoryTests { - public void Dispose() - { - A2AClientFactory.ClearFallback(); - GC.SuppressFinalize(this); - } - [Fact] public void Create_WithV10Card_ReturnsA2AClient() { @@ -30,7 +24,7 @@ public void Create_WithV10Card_ReturnsA2AClient() } [Fact] - public void Create_WithV03Card_AndGlobalFallback_ReturnsV03Adapter() + public void Create_WithV03Card_AndRegisteredBinding_ReturnsV03Adapter() { V03FallbackRegistration.Register(); @@ -56,37 +50,11 @@ public void Create_WithV03Card_AndGlobalFallback_ReturnsV03Adapter() } [Fact] - public void Create_WithV03Card_AndPerCallFallback_ReturnsV03Adapter() + public void Create_WithV03Card_NoRegisteredBinding_Throws() { - var cardJson = """ - { - "name": "Legacy Agent", - "description": "A legacy agent", - "version": "1.0", - "url": "http://localhost/a2a", - "protocolVersion": "0.3.0", - "capabilities": { "streaming": true }, - "skills": [], - "defaultInputModes": ["text"], - "defaultOutputModes": ["text"], - "preferredTransport": "jsonrpc" - } - """; - - var options = new A2AClientOptions - { - FallbackFactory = V03FallbackRegistration.CreateV03Client, - }; + // Ensure no v0.3 binding is registered + A2AClientFactory.Unregister("JSONRPC", "0.3"); - var client = A2AClientFactory.Create(cardJson, new Uri("http://localhost"), options: options); - - Assert.IsNotType(client); - Assert.IsAssignableFrom(client); - } - - [Fact] - public void Create_WithV03Card_NoFallback_Throws() - { var cardJson = """ { "name": "Legacy Agent", @@ -126,8 +94,10 @@ public void Create_WithV10Card_UsesInterfaceUrl() } [Fact] - public void Create_WithEmptySupportedInterfaces_UsesPerCallFallback() + public void Create_WithEmptySupportedInterfaces_AndRegisteredV03_ReturnsAdapter() { + V03FallbackRegistration.Register(); + var cardJson = """ { "name": "Ambiguous Agent", @@ -135,19 +105,37 @@ public void Create_WithEmptySupportedInterfaces_UsesPerCallFallback() "version": "1.0", "supportedInterfaces": [], "url": "http://localhost/a2a", + "protocolVersion": "0.3", "capabilities": {}, "skills": [] } """; - var options = new A2AClientOptions + var client = A2AClientFactory.Create(cardJson, new Uri("http://localhost")); + + Assert.IsNotType(client); + Assert.IsAssignableFrom(client); + } + + [Fact] + public void Create_V03Card_NormalizesVersionWithPatch() + { + V03FallbackRegistration.Register(); + + // protocolVersion "0.3.0" should normalize to "0.3" and match + var cardJson = """ { - FallbackFactory = V03FallbackRegistration.CreateV03Client, - }; + "name": "Legacy Agent", + "description": "A legacy agent", + "url": "http://localhost/a2a", + "protocolVersion": "0.3.0", + "capabilities": {}, + "skills": [] + } + """; - var client = A2AClientFactory.Create(cardJson, new Uri("http://localhost"), options: options); + var client = A2AClientFactory.Create(cardJson, new Uri("http://localhost")); - Assert.IsNotType(client); Assert.IsAssignableFrom(client); } } From 74e50c44ff473a26965fbd7d3aa151079715a788 Mon Sep 17 00:00:00 2001 From: "Shanshan Xu (from Dev Box)" Date: Tue, 31 Mar 2026 00:06:29 +0800 Subject: [PATCH 08/10] fix: update V03TypeConverter to use ReturnImmediately (renamed from Blocking in #339) Co-Authored-By: Claude Sonnet 4.6 --- src/A2A.V0_3Compat/V03TypeConverter.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/A2A.V0_3Compat/V03TypeConverter.cs b/src/A2A.V0_3Compat/V03TypeConverter.cs index e3e91f9f..88b4bfc2 100644 --- a/src/A2A.V0_3Compat/V03TypeConverter.cs +++ b/src/A2A.V0_3Compat/V03TypeConverter.cs @@ -78,7 +78,7 @@ internal static V03.MessageSendParams ToV03(A2A.SendMessageRequest request) { AcceptedOutputModes = config.AcceptedOutputModes ?? [], HistoryLength = config.HistoryLength, - Blocking = config.Blocking, + Blocking = config.ReturnImmediately, }; if (config.PushNotificationConfig is { } pushConfig) @@ -382,7 +382,7 @@ internal static A2A.SendMessageRequest ToV1SendMessageRequest(V03.MessageSendPar { AcceptedOutputModes = cfg.AcceptedOutputModes, HistoryLength = cfg.HistoryLength, - Blocking = cfg.Blocking, + ReturnImmediately = cfg.Blocking, PushNotificationConfig = cfg.PushNotification is { } pn ? ToV1PushNotificationConfig(pn) : null, From 447c0a6dc58da6042f1eedfd09ca7196a65609f2 Mon Sep 17 00:00:00 2001 From: "Shanshan Xu (from Dev Box)" Date: Thu, 2 Apr 2026 12:13:20 +0800 Subject: [PATCH 09/10] fix(V03ServerProcessor): route by A2A-Version header, absent header = v0.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per spec, v1.0 clients MUST send A2A-Version; absent header indicates a v0.3 client. Route requests by header value instead of body sniffing: - No header or A2A-Version: 0.3 → v0.3 processing path - A2A-Version: 1.0 (or any non-0.3) → v1.0 processing path Addresses review feedback from @darrelmiller. Co-Authored-By: Claude Sonnet 4.6 --- src/A2A.V0_3Compat/V03ServerProcessor.cs | 64 ++++++++++++------- .../V03ServerProcessorTests.cs | 10 +-- 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/src/A2A.V0_3Compat/V03ServerProcessor.cs b/src/A2A.V0_3Compat/V03ServerProcessor.cs index 371e3e11..df720f5d 100644 --- a/src/A2A.V0_3Compat/V03ServerProcessor.cs +++ b/src/A2A.V0_3Compat/V03ServerProcessor.cs @@ -34,12 +34,44 @@ public static async Task ProcessRequestAsync( var preflightResult = A2AJsonRpcProcessor.CheckPreflight(request); if (preflightResult != null) return preflightResult; - // Parse body once to peek at "method" and route v0.3 vs v1.0 before full validation. - // V03.JsonRpcRequestConverter rejects v1.0 method names, so we must detect them early. - JsonDocument doc; + // Route by A2A-Version header: per spec, v1.0 clients MUST send this header; + // absent header indicates a v0.3 client. + var version = request.Headers["A2A-Version"].FirstOrDefault(); + if (!string.IsNullOrEmpty(version) && version != "0.3") + { + // v1.0 request: parse body and delegate directly to v1.0 processor. + JsonDocument doc; + try + { + doc = await JsonDocument.ParseAsync(request.Body, cancellationToken: cancellationToken) + .ConfigureAwait(false); + } + catch (JsonException) + { + return MakeErrorResult(default, V03.JsonRpcResponse.ParseErrorResponse); + } + + using (doc) + { + var root = doc.RootElement; + if (!root.TryGetProperty("method", out var methodProp) || + methodProp.ValueKind != JsonValueKind.String) + { + return MakeErrorResult(default, V03.JsonRpcResponse.ParseErrorResponse); + } + + var method = methodProp.GetString() ?? string.Empty; + return await HandleV1RequestAsync(requestHandler, root, method, cancellationToken) + .ConfigureAwait(false); + } + } + + // v0.3 request: peek at method name first — V03.JsonRpcRequestConverter may throw + // for method names it doesn't recognise, so validate before attempting full deserialization. + JsonDocument v03Doc; try { - doc = await JsonDocument.ParseAsync(request.Body, cancellationToken: cancellationToken) + v03Doc = await JsonDocument.ParseAsync(request.Body, cancellationToken: cancellationToken) .ConfigureAwait(false); } catch (JsonException) @@ -47,10 +79,9 @@ public static async Task ProcessRequestAsync( return MakeErrorResult(default, V03.JsonRpcResponse.ParseErrorResponse); } - using (doc) + using (v03Doc) { - var root = doc.RootElement; - + var root = v03Doc.RootElement; if (!root.TryGetProperty("method", out var methodProp) || methodProp.ValueKind != JsonValueKind.String) { @@ -58,15 +89,11 @@ public static async Task ProcessRequestAsync( } var method = methodProp.GetString() ?? string.Empty; - - // v1.0 method names: bypass V03 validator, delegate directly to v1.0 processor. if (!V03.A2AMethods.IsValidMethod(method)) { - return await HandleV1RequestAsync(requestHandler, root, method, cancellationToken) - .ConfigureAwait(false); + return MakeErrorResult(default, V03.JsonRpcResponse.MethodNotFoundResponse); } - // v0.3 method names: deserialize with full V03 validation. V03.JsonRpcRequest? rpcRequest = null; try { @@ -216,16 +243,9 @@ private static async Task HandleSingleAsync( } default: - { - // Unrecognized v0.3 method — delegate to v1.0 processor. - // This handles v1.0 clients sending v1.0 method names (e.g. "SendMessage"). - var v1Id = ToV1Id(rpcRequest.Id); - if (A2AMethods.IsStreamingMethod(rpcRequest.Method)) - { - return A2AJsonRpcProcessor.StreamResponse(handler, v1Id, rpcRequest.Method, rpcRequest.Params, ct); - } - return await A2AJsonRpcProcessor.SingleResponseAsync(handler, v1Id, rpcRequest.Method, rpcRequest.Params, ct).ConfigureAwait(false); - } + // Unrecognized v0.3 method name. v1.0 clients are routed via the + // A2A-Version header before reaching here, so this is a genuine v0.3 unknown method. + return MakeErrorResult(rpcRequest.Id, V03.JsonRpcResponse.MethodNotFoundResponse); } } diff --git a/tests/A2A.V0_3Compat.UnitTests/V03ServerProcessorTests.cs b/tests/A2A.V0_3Compat.UnitTests/V03ServerProcessorTests.cs index e3500e7b..e48e3f41 100644 --- a/tests/A2A.V0_3Compat.UnitTests/V03ServerProcessorTests.cs +++ b/tests/A2A.V0_3Compat.UnitTests/V03ServerProcessorTests.cs @@ -121,12 +121,11 @@ public async Task ProcessRequestAsync_V03TasksCancel_NonExistentTask_ReturnsTask [Fact] public async Task ProcessRequestAsync_V1MethodName_RoutesDirectlyToV1Processor() { - // Tests commit 49d5f02: v1.0 method names must bypass the V03 deserializer - // (which rejects them) and route via HandleV1RequestAsync to the v1.0 processor. - // Without the fix this returns -32601 (method not found) or a deserialization error. + // v1.0 clients send A2A-Version: 1.0; the processor routes to HandleV1RequestAsync. var handler = CreateRequestHandler(); var request = CreateHttpRequest( - """{"jsonrpc":"2.0","method":"SendMessage","id":1,"params":{"message":{"messageId":"m1","role":"ROLE_USER","parts":[{"text":"hi"}]}}}"""); + """{"jsonrpc":"2.0","method":"SendMessage","id":1,"params":{"message":{"messageId":"m1","role":"ROLE_USER","parts":[{"text":"hi"}]}}}""", + version: "1.0"); var result = await V03ServerProcessor.ProcessRequestAsync(handler, request, CancellationToken.None); @@ -142,7 +141,8 @@ public async Task ProcessRequestAsync_V1GetTaskMethod_RoutesDirectlyToV1Processo { var handler = CreateRequestHandler(); var request = CreateHttpRequest( - """{"jsonrpc":"2.0","method":"GetTask","id":1,"params":{"id":"nonexistent-task"}}"""); + """{"jsonrpc":"2.0","method":"GetTask","id":1,"params":{"id":"nonexistent-task"}}""", + version: "1.0"); var result = await V03ServerProcessor.ProcessRequestAsync(handler, request, CancellationToken.None); From 0af325d9174002dab101119440e95cbffc349997 Mon Sep 17 00:00:00 2001 From: "Shanshan Xu (from Dev Box)" Date: Thu, 2 Apr 2026 12:13:29 +0800 Subject: [PATCH 10/10] feat(MapAgentCardGetWithV03Compat): add blendedCard option, fix explicit v0.3 routing - Add blendedCard parameter (default true): when absent header, return a card with both v0.3 fields and v1.0 supportedInterfaces side-by-side, so v1.0 clients can read the card even without sending the header. Set to false for strict v0.3 clients whose deserializers reject unknown fields. - Fix: explicit A2A-Version: 0.3 now always returns strict v0.3 card, regardless of blendedCard setting. blendedCard only applies when no header is present (client version unknown). Addresses review feedback from @darrelmiller. Co-Authored-By: Claude Sonnet 4.6 --- .../V03ServerCompatEndpointExtensions.cs | 38 ++++++++++++++----- src/A2A.V0_3Compat/V03TypeConverter.cs | 25 ++++++++++++ 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/A2A.V0_3Compat/V03ServerCompatEndpointExtensions.cs b/src/A2A.V0_3Compat/V03ServerCompatEndpointExtensions.cs index 7f03e769..9f2e77d8 100644 --- a/src/A2A.V0_3Compat/V03ServerCompatEndpointExtensions.cs +++ b/src/A2A.V0_3Compat/V03ServerCompatEndpointExtensions.cs @@ -50,7 +50,8 @@ public static IEndpointConventionBuilder MapA2AWithV03Compat( /// Format negotiation is based on the A2A-Version request header: /// /// - /// GET {path}/: returns v1.0 by default; returns v0.3 only when A2A-Version: 0.3 is present. + /// GET {path}/: returns v0.3 by default (absent header indicates a v0.3 client per spec); + /// returns v1.0 when A2A-Version: 1.0 is present. /// /// /// GET {path}/.well-known/agent-card.json: returns v0.3 by default (backward compatibility for @@ -65,38 +66,57 @@ public static IEndpointConventionBuilder MapA2AWithV03Compat( /// The endpoint route builder. /// A factory that returns the v1.0 agent card to serve or convert. /// The route prefix for the agent card endpoints. + /// + /// When true (default), the v0.3 response includes both v0.3 fields and the v1.0 + /// supportedInterfaces property side-by-side. This allows v1.0 clients to read the card + /// even when no A2A-Version header is sent. Set to false to return a strict v0.3 + /// card with no v1.0 properties, for clients whose deserializers reject unknown fields. + /// /// An endpoint convention builder for further configuration. [RequiresDynamicCode("MapAgentCardGetWithV03Compat uses runtime reflection for route binding. For AOT-compatible usage, use a source-generated host.")] [RequiresUnreferencedCode("MapAgentCardGetWithV03Compat may perform reflection on types that are not preserved by trimming.")] public static IEndpointConventionBuilder MapAgentCardGetWithV03Compat( this IEndpointRouteBuilder endpoints, Func> getAgentCardAsync, - [StringSyntax("Route")] string path = "") + [StringSyntax("Route")] string path = "", + bool blendedCard = true) { ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(getAgentCardAsync); var routeGroup = endpoints.MapGroup(path); - // v1.0 clients use GET / — negotiate format via A2A-Version header + // Negotiate format via A2A-Version header. + // Per spec, v1.0 clients MUST send A2A-Version; absent header indicates a v0.3 client. + // Explicit A2A-Version: 0.3 always returns strict v0.3; blendedCard only applies when + // no header is present (client version unknown). routeGroup.MapGet(string.Empty, async (HttpRequest request) => { var v1Card = await getAgentCardAsync(); var version = request.Headers["A2A-Version"].FirstOrDefault(); - return version == "0.3" - ? Results.Ok(V03TypeConverter.ToV03AgentCard(v1Card)) - : Results.Ok(v1Card); + if (version == "1.0") + return Results.Ok(v1Card); + if (version == "0.3") + return Results.Ok(V03TypeConverter.ToV03AgentCard(v1Card)); + return blendedCard + ? Results.Json(V03TypeConverter.ToBlendedAgentCard(v1Card)) + : Results.Ok(V03TypeConverter.ToV03AgentCard(v1Card)); }); // Both v0.3 and v1.0 clients use GET .well-known/agent-card.json. // v1.0 clients (A2AClientFactory.CreateAsync) send A2A-Version: 1.0; return v1.0 format. - // v0.3 clients send no header; default to v0.3 format for backward compatibility. + // Explicit A2A-Version: 0.3 returns strict v0.3; absent header defaults to blended or strict + // depending on blendedCard. routeGroup.MapGet(".well-known/agent-card.json", async (HttpRequest request, CancellationToken ct) => { var v1Card = await getAgentCardAsync(); var version = request.Headers["A2A-Version"].FirstOrDefault(); - return version == "1.0" - ? Results.Ok(v1Card) + if (version == "1.0") + return Results.Ok(v1Card); + if (version == "0.3") + return Results.Ok(V03TypeConverter.ToV03AgentCard(v1Card)); + return blendedCard + ? Results.Json(V03TypeConverter.ToBlendedAgentCard(v1Card)) : Results.Ok(V03TypeConverter.ToV03AgentCard(v1Card)); }); diff --git a/src/A2A.V0_3Compat/V03TypeConverter.cs b/src/A2A.V0_3Compat/V03TypeConverter.cs index 88b4bfc2..38a319bf 100644 --- a/src/A2A.V0_3Compat/V03TypeConverter.cs +++ b/src/A2A.V0_3Compat/V03TypeConverter.cs @@ -1,6 +1,8 @@ namespace A2A.V0_3Compat; using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; using V03 = A2A.V0_3; @@ -48,6 +50,29 @@ internal static V03.AgentCard ToV03AgentCard(A2A.AgentCard v1Card) }; } + /// + /// Returns a blended AgentCard containing both v1.0 properties and v0.3 backward-compat fields. + /// v0.3 clients read the familiar fields and ignore unknown ones; v1.0 clients use supportedInterfaces. + /// Use instead if the v0.3 client's deserializer is strict. + /// + /// The v1.0 agent card to convert. + internal static JsonObject ToBlendedAgentCard(A2A.AgentCard v1Card) + { + // Serialize v0.3 card as base so all v0.3 backward-compat fields are present. + var v03Card = ToV03AgentCard(v1Card); + var v03TypeInfo = (JsonTypeInfo)V03.A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(V03.AgentCard)); + var blended = JsonSerializer.SerializeToNode(v03Card, v03TypeInfo)!.AsObject(); + + // Extract supportedInterfaces from the v1.0 card and add to the blended response + // so v1.0 clients can also read the card. + var v1TypeInfo = (JsonTypeInfo)A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AgentCard)); + var v1Json = JsonSerializer.SerializeToNode(v1Card, v1TypeInfo)!.AsObject(); + if (v1Json.TryGetPropertyValue("supportedInterfaces", out var interfaces)) + blended["supportedInterfaces"] = interfaces?.DeepClone(); + + return blended; + } + private static V03.AgentSkill ToV03AgentSkill(A2A.AgentSkill skill) => new() { Id = skill.Id,