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);
+ }
+}