diff --git a/A2A.slnx b/A2A.slnx index 07be1f6..0fb7375 100644 --- a/A2A.slnx +++ b/A2A.slnx @@ -20,6 +20,7 @@ + @@ -33,5 +34,6 @@ + diff --git a/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs b/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs index 44f6588..8781583 100644 --- a/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs +++ b/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs @@ -10,9 +10,16 @@ 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. + /// + /// The incoming HTTP request. + 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 +29,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); @@ -55,7 +69,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 +209,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/A2A.V0_3Compat.csproj b/src/A2A.V0_3Compat/A2A.V0_3Compat.csproj new file mode 100644 index 0000000..e2064a3 --- /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 0000000..095399e --- /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 0000000..09445e8 --- /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 0000000..6e33c62 --- /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 0000000..19c4993 --- /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 0000000..7f03e76 --- /dev/null +++ b/src/A2A.V0_3Compat/V03ServerCompatEndpointExtensions.cs @@ -0,0 +1,105 @@ +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; + } + + /// + /// 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 new file mode 100644 index 0000000..371e3e1 --- /dev/null +++ b/src/A2A.V0_3Compat/V03ServerProcessor.cs @@ -0,0 +1,298 @@ +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; + +/// +/// 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); + + // 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; + 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; + + // 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); + } + } + } + + // 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 + { + 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); + + JsonElement? paramsEl = null; + if (root.TryGetProperty("params", out var p) && p.ValueKind == JsonValueKind.Object) + { + paramsEl = p.Clone(); + } + + try + { + if (A2AMethods.IsStreamingMethod(method)) + { + return A2AJsonRpcProcessor.StreamResponse(handler, id, method, paramsEl, ct); + } + return await A2AJsonRpcProcessor.SingleResponseAsync(handler, id, method, paramsEl, ct) + .ConfigureAwait(false); + } + catch (A2AException ex) + { + return new JsonRpcResponseResult(JsonRpcResponse.CreateJsonRpcErrorResponse(id, ex)); + } + catch (Exception) + { + return new JsonRpcResponseResult(JsonRpcResponse.InternalErrorResponse(id, "An internal error occurred.")); + } + } + + 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: + { + // 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); + } + } + } + + 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))); + + 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 new file mode 100644 index 0000000..e3e91f9 --- /dev/null +++ b/src/A2A.V0_3Compat/V03TypeConverter.cs @@ -0,0 +1,548 @@ +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 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. + /// 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 9bae32c..c128b92 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 b3d88ee..6d6ec61 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/src/Directory.Build.props b/src/Directory.Build.props index 03e810b..266c044 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ - 1.0.0-preview2 + 1.0.0-preview4 Community Contributors Apache-2.0 https://github.com/a2aproject/a2a-dotnet 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 0000000..7750bed --- /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 0000000..f279772 --- /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 0000000..8f736a0 --- /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 0000000..530858a --- /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/V03ServerProcessorTests.cs b/tests/A2A.V0_3Compat.UnitTests/V03ServerProcessorTests.cs new file mode 100644 index 0000000..e3500e7 --- /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); + } + } +} diff --git a/tests/A2A.V0_3Compat.UnitTests/V03TypeConverterTests.cs b/tests/A2A.V0_3Compat.UnitTests/V03TypeConverterTests.cs new file mode 100644 index 0000000..a14ac0e --- /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); + } +}