From 6173e63f0b2fd03507969f5d0ae5346e90a3524d Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Wed, 15 Apr 2026 11:08:05 +0100
Subject: [PATCH 01/12] update a2a agent to the latest a2a sdk (#5257)
---
dotnet/Directory.Packages.props | 22 +-
.../A2AAgent_PollingForTaskCompletion.csproj | 3 +-
.../A2AClientServer/A2AClient/Program.cs | 10 +-
.../A2AServer/HostAgentFactory.cs | 35 +-
.../A2AClientServer/A2AServer/Program.cs | 22 +-
.../AgentWebChat.Web/A2AAgentClient.cs | 12 +-
.../src/Microsoft.Agents.AI.A2A/A2AAgent.cs | 209 +++---
.../Extensions/A2AAgentCardExtensions.cs | 7 +-
.../Extensions/ChatMessageExtensions.cs | 6 +-
.../Microsoft.Agents.AI.A2A.csproj | 1 +
.../A2AAgentTests.cs | 627 +++++++++++++-----
.../Extensions/A2AAIContentExtensionsTests.cs | 24 +-
.../Extensions/A2AAgentCardExtensionsTests.cs | 55 +-
.../Extensions/A2AAgentTaskExtensionsTests.cs | 20 +-
.../Extensions/A2AArtifactExtensionsTests.cs | 12 +-
.../A2ACardResolverExtensionsTests.cs | 21 +-
.../Extensions/ChatMessageExtensionsTests.cs | 30 +-
.../Microsoft.Agents.AI.A2A.UnitTests.csproj | 4 +
18 files changed, 729 insertions(+), 391 deletions(-)
diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 0270f0e38b..1d3da75c86 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -32,18 +32,18 @@
-
+
-
+
-
-
+
+
-
-
+
+
@@ -64,7 +64,7 @@
-
+
@@ -77,11 +77,11 @@
-
+
-
+
@@ -102,8 +102,8 @@
-
-
+
+
diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj b/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj
index 1bccc99d4f..d91b20e34b 100644
--- a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj
+++ b/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj
@@ -2,7 +2,7 @@
Exe
- net10.0
+ net10.0
enable
enable
@@ -13,7 +13,6 @@
-
diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/Program.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/Program.cs
index 3624acd981..2175e13e71 100644
--- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/Program.cs
+++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/Program.cs
@@ -62,12 +62,10 @@ private static async Task HandleCommandsAsync(CancellationToken cancellationToke
}
var agentResponse = await hostAgent.Agent!.RunAsync(message, session, cancellationToken: cancellationToken);
- foreach (var chatMessage in agentResponse.Messages)
- {
- Console.ForegroundColor = ConsoleColor.Cyan;
- Console.WriteLine($"\nAgent: {chatMessage.Text}");
- Console.ResetColor();
- }
+
+ Console.ForegroundColor = ConsoleColor.Cyan;
+ Console.WriteLine($"\nAgent: {agentResponse.Text}");
+ Console.ResetColor();
}
}
catch (Exception ex)
diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs
index d5f1c9a88d..28e0c5fe5e 100644
--- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs
+++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs
@@ -13,7 +13,7 @@ namespace A2AServer;
internal static class HostAgentFactory
{
- internal static async Task<(AIAgent, AgentCard)> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string agentName, IList? tools = null)
+ internal static async Task<(AIAgent, AgentCard)> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string agentName, string[] agentUrls, IList? tools = null)
{
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
@@ -25,16 +25,16 @@ internal static class HostAgentFactory
AgentCard agentCard = agentType.ToUpperInvariant() switch
{
- "INVOICE" => GetInvoiceAgentCard(),
- "POLICY" => GetPolicyAgentCard(),
- "LOGISTICS" => GetLogisticsAgentCard(),
+ "INVOICE" => GetInvoiceAgentCard(agentUrls),
+ "POLICY" => GetPolicyAgentCard(agentUrls),
+ "LOGISTICS" => GetLogisticsAgentCard(agentUrls),
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
};
return new(agent, agentCard);
}
- internal static async Task<(AIAgent, AgentCard)> CreateChatCompletionHostAgentAsync(string agentType, string model, string apiKey, string name, string instructions, IList? tools = null)
+ internal static async Task<(AIAgent, AgentCard)> CreateChatCompletionHostAgentAsync(string agentType, string model, string apiKey, string name, string instructions, string[] agentUrls, IList? tools = null)
{
AIAgent agent = new OpenAIClient(apiKey)
.GetChatClient(model)
@@ -42,9 +42,9 @@ internal static class HostAgentFactory
AgentCard agentCard = agentType.ToUpperInvariant() switch
{
- "INVOICE" => GetInvoiceAgentCard(),
- "POLICY" => GetPolicyAgentCard(),
- "LOGISTICS" => GetLogisticsAgentCard(),
+ "INVOICE" => GetInvoiceAgentCard(agentUrls),
+ "POLICY" => GetPolicyAgentCard(agentUrls),
+ "LOGISTICS" => GetLogisticsAgentCard(agentUrls),
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
};
@@ -52,7 +52,7 @@ internal static class HostAgentFactory
}
#region private
- private static AgentCard GetInvoiceAgentCard()
+ private static AgentCard GetInvoiceAgentCard(string[] agentUrls)
{
var capabilities = new AgentCapabilities()
{
@@ -81,10 +81,11 @@ private static AgentCard GetInvoiceAgentCard()
DefaultOutputModes = ["text"],
Capabilities = capabilities,
Skills = [invoiceQuery],
+ SupportedInterfaces = CreateAgentInterfaces(agentUrls)
};
}
- private static AgentCard GetPolicyAgentCard()
+ private static AgentCard GetPolicyAgentCard(string[] agentUrls)
{
var capabilities = new AgentCapabilities()
{
@@ -113,10 +114,11 @@ private static AgentCard GetPolicyAgentCard()
DefaultOutputModes = ["text"],
Capabilities = capabilities,
Skills = [policyQuery],
+ SupportedInterfaces = CreateAgentInterfaces(agentUrls)
};
}
- private static AgentCard GetLogisticsAgentCard()
+ private static AgentCard GetLogisticsAgentCard(string[] agentUrls)
{
var capabilities = new AgentCapabilities()
{
@@ -145,7 +147,18 @@ private static AgentCard GetLogisticsAgentCard()
DefaultOutputModes = ["text"],
Capabilities = capabilities,
Skills = [logisticsQuery],
+ SupportedInterfaces = CreateAgentInterfaces(agentUrls)
};
}
+
+ private static List CreateAgentInterfaces(string[] agentUrls)
+ {
+ return agentUrls.Select(url => new AgentInterface
+ {
+ Url = url,
+ ProtocolBinding = "JSONRPC",
+ ProtocolVersion = "1.0",
+ }).ToList();
+ }
#endregion
}
diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs
index 8dcb3d1a34..773d57e9fc 100644
--- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs
+++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs
@@ -38,14 +38,15 @@
string? apiKey = configuration["OPENAI_API_KEY"];
string model = configuration["OPENAI_CHAT_MODEL_NAME"] ?? "gpt-5.4-mini";
string? endpoint = configuration["AZURE_AI_PROJECT_ENDPOINT"];
+string[] agentUrls = (app.Configuration["urls"] ?? "http://localhost:5000").Split(';');
var invoiceQueryPlugin = new InvoiceQuery();
IList tools =
- [
+[
AIFunctionFactory.Create(invoiceQueryPlugin.QueryInvoices),
AIFunctionFactory.Create(invoiceQueryPlugin.QueryByTransactionId),
AIFunctionFactory.Create(invoiceQueryPlugin.QueryByInvoiceId)
- ];
+];
AIAgent hostA2AAgent;
AgentCard hostA2AAgentCard;
@@ -54,9 +55,9 @@
{
(hostA2AAgent, hostA2AAgentCard) = agentType.ToUpperInvariant() switch
{
- "INVOICE" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, tools),
- "POLICY" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName),
- "LOGISTICS" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName),
+ "INVOICE" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls, tools),
+ "POLICY" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls),
+ "LOGISTICS" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls),
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
};
}
@@ -68,7 +69,7 @@
agentType, model, apiKey, "InvoiceAgent",
"""
You specialize in handling queries related to invoices.
- """, tools),
+ """, agentUrls, tools),
"POLICY" => await HostAgentFactory.CreateChatCompletionHostAgentAsync(
agentType, model, apiKey, "PolicyAgent",
"""
@@ -84,7 +85,7 @@ You specialize in handling queries related to policies and customer communicatio
resolution in SAP CRM and notify the customer via email within 2 business days, referencing the
original invoice and the credit memo number. Use the 'Formal Credit Notification' email
template."
- """),
+ """, agentUrls),
"LOGISTICS" => await HostAgentFactory.CreateChatCompletionHostAgentAsync(
agentType, model, apiKey, "LogisticsAgent",
"""
@@ -95,7 +96,7 @@ You specialize in handling queries related to logistics.
Shipment number: SHPMT-SAP-001
Item: TSHIRT-RED-L
Quantity: 900
- """),
+ """, agentUrls),
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
};
}
@@ -104,10 +105,9 @@ You specialize in handling queries related to logistics.
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
}
-var a2aTaskManager = app.MapA2A(
+app.MapA2A(
hostA2AAgent,
path: "/",
- agentCard: hostA2AAgentCard,
- taskManager => app.MapWellKnownAgentCard(taskManager, "/"));
+ agentCard: hostA2AAgentCard);
await app.RunAsync();
diff --git a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs
index f790ec0daa..d2c67d0ca5 100644
--- a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs
+++ b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs
@@ -43,20 +43,21 @@ public override async IAsyncEnumerable RunStreamingAsync(
{
// Convert all messages to A2A parts and create a single message
var parts = messages.ToParts();
- var a2aMessage = new AgentMessage
+ var a2aMessage = new Message
{
MessageId = Guid.NewGuid().ToString("N"),
ContextId = contextId,
- Role = MessageRole.User,
+ Role = Role.User,
Parts = parts
};
- var messageSendParams = new MessageSendParams { Message = a2aMessage };
+ var messageSendParams = new SendMessageRequest { Message = a2aMessage };
var a2aResponse = await a2aClient.SendMessageAsync(messageSendParams, cancellationToken);
// Handle different response types
- if (a2aResponse is AgentMessage message)
+ if (a2aResponse.PayloadCase == SendMessageResponseCase.Message)
{
+ var message = a2aResponse.Message!;
var responseMessage = message.ToChatMessage();
if (responseMessage is { Contents.Count: > 0 })
{
@@ -67,9 +68,10 @@ public override async IAsyncEnumerable RunStreamingAsync(
});
}
}
- else if (a2aResponse is AgentTask agentTask)
+ else if (a2aResponse.PayloadCase == SendMessageResponseCase.Task)
{
// Manually convert AgentTask artifacts to ChatMessages since the extension method is internal
+ var agentTask = a2aResponse.Task!;
if (agentTask.Artifacts is not null)
{
foreach (var artifact in agentTask.Artifacts)
diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs
index 9d98857e9b..1e3ce3a273 100644
--- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs
@@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
-using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
@@ -100,64 +99,47 @@ protected override async Task RunCoreAsync(IEnumerable 0 } taskMessages)
- {
- response.Messages = taskMessages;
- }
+ UpdateSession(typedSession, agentTask.ContextId, agentTask.Id);
- return response;
+ return this.ConvertToAgentResponse(agentTask);
}
- throw new NotSupportedException($"Only Message and AgentTask responses are supported from A2A agents. Received: {a2aResponse.GetType().FullName ?? "null"}");
+ throw new NotSupportedException($"Only Message and AgentTask responses are supported from A2A agents. Received: {a2aResponse.PayloadCase}");
}
///
@@ -169,59 +151,61 @@ protected override async IAsyncEnumerable RunCoreStreamingA
this._logger.LogA2AAgentInvokingAgent(nameof(RunStreamingAsync), this.Id, this.Name);
- ConfiguredCancelableAsyncEnumerable> a2aSseEvents;
+ ConfiguredCancelableAsyncEnumerable streamEvents;
- if (options?.ContinuationToken is not null)
+ if (GetContinuationToken(messages, options) is { } token)
{
- // Task stream resumption is not well defined in the A2A v2.* specification, leaving it to the agent implementations.
- // The v3.0 specification improves this by defining task stream reconnection that allows obtaining the same stream
- // from the beginning, but it does not define stream resumption from a specific point in the stream.
- // Therefore, the code should be updated once the A2A .NET library supports the A2A v3.0 specification,
- // and AF has the necessary model to allow consumers to know whether they need to resume the stream and add new updates to
- // the existing ones or reconnect the stream and obtain all updates again.
- // For more details, see the following issue: https://github.com/microsoft/agent-framework/issues/1764
- throw new InvalidOperationException("Reconnecting to task streams using continuation tokens is not supported yet.");
- // a2aSseEvents = this._a2aClient.SubscribeToTaskAsync(token.TaskId, cancellationToken).ConfigureAwait(false);
+ streamEvents = this._a2aClient.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = token.TaskId }, cancellationToken).ConfigureAwait(false);
}
-
- MessageSendParams sendParams = new()
+ else
{
- Message = CreateA2AMessage(typedSession, messages),
- Metadata = options?.AdditionalProperties?.ToA2AMetadata()
- };
+ SendMessageRequest sendParams = new()
+ {
+ Message = CreateA2AMessage(typedSession, messages),
+ Metadata = options?.AdditionalProperties?.ToA2AMetadata()
+ };
- a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(sendParams, cancellationToken).ConfigureAwait(false);
+ streamEvents = this._a2aClient.SendStreamingMessageAsync(sendParams, cancellationToken).ConfigureAwait(false);
+ }
this._logger.LogAgentChatClientInvokedAgent(nameof(RunStreamingAsync), this.Id, this.Name);
string? contextId = null;
string? taskId = null;
- await foreach (var sseEvent in a2aSseEvents)
+ await foreach (var streamResponse in streamEvents)
{
- if (sseEvent.Data is AgentMessage message)
- {
- contextId = message.ContextId;
-
- yield return this.ConvertToAgentResponseUpdate(message);
- }
- else if (sseEvent.Data is AgentTask task)
+ switch (streamResponse.PayloadCase)
{
- contextId = task.ContextId;
- taskId = task.Id;
-
- yield return this.ConvertToAgentResponseUpdate(task);
- }
- else if (sseEvent.Data is TaskUpdateEvent taskUpdateEvent)
- {
- contextId = taskUpdateEvent.ContextId;
- taskId = taskUpdateEvent.TaskId;
-
- yield return this.ConvertToAgentResponseUpdate(taskUpdateEvent);
- }
- else
- {
- throw new NotSupportedException($"Only message, task, task update events are supported from A2A agents. Received: {sseEvent.Data.GetType().FullName ?? "null"}");
+ case StreamResponseCase.Message:
+ var message = streamResponse.Message!;
+ contextId = message.ContextId;
+ yield return this.ConvertToAgentResponseUpdate(message);
+ break;
+
+ case StreamResponseCase.Task:
+ var task = streamResponse.Task!;
+ contextId = task.ContextId;
+ taskId = task.Id;
+ yield return this.ConvertToAgentResponseUpdate(task);
+ break;
+
+ case StreamResponseCase.StatusUpdate:
+ var statusUpdate = streamResponse.StatusUpdate!;
+ contextId = statusUpdate.ContextId;
+ taskId = statusUpdate.TaskId;
+ yield return this.ConvertToAgentResponseUpdate(statusUpdate);
+ break;
+
+ case StreamResponseCase.ArtifactUpdate:
+ var artifactUpdate = streamResponse.ArtifactUpdate!;
+ contextId = artifactUpdate.ContextId;
+ taskId = artifactUpdate.TaskId;
+ yield return this.ConvertToAgentResponseUpdate(artifactUpdate);
+ break;
+
+ default:
+ throw new NotSupportedException($"Only message, task, task update events are supported from A2A agents. Received: {streamResponse.PayloadCase}");
}
}
@@ -284,7 +268,7 @@ private static void UpdateSession(A2AAgentSession? session, string? contextId, s
session.TaskId = taskId;
}
- private static AgentMessage CreateA2AMessage(A2AAgentSession typedSession, IEnumerable messages)
+ private static Message CreateA2AMessage(A2AAgentSession typedSession, IEnumerable messages)
{
var a2aMessage = messages.ToA2AMessage();
@@ -324,7 +308,34 @@ private static AgentMessage CreateA2AMessage(A2AAgentSession typedSession, IEnum
return null;
}
- private AgentResponseUpdate ConvertToAgentResponseUpdate(AgentMessage message)
+ private AgentResponse ConvertToAgentResponse(Message message)
+ {
+ return new AgentResponse
+ {
+ AgentId = this.Id,
+ ResponseId = message.MessageId,
+ FinishReason = ChatFinishReason.Stop,
+ RawRepresentation = message,
+ Messages = [message.ToChatMessage()],
+ AdditionalProperties = message.Metadata?.ToAdditionalProperties(),
+ };
+ }
+
+ private AgentResponse ConvertToAgentResponse(AgentTask agentTask)
+ {
+ return new AgentResponse
+ {
+ AgentId = this.Id,
+ ResponseId = agentTask.Id,
+ FinishReason = MapTaskStateToFinishReason(agentTask.Status.State),
+ RawRepresentation = agentTask,
+ Messages = agentTask.ToChatMessages() ?? [],
+ ContinuationToken = CreateContinuationToken(agentTask.Id, agentTask.Status.State),
+ AdditionalProperties = agentTask.Metadata?.ToAdditionalProperties(),
+ };
+ }
+
+ private AgentResponseUpdate ConvertToAgentResponseUpdate(Message message)
{
return new AgentResponseUpdate
{
@@ -353,28 +364,30 @@ private AgentResponseUpdate ConvertToAgentResponseUpdate(AgentTask task)
};
}
- private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskUpdateEvent taskUpdateEvent)
+ private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskStatusUpdateEvent statusUpdateEvent)
{
- AgentResponseUpdate responseUpdate = new()
+ return new AgentResponseUpdate
{
AgentId = this.Id,
- ResponseId = taskUpdateEvent.TaskId,
- RawRepresentation = taskUpdateEvent,
+ ResponseId = statusUpdateEvent.TaskId,
+ RawRepresentation = statusUpdateEvent,
Role = ChatRole.Assistant,
- AdditionalProperties = taskUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
+ FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State),
+ AdditionalProperties = statusUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
};
+ }
- if (taskUpdateEvent is TaskArtifactUpdateEvent artifactUpdateEvent)
- {
- responseUpdate.Contents = artifactUpdateEvent.Artifact.ToAIContents();
- responseUpdate.RawRepresentation = artifactUpdateEvent;
- }
- else if (taskUpdateEvent is TaskStatusUpdateEvent statusUpdateEvent)
+ private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskArtifactUpdateEvent artifactUpdateEvent)
+ {
+ return new AgentResponseUpdate
{
- responseUpdate.FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State);
- }
-
- return responseUpdate;
+ AgentId = this.Id,
+ ResponseId = artifactUpdateEvent.TaskId,
+ RawRepresentation = artifactUpdateEvent,
+ Role = ChatRole.Assistant,
+ Contents = artifactUpdateEvent.Artifact.ToAIContents(),
+ AdditionalProperties = artifactUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
+ };
}
private static ChatFinishReason? MapTaskStateToFinishReason(TaskState state)
diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs
index 1998d020b5..897349f666 100644
--- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
+using System.Linq;
using System.Net.Http;
using Microsoft.Agents.AI;
using Microsoft.Extensions.Logging;
@@ -29,8 +30,12 @@ public static class A2AAgentCardExtensions
/// An instance backed by the A2A agent.
public static AIAgent AsAIAgent(this AgentCard card, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null)
{
+ // TODO: Refactor to support interface selection from card.SupportedInterfaces.
+ var url = card.SupportedInterfaces?.FirstOrDefault()?.Url
+ ?? throw new InvalidOperationException("The AgentCard does not have any SupportedInterfaces with a URL.");
+
// Create the A2A client using the agent URL from the card.
- var a2aClient = new A2AClient(new Uri(card.Url), httpClient);
+ var a2aClient = new A2AClient(new Uri(url), httpClient);
return a2aClient.AsAIAgent(name: card.Name, description: card.Description, loggerFactory: loggerFactory);
}
diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/ChatMessageExtensions.cs
index b1f1bd643a..dd0749ecc9 100644
--- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/ChatMessageExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/ChatMessageExtensions.cs
@@ -11,7 +11,7 @@ namespace Microsoft.Extensions.AI;
///
internal static class ChatMessageExtensions
{
- internal static AgentMessage ToA2AMessage(this IEnumerable messages)
+ internal static Message ToA2AMessage(this IEnumerable messages)
{
List allParts = [];
@@ -23,10 +23,10 @@ internal static AgentMessage ToA2AMessage(this IEnumerable messages
}
}
- return new AgentMessage
+ return new Message
{
MessageId = Guid.NewGuid().ToString("N"),
- Role = MessageRole.User,
+ Role = Role.User,
Parts = allParts,
};
}
diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj b/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj
index b1b9ba7671..4e92826f56 100644
--- a/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj
@@ -1,6 +1,7 @@
+ $(TargetFrameworksCore)
preview
$(NoWarn);MEAI001
diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs
index 514922dd26..f6a4722699 100644
--- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs
@@ -6,9 +6,7 @@
using System.Linq;
using System.Net;
using System.Net.Http;
-using System.Net.ServerSentEvents;
using System.Text;
-using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -89,14 +87,17 @@ public async Task RunAsync_AllowsNonUserRoleMessagesAsync()
public async Task RunAsync_WithValidUserMessage_RunsSuccessfullyAsync()
{
// Arrange
- this._handler.ResponseToReturn = new AgentMessage
+ this._handler.ResponseToReturn = new SendMessageResponse
{
- MessageId = "response-123",
- Role = MessageRole.Agent,
- Parts =
- [
- new TextPart { Text = "Hello! How can I help you today?" }
- ]
+ Message = new Message
+ {
+ MessageId = "response-123",
+ Role = Role.Agent,
+ Parts =
+ [
+ new Part { Text = "Hello! How can I help you today?" }
+ ]
+ }
};
var inputMessages = new List
@@ -108,11 +109,11 @@ public async Task RunAsync_WithValidUserMessage_RunsSuccessfullyAsync()
var result = await this._agent.RunAsync(inputMessages);
// Assert input message sent to A2AClient
- var inputMessage = this._handler.CapturedMessageSendParams?.Message;
+ var inputMessage = this._handler.CapturedSendMessageRequest?.Message;
Assert.NotNull(inputMessage);
Assert.Single(inputMessage.Parts);
- Assert.Equal(MessageRole.User, inputMessage.Role);
- Assert.Equal("Hello, world!", ((TextPart)inputMessage.Parts[0]).Text);
+ Assert.Equal(Role.User, inputMessage.Role);
+ Assert.Equal("Hello, world!", inputMessage.Parts[0].Text);
// Assert response from A2AClient is converted correctly
Assert.NotNull(result);
@@ -120,8 +121,8 @@ public async Task RunAsync_WithValidUserMessage_RunsSuccessfullyAsync()
Assert.Equal("response-123", result.ResponseId);
Assert.NotNull(result.RawRepresentation);
- Assert.IsType(result.RawRepresentation);
- Assert.Equal("response-123", ((AgentMessage)result.RawRepresentation).MessageId);
+ Assert.IsType(result.RawRepresentation);
+ Assert.Equal("response-123", ((Message)result.RawRepresentation).MessageId);
Assert.Single(result.Messages);
Assert.Equal(ChatRole.Assistant, result.Messages[0].Role);
@@ -133,15 +134,18 @@ public async Task RunAsync_WithValidUserMessage_RunsSuccessfullyAsync()
public async Task RunAsync_WithNewSession_UpdatesSessionConversationIdAsync()
{
// Arrange
- this._handler.ResponseToReturn = new AgentMessage
+ this._handler.ResponseToReturn = new SendMessageResponse
{
- MessageId = "response-123",
- Role = MessageRole.Agent,
- Parts =
- [
- new TextPart { Text = "Response" }
- ],
- ContextId = "new-context-id"
+ Message = new Message
+ {
+ MessageId = "response-123",
+ Role = Role.Agent,
+ Parts =
+ [
+ new Part { Text = "Response" }
+ ],
+ ContextId = "new-context-id"
+ }
};
var inputMessages = new List
@@ -177,7 +181,7 @@ public async Task RunAsync_WithExistingSession_SetConversationIdToMessageAsync()
await this._agent.RunAsync(inputMessages, session);
// Assert
- var message = this._handler.CapturedMessageSendParams?.Message;
+ var message = this._handler.CapturedSendMessageRequest?.Message;
Assert.NotNull(message);
Assert.Equal("existing-context-id", message.ContextId);
}
@@ -191,15 +195,18 @@ public async Task RunAsync_WithSessionHavingDifferentContextId_ThrowsInvalidOper
new(ChatRole.User, "Test message")
};
- this._handler.ResponseToReturn = new AgentMessage
+ this._handler.ResponseToReturn = new SendMessageResponse
{
- MessageId = "response-123",
- Role = MessageRole.Agent,
- Parts =
- [
- new TextPart { Text = "Response" }
- ],
- ContextId = "different-context"
+ Message = new Message
+ {
+ MessageId = "response-123",
+ Role = Role.Agent,
+ Parts =
+ [
+ new Part { Text = "Response" }
+ ],
+ ContextId = "different-context"
+ }
};
var session = await this._agent.CreateSessionAsync();
@@ -219,12 +226,15 @@ public async Task RunStreamingAsync_WithValidUserMessage_YieldsAgentResponseUpda
new(ChatRole.User, "Hello, streaming!")
};
- this._handler.StreamingResponseToReturn = new AgentMessage()
+ this._handler.StreamingResponseToReturn = new StreamResponse
{
- MessageId = "stream-1",
- Role = MessageRole.Agent,
- Parts = [new TextPart { Text = "Hello" }],
- ContextId = "stream-context"
+ Message = new Message
+ {
+ MessageId = "stream-1",
+ Role = Role.Agent,
+ Parts = [new Part { Text = "Hello" }],
+ ContextId = "stream-context"
+ }
};
// Act
@@ -238,11 +248,11 @@ public async Task RunStreamingAsync_WithValidUserMessage_YieldsAgentResponseUpda
Assert.Single(updates);
// Assert input message sent to A2AClient
- var inputMessage = this._handler.CapturedMessageSendParams?.Message;
+ var inputMessage = this._handler.CapturedSendMessageRequest?.Message;
Assert.NotNull(inputMessage);
Assert.Single(inputMessage.Parts);
- Assert.Equal(MessageRole.User, inputMessage.Role);
- Assert.Equal("Hello, streaming!", ((TextPart)inputMessage.Parts[0]).Text);
+ Assert.Equal(Role.User, inputMessage.Role);
+ Assert.Equal("Hello, streaming!", inputMessage.Parts[0].Text);
// Assert response from A2AClient is converted correctly
Assert.Equal(ChatRole.Assistant, updates[0].Role);
@@ -251,8 +261,8 @@ public async Task RunStreamingAsync_WithValidUserMessage_YieldsAgentResponseUpda
Assert.Equal(this._agent.Id, updates[0].AgentId);
Assert.Equal("stream-1", updates[0].ResponseId);
Assert.Equal(ChatFinishReason.Stop, updates[0].FinishReason);
- Assert.IsType(updates[0].RawRepresentation);
- Assert.Equal("stream-1", ((AgentMessage)updates[0].RawRepresentation!).MessageId);
+ Assert.IsType(updates[0].RawRepresentation);
+ Assert.Equal("stream-1", ((Message)updates[0].RawRepresentation!).MessageId);
}
[Fact]
@@ -264,12 +274,15 @@ public async Task RunStreamingAsync_WithSession_UpdatesSessionConversationIdAsyn
new(ChatRole.User, "Test streaming")
};
- this._handler.StreamingResponseToReturn = new AgentMessage()
+ this._handler.StreamingResponseToReturn = new StreamResponse
{
- MessageId = "stream-1",
- Role = MessageRole.Agent,
- Parts = [new TextPart { Text = "Response" }],
- ContextId = "new-stream-context"
+ Message = new Message
+ {
+ MessageId = "stream-1",
+ Role = Role.Agent,
+ Parts = [new Part { Text = "Response" }],
+ ContextId = "new-stream-context"
+ }
};
var session = await this._agent.CreateSessionAsync();
@@ -294,7 +307,7 @@ public async Task RunStreamingAsync_WithExistingSession_SetConversationIdToMessa
new(ChatRole.User, "Test streaming")
};
- this._handler.StreamingResponseToReturn = new AgentMessage();
+ this._handler.StreamingResponseToReturn = new StreamResponse { Message = new Message() };
var session = await this._agent.CreateSessionAsync();
var a2aSession = (A2AAgentSession)session;
@@ -307,7 +320,7 @@ public async Task RunStreamingAsync_WithExistingSession_SetConversationIdToMessa
}
// Assert
- var message = this._handler.CapturedMessageSendParams?.Message;
+ var message = this._handler.CapturedSendMessageRequest?.Message;
Assert.NotNull(message);
Assert.Equal("existing-context-id", message.ContextId);
}
@@ -325,12 +338,15 @@ public async Task RunStreamingAsync_WithSessionHavingDifferentContextId_ThrowsIn
new(ChatRole.User, "Test streaming")
};
- this._handler.StreamingResponseToReturn = new AgentMessage()
+ this._handler.StreamingResponseToReturn = new StreamResponse
{
- MessageId = "stream-1",
- Role = MessageRole.Agent,
- Parts = [new TextPart { Text = "Response" }],
- ContextId = "different-context"
+ Message = new Message
+ {
+ MessageId = "stream-1",
+ Role = Role.Agent,
+ Parts = [new Part { Text = "Response" }],
+ ContextId = "different-context"
+ }
};
// Act
@@ -346,12 +362,15 @@ await Assert.ThrowsAsync(async () =>
public async Task RunStreamingAsync_AllowsNonUserRoleMessagesAsync()
{
// Arrange
- this._handler.StreamingResponseToReturn = new AgentMessage()
+ this._handler.StreamingResponseToReturn = new StreamResponse
{
- MessageId = "stream-1",
- Role = MessageRole.Agent,
- Parts = [new TextPart { Text = "Response" }],
- ContextId = "new-stream-context"
+ Message = new Message
+ {
+ MessageId = "stream-1",
+ Role = Role.Agent,
+ Parts = [new Part { Text = "Response" }],
+ ContextId = "new-stream-context"
+ }
};
var inputMessages = new List
@@ -385,13 +404,13 @@ public async Task RunAsync_WithHostedFileContent_ConvertsToFilePartAsync()
await this._agent.RunAsync(inputMessages);
// Assert
- var message = this._handler.CapturedMessageSendParams?.Message;
+ var message = this._handler.CapturedSendMessageRequest?.Message;
Assert.NotNull(message);
Assert.Equal(2, message.Parts.Count);
- Assert.IsType(message.Parts[0]);
- Assert.Equal("Check this file:", ((TextPart)message.Parts[0]).Text);
- Assert.IsType(message.Parts[1]);
- Assert.Equal("https://example.com/file.pdf", ((FilePart)message.Parts[1]).File.Uri?.ToString());
+ Assert.Equal(PartContentCase.Text, message.Parts[0].ContentCase);
+ Assert.Equal("Check this file:", message.Parts[0].Text);
+ Assert.Equal(PartContentCase.Url, message.Parts[1].ContentCase);
+ Assert.Equal("https://example.com/file.pdf", message.Parts[1].Url);
}
[Fact]
@@ -413,10 +432,11 @@ public async Task RunAsync_WithContinuationTokenAndMessages_ThrowsInvalidOperati
public async Task RunAsync_WithContinuationToken_CallsGetTaskAsyncAsync()
{
// Arrange
- this._handler.ResponseToReturn = new AgentTask
+ this._handler.AgentTaskToReturn = new AgentTask
{
Id = "task-123",
- ContextId = "context-123"
+ ContextId = "context-123",
+ Status = new() { State = TaskState.Submitted }
};
var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken("task-123") };
@@ -425,19 +445,22 @@ public async Task RunAsync_WithContinuationToken_CallsGetTaskAsyncAsync()
await this._agent.RunAsync([], options: options);
// Assert
- Assert.Equal("tasks/get", this._handler.CapturedJsonRpcRequest?.Method);
- Assert.Equal("task-123", this._handler.CapturedTaskIdParams?.Id);
+ Assert.Equal("GetTask", this._handler.CapturedJsonRpcRequest?.Method);
+ Assert.Equal("task-123", this._handler.CapturedGetTaskRequest?.Id);
}
[Fact]
public async Task RunAsync_WithTaskInSessionAndMessage_AddTaskAsReferencesToMessageAsync()
{
// Arrange
- this._handler.ResponseToReturn = new AgentMessage
+ this._handler.ResponseToReturn = new SendMessageResponse
{
- MessageId = "response-123",
- Role = MessageRole.Agent,
- Parts = [new TextPart { Text = "Response to task" }]
+ Message = new Message
+ {
+ MessageId = "response-123",
+ Role = Role.Agent,
+ Parts = [new Part { Text = "Response to task" }]
+ }
};
var session = (A2AAgentSession)await this._agent.CreateSessionAsync();
@@ -449,7 +472,7 @@ public async Task RunAsync_WithTaskInSessionAndMessage_AddTaskAsReferencesToMess
await this._agent.RunAsync(inputMessage, session);
// Assert
- var message = this._handler.CapturedMessageSendParams?.Message;
+ var message = this._handler.CapturedSendMessageRequest?.Message;
Assert.Null(message?.TaskId);
Assert.NotNull(message?.ReferenceTaskIds);
Assert.Contains("task-123", message.ReferenceTaskIds);
@@ -459,11 +482,14 @@ public async Task RunAsync_WithTaskInSessionAndMessage_AddTaskAsReferencesToMess
public async Task RunAsync_WithAgentTask_UpdatesSessionTaskIdAsync()
{
// Arrange
- this._handler.ResponseToReturn = new AgentTask
+ this._handler.ResponseToReturn = new SendMessageResponse
{
- Id = "task-456",
- ContextId = "context-789",
- Status = new() { State = TaskState.Submitted }
+ Task = new AgentTask
+ {
+ Id = "task-456",
+ ContextId = "context-789",
+ Status = new() { State = TaskState.Submitted }
+ }
};
var session = await this._agent.CreateSessionAsync();
@@ -480,16 +506,19 @@ public async Task RunAsync_WithAgentTask_UpdatesSessionTaskIdAsync()
public async Task RunAsync_WithAgentTaskResponse_ReturnsTaskResponseCorrectlyAsync()
{
// Arrange
- this._handler.ResponseToReturn = new AgentTask
+ this._handler.ResponseToReturn = new SendMessageResponse
{
- Id = "task-789",
- ContextId = "context-456",
- Status = new() { State = TaskState.Submitted },
- Metadata = new Dictionary
+ Task = new AgentTask
+ {
+ Id = "task-789",
+ ContextId = "context-456",
+ Status = new() { State = TaskState.Submitted },
+ Metadata = new Dictionary
{
{ "key1", JsonSerializer.SerializeToElement("value1") },
{ "count", JsonSerializer.SerializeToElement(42) }
}
+ }
};
var session = await this._agent.CreateSessionAsync();
@@ -532,11 +561,14 @@ public async Task RunAsync_WithAgentTaskResponse_ReturnsTaskResponseCorrectlyAsy
public async Task RunAsync_WithVariousTaskStates_ReturnsCorrectTokenAsync(TaskState taskState)
{
// Arrange
- this._handler.ResponseToReturn = new AgentTask
+ this._handler.ResponseToReturn = new SendMessageResponse
{
- Id = "task-123",
- ContextId = "context-123",
- Status = new() { State = taskState }
+ Task = new AgentTask
+ {
+ Id = "task-123",
+ ContextId = "context-123",
+ Status = new() { State = taskState }
+ }
};
// Act
@@ -583,15 +615,76 @@ await Assert.ThrowsAsync(async () =>
});
}
+ [Fact]
+ public async Task RunStreamingAsync_WithContinuationToken_UsesSubscribeToTaskMethodAsync()
+ {
+ // Arrange
+ this._handler.StreamingResponseToReturn = new StreamResponse
+ {
+ Message = new Message
+ {
+ MessageId = "response-123",
+ Role = Role.Agent,
+ Parts = [new Part { Text = "Continuation response" }]
+ }
+ };
+
+ var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken("task-456") };
+
+ // Act
+ await foreach (var _ in this._agent.RunStreamingAsync([], null, options))
+ {
+ // Just iterate through to trigger the logic
+ }
+
+ // Assert - verify SubscribeToTask was called (not SendStreamingMessage)
+ Assert.Single(this._handler.CapturedJsonRpcRequests);
+ Assert.Equal("SubscribeToTask", this._handler.CapturedJsonRpcRequests[0].Method);
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_WithContinuationToken_PassesCorrectTaskIdAsync()
+ {
+ // Arrange
+ this._handler.StreamingResponseToReturn = new StreamResponse
+ {
+ Message = new Message
+ {
+ MessageId = "response-123",
+ Role = Role.Agent,
+ Parts = [new Part { Text = "Continuation response" }]
+ }
+ };
+
+ const string ExpectedTaskId = "my-task-789";
+ var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken(ExpectedTaskId) };
+
+ // Act
+ await foreach (var _ in this._agent.RunStreamingAsync([], null, options))
+ {
+ // Just iterate through to trigger the logic
+ }
+
+ // Assert - verify the task ID was passed correctly
+ Assert.NotEmpty(this._handler.CapturedJsonRpcRequests);
+ var subscribeRequest = this._handler.CapturedJsonRpcRequests[0];
+ var subscribeParams = subscribeRequest.Params?.Deserialize(A2AJsonUtilities.DefaultOptions);
+ Assert.NotNull(subscribeParams);
+ Assert.Equal(ExpectedTaskId, subscribeParams.Id);
+ }
+
[Fact]
public async Task RunStreamingAsync_WithTaskInSessionAndMessage_AddTaskAsReferencesToMessageAsync()
{
// Arrange
- this._handler.StreamingResponseToReturn = new AgentMessage
+ this._handler.StreamingResponseToReturn = new StreamResponse
{
- MessageId = "response-123",
- Role = MessageRole.Agent,
- Parts = [new TextPart { Text = "Response to task" }]
+ Message = new Message
+ {
+ MessageId = "response-123",
+ Role = Role.Agent,
+ Parts = [new Part { Text = "Response to task" }]
+ }
};
var session = (A2AAgentSession)await this._agent.CreateSessionAsync();
@@ -604,7 +697,7 @@ public async Task RunStreamingAsync_WithTaskInSessionAndMessage_AddTaskAsReferen
}
// Assert
- var message = this._handler.CapturedMessageSendParams?.Message;
+ var message = this._handler.CapturedSendMessageRequest?.Message;
Assert.Null(message?.TaskId);
Assert.NotNull(message?.ReferenceTaskIds);
Assert.Contains("task-123", message.ReferenceTaskIds);
@@ -614,11 +707,14 @@ public async Task RunStreamingAsync_WithTaskInSessionAndMessage_AddTaskAsReferen
public async Task RunStreamingAsync_WithAgentTask_UpdatesSessionTaskIdAsync()
{
// Arrange
- this._handler.StreamingResponseToReturn = new AgentTask
+ this._handler.StreamingResponseToReturn = new StreamResponse
{
- Id = "task-456",
- ContextId = "context-789",
- Status = new() { State = TaskState.Submitted }
+ Task = new AgentTask
+ {
+ Id = "task-456",
+ ContextId = "context-789",
+ Status = new() { State = TaskState.Submitted }
+ }
};
var session = await this._agent.CreateSessionAsync();
@@ -642,15 +738,18 @@ public async Task RunStreamingAsync_WithAgentMessage_YieldsResponseUpdateAsync()
const string ContextId = "ctx-456";
const string MessageText = "Hello from agent!";
- this._handler.StreamingResponseToReturn = new AgentMessage
+ this._handler.StreamingResponseToReturn = new StreamResponse
{
- MessageId = MessageId,
- Role = MessageRole.Agent,
- ContextId = ContextId,
- Parts =
- [
- new TextPart { Text = MessageText }
- ]
+ Message = new Message
+ {
+ MessageId = MessageId,
+ Role = Role.Agent,
+ ContextId = ContextId,
+ Parts =
+ [
+ new Part { Text = MessageText }
+ ]
+ }
};
// Act
@@ -670,8 +769,8 @@ public async Task RunStreamingAsync_WithAgentMessage_YieldsResponseUpdateAsync()
Assert.Equal(this._agent.Id, update0.AgentId);
Assert.Equal(MessageText, update0.Text);
Assert.Equal(ChatFinishReason.Stop, update0.FinishReason);
- Assert.IsType(update0.RawRepresentation);
- Assert.Equal(MessageId, ((AgentMessage)update0.RawRepresentation!).MessageId);
+ Assert.IsType(update0.RawRepresentation);
+ Assert.Equal(MessageId, ((Message)update0.RawRepresentation!).MessageId);
}
[Fact]
@@ -681,18 +780,21 @@ public async Task RunStreamingAsync_WithAgentTask_YieldsResponseUpdateAsync()
const string TaskId = "task-789";
const string ContextId = "ctx-012";
- this._handler.StreamingResponseToReturn = new AgentTask
+ this._handler.StreamingResponseToReturn = new StreamResponse
{
- Id = TaskId,
- ContextId = ContextId,
- Status = new() { State = TaskState.Submitted },
- Artifacts = [
+ Task = new AgentTask
+ {
+ Id = TaskId,
+ ContextId = ContextId,
+ Status = new() { State = TaskState.Submitted },
+ Artifacts = [
new()
{
ArtifactId = "art-123",
- Parts = [new TextPart { Text = "Task artifact content" }]
+ Parts = [new Part { Text = "Task artifact content" }]
}
]
+ }
};
var session = await this._agent.CreateSessionAsync();
@@ -728,11 +830,14 @@ public async Task RunStreamingAsync_WithTaskStatusUpdateEvent_YieldsResponseUpda
const string TaskId = "task-status-123";
const string ContextId = "ctx-status-456";
- this._handler.StreamingResponseToReturn = new TaskStatusUpdateEvent
+ this._handler.StreamingResponseToReturn = new StreamResponse
{
- TaskId = TaskId,
- ContextId = ContextId,
- Status = new() { State = TaskState.Working }
+ StatusUpdate = new TaskStatusUpdateEvent
+ {
+ TaskId = TaskId,
+ ContextId = ContextId,
+ Status = new() { State = TaskState.Working }
+ }
};
var session = await this._agent.CreateSessionAsync();
@@ -768,14 +873,17 @@ public async Task RunStreamingAsync_WithTaskArtifactUpdateEvent_YieldsResponseUp
const string ContextId = "ctx-artifact-456";
const string ArtifactContent = "Task artifact data";
- this._handler.StreamingResponseToReturn = new TaskArtifactUpdateEvent
+ this._handler.StreamingResponseToReturn = new StreamResponse
{
- TaskId = TaskId,
- ContextId = ContextId,
- Artifact = new()
+ ArtifactUpdate = new TaskArtifactUpdateEvent
{
- ArtifactId = "artifact-789",
- Parts = [new TextPart { Text = ArtifactContent }]
+ TaskId = TaskId,
+ ContextId = ContextId,
+ Artifact = new()
+ {
+ ArtifactId = "artifact-789",
+ Parts = [new Part { Text = ArtifactContent }]
+ }
}
};
@@ -848,15 +956,18 @@ await Assert.ThrowsAsync(async () =>
public async Task RunAsync_WithAgentMessageResponseMetadata_ReturnsMetadataAsAdditionalPropertiesAsync()
{
// Arrange
- this._handler.ResponseToReturn = new AgentMessage
+ this._handler.ResponseToReturn = new SendMessageResponse
{
- MessageId = "response-123",
- Role = MessageRole.Agent,
- Parts = [new TextPart { Text = "Response with metadata" }],
- Metadata = new Dictionary
+ Message = new Message
{
- { "responseKey1", JsonSerializer.SerializeToElement("responseValue1") },
- { "responseCount", JsonSerializer.SerializeToElement(99) }
+ MessageId = "response-123",
+ Role = Role.Agent,
+ Parts = [new Part { Text = "Response with metadata" }],
+ Metadata = new Dictionary
+ {
+ { "responseKey1", JsonSerializer.SerializeToElement("responseValue1") },
+ { "responseCount", JsonSerializer.SerializeToElement(99) }
+ }
}
};
@@ -877,14 +988,17 @@ public async Task RunAsync_WithAgentMessageResponseMetadata_ReturnsMetadataAsAdd
}
[Fact]
- public async Task RunAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMessageSendParamsAsync()
+ public async Task RunAsync_WithAdditionalProperties_PropagatesThemAsMetadataToSendMessageRequestAsync()
{
// Arrange
- this._handler.ResponseToReturn = new AgentMessage
+ this._handler.ResponseToReturn = new SendMessageResponse
{
- MessageId = "response-123",
- Role = MessageRole.Agent,
- Parts = [new TextPart { Text = "Response" }]
+ Message = new Message
+ {
+ MessageId = "response-123",
+ Role = Role.Agent,
+ Parts = [new Part { Text = "Response" }]
+ }
};
var inputMessages = new List
@@ -906,22 +1020,25 @@ public async Task RunAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMe
await this._agent.RunAsync(inputMessages, null, options);
// Assert
- Assert.NotNull(this._handler.CapturedMessageSendParams);
- Assert.NotNull(this._handler.CapturedMessageSendParams.Metadata);
- Assert.Equal("value1", this._handler.CapturedMessageSendParams.Metadata["key1"].GetString());
- Assert.Equal(42, this._handler.CapturedMessageSendParams.Metadata["key2"].GetInt32());
- Assert.True(this._handler.CapturedMessageSendParams.Metadata["key3"].GetBoolean());
+ Assert.NotNull(this._handler.CapturedSendMessageRequest);
+ Assert.NotNull(this._handler.CapturedSendMessageRequest.Metadata);
+ Assert.Equal("value1", this._handler.CapturedSendMessageRequest.Metadata["key1"].GetString());
+ Assert.Equal(42, this._handler.CapturedSendMessageRequest.Metadata["key2"].GetInt32());
+ Assert.True(this._handler.CapturedSendMessageRequest.Metadata["key3"].GetBoolean());
}
[Fact]
public async Task RunAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync()
{
// Arrange
- this._handler.ResponseToReturn = new AgentMessage
+ this._handler.ResponseToReturn = new SendMessageResponse
{
- MessageId = "response-123",
- Role = MessageRole.Agent,
- Parts = [new TextPart { Text = "Response" }]
+ Message = new Message
+ {
+ MessageId = "response-123",
+ Role = Role.Agent,
+ Parts = [new Part { Text = "Response" }]
+ }
};
var inputMessages = new List
@@ -938,19 +1055,22 @@ public async Task RunAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync(
await this._agent.RunAsync(inputMessages, null, options);
// Assert
- Assert.NotNull(this._handler.CapturedMessageSendParams);
- Assert.Null(this._handler.CapturedMessageSendParams.Metadata);
+ Assert.NotNull(this._handler.CapturedSendMessageRequest);
+ Assert.Null(this._handler.CapturedSendMessageRequest.Metadata);
}
[Fact]
- public async Task RunStreamingAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMessageSendParamsAsync()
+ public async Task RunStreamingAsync_WithAdditionalProperties_PropagatesThemAsMetadataToSendMessageRequestAsync()
{
// Arrange
- this._handler.StreamingResponseToReturn = new AgentMessage
+ this._handler.StreamingResponseToReturn = new StreamResponse
{
- MessageId = "stream-123",
- Role = MessageRole.Agent,
- Parts = [new TextPart { Text = "Streaming response" }]
+ Message = new Message
+ {
+ MessageId = "stream-123",
+ Role = Role.Agent,
+ Parts = [new Part { Text = "Streaming response" }]
+ }
};
var inputMessages = new List
@@ -974,22 +1094,25 @@ public async Task RunStreamingAsync_WithAdditionalProperties_PropagatesThemAsMet
}
// Assert
- Assert.NotNull(this._handler.CapturedMessageSendParams);
- Assert.NotNull(this._handler.CapturedMessageSendParams.Metadata);
- Assert.Equal("streamValue1", this._handler.CapturedMessageSendParams.Metadata["streamKey1"].GetString());
- Assert.Equal(100, this._handler.CapturedMessageSendParams.Metadata["streamKey2"].GetInt32());
- Assert.False(this._handler.CapturedMessageSendParams.Metadata["streamKey3"].GetBoolean());
+ Assert.NotNull(this._handler.CapturedSendMessageRequest);
+ Assert.NotNull(this._handler.CapturedSendMessageRequest.Metadata);
+ Assert.Equal("streamValue1", this._handler.CapturedSendMessageRequest.Metadata["streamKey1"].GetString());
+ Assert.Equal(100, this._handler.CapturedSendMessageRequest.Metadata["streamKey2"].GetInt32());
+ Assert.False(this._handler.CapturedSendMessageRequest.Metadata["streamKey3"].GetBoolean());
}
[Fact]
public async Task RunStreamingAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync()
{
// Arrange
- this._handler.StreamingResponseToReturn = new AgentMessage
+ this._handler.StreamingResponseToReturn = new StreamResponse
{
- MessageId = "stream-123",
- Role = MessageRole.Agent,
- Parts = [new TextPart { Text = "Streaming response" }]
+ Message = new Message
+ {
+ MessageId = "stream-123",
+ Role = Role.Agent,
+ Parts = [new Part { Text = "Streaming response" }]
+ }
};
var inputMessages = new List
@@ -1008,8 +1131,115 @@ public async Task RunStreamingAsync_WithNullAdditionalProperties_DoesNotSetMetad
}
// Assert
- Assert.NotNull(this._handler.CapturedMessageSendParams);
- Assert.Null(this._handler.CapturedMessageSendParams.Metadata);
+ Assert.NotNull(this._handler.CapturedSendMessageRequest);
+ Assert.Null(this._handler.CapturedSendMessageRequest.Metadata);
+ }
+
+ [Fact]
+ public async Task RunAsync_WithDefaultOptions_SetsBlockingToTrueAsync()
+ {
+ // Arrange
+ var inputMessages = new List
+ {
+ new(ChatRole.User, "Test message")
+ };
+
+ // Act
+ await this._agent.RunAsync(inputMessages);
+
+ // Assert
+ Assert.NotNull(this._handler.CapturedSendMessageRequest);
+ Assert.NotNull(this._handler.CapturedSendMessageRequest.Configuration);
+ Assert.False(this._handler.CapturedSendMessageRequest.Configuration.ReturnImmediately);
+ }
+
+ [Fact]
+ public async Task RunAsync_WithAllowBackgroundResponsesTrue_SetsReturnImmediatelyToTrueAsync()
+ {
+ // Arrange
+ var inputMessages = new List
+ {
+ new(ChatRole.User, "Test message")
+ };
+
+ var session = await this._agent.CreateSessionAsync();
+ var options = new AgentRunOptions { AllowBackgroundResponses = true };
+
+ // Act
+ await this._agent.RunAsync(inputMessages, session, options);
+
+ // Assert
+ Assert.NotNull(this._handler.CapturedSendMessageRequest);
+ Assert.NotNull(this._handler.CapturedSendMessageRequest.Configuration);
+ Assert.True(this._handler.CapturedSendMessageRequest.Configuration.ReturnImmediately);
+ }
+
+ [Fact]
+ public async Task RunAsync_WithAllowBackgroundResponsesFalse_SetsReturnImmediatelyToFalseAsync()
+ {
+ // Arrange
+ var inputMessages = new List
+ {
+ new(ChatRole.User, "Test message")
+ };
+
+ var options = new AgentRunOptions { AllowBackgroundResponses = false };
+
+ // Act
+ await this._agent.RunAsync(inputMessages, null, options);
+
+ // Assert
+ Assert.NotNull(this._handler.CapturedSendMessageRequest);
+ Assert.NotNull(this._handler.CapturedSendMessageRequest.Configuration);
+ Assert.False(this._handler.CapturedSendMessageRequest.Configuration.ReturnImmediately);
+ }
+
+ [Fact]
+ public async Task RunAsync_WithNullOptions_SetsReturnImmediatelyToFalseAsync()
+ {
+ // Arrange
+ var inputMessages = new List
+ {
+ new(ChatRole.User, "Test message")
+ };
+
+ // Act
+ await this._agent.RunAsync(inputMessages, null, null);
+
+ // Assert
+ Assert.NotNull(this._handler.CapturedSendMessageRequest);
+ Assert.NotNull(this._handler.CapturedSendMessageRequest.Configuration);
+ Assert.False(this._handler.CapturedSendMessageRequest.Configuration.ReturnImmediately);
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_SendMessageRequest_DoesNotSetReturnImmediatelyConfigurationAsync()
+ {
+ // Arrange
+ this._handler.StreamingResponseToReturn = new StreamResponse
+ {
+ Message = new Message
+ {
+ MessageId = "response-123",
+ Role = Role.Agent,
+ Parts = [new Part { Text = "Streaming response" }]
+ }
+ };
+
+ var inputMessages = new List
+ {
+ new(ChatRole.User, "Test message")
+ };
+
+ // Act
+ await foreach (var _ in this._agent.RunStreamingAsync(inputMessages))
+ {
+ // Just iterate through to trigger the logic
+ }
+
+ // Assert
+ Assert.NotNull(this._handler.CapturedSendMessageRequest);
+ Assert.Null(this._handler.CapturedSendMessageRequest.Configuration);
}
[Fact]
@@ -1256,6 +1486,7 @@ await Assert.ThrowsAnyAsync(async () =>
public void Dispose()
{
+ this._a2aClient.Dispose();
this._handler.Dispose();
this._httpClient.Dispose();
}
@@ -1269,13 +1500,17 @@ internal sealed class A2AClientHttpMessageHandlerStub : HttpMessageHandler
{
public JsonRpcRequest? CapturedJsonRpcRequest { get; set; }
- public MessageSendParams? CapturedMessageSendParams { get; set; }
+ public List CapturedJsonRpcRequests { get; } = [];
+
+ public SendMessageRequest? CapturedSendMessageRequest { get; set; }
- public TaskIdParams? CapturedTaskIdParams { get; set; }
+ public GetTaskRequest? CapturedGetTaskRequest { get; set; }
- public A2AEvent? ResponseToReturn { get; set; }
+ public SendMessageResponse? ResponseToReturn { get; set; }
- public A2AEvent? StreamingResponseToReturn { get; set; }
+ public AgentTask? AgentTaskToReturn { get; set; }
+
+ public StreamResponse? StreamingResponseToReturn { get; set; }
protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
@@ -1286,22 +1521,46 @@ protected override async Task SendAsync(HttpRequestMessage
this.CapturedJsonRpcRequest = JsonSerializer.Deserialize(content);
+ if (this.CapturedJsonRpcRequest is not null)
+ {
+ this.CapturedJsonRpcRequests.Add(this.CapturedJsonRpcRequest);
+ }
+
try
{
- this.CapturedMessageSendParams = this.CapturedJsonRpcRequest?.Params?.Deserialize();
+ this.CapturedSendMessageRequest = this.CapturedJsonRpcRequest?.Params?.Deserialize(A2AJsonUtilities.DefaultOptions);
}
- catch { /* Ignore deserialization errors for non-MessageSendParams requests */ }
+ catch { /* Ignore deserialization errors for non-SendMessageRequest requests */ }
try
{
- this.CapturedTaskIdParams = this.CapturedJsonRpcRequest?.Params?.Deserialize();
+ this.CapturedGetTaskRequest = this.CapturedJsonRpcRequest?.Params?.Deserialize(A2AJsonUtilities.DefaultOptions);
+ }
+ catch { /* Ignore deserialization errors for non-GetTaskRequest requests */ }
+
+ // Return the pre-configured AgentTask response (for tasks/get)
+ if (this.AgentTaskToReturn is not null && this.CapturedJsonRpcRequest?.Method == "GetTask")
+ {
+ var jsonRpcResponse = new JsonRpcResponse
+ {
+ Id = "response-id",
+ Result = JsonSerializer.SerializeToNode(this.AgentTaskToReturn, A2AJsonUtilities.DefaultOptions)
+ };
+
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
+ };
}
- catch { /* Ignore deserialization errors for non-TaskIdParams requests */ }
// Return the pre-configured non-streaming response
if (this.ResponseToReturn is not null)
{
- var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("response-id", this.ResponseToReturn);
+ var jsonRpcResponse = new JsonRpcResponse
+ {
+ Id = "response-id",
+ Result = JsonSerializer.SerializeToNode(this.ResponseToReturn, A2AJsonUtilities.DefaultOptions)
+ };
return new HttpResponseMessage(HttpStatusCode.OK)
{
@@ -1311,22 +1570,18 @@ protected override async Task SendAsync(HttpRequestMessage
// Return the pre-configured streaming response
else if (this.StreamingResponseToReturn is not null)
{
- var stream = new MemoryStream();
-
- await SseFormatter.WriteAsync(
- new SseItem[]
- {
- new(JsonRpcResponse.CreateJsonRpcResponse("response-id", this.StreamingResponseToReturn!))
- }.ToAsyncEnumerable(),
- stream,
- (item, writer) =>
- {
- using Utf8JsonWriter json = new(writer, new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping });
- JsonSerializer.Serialize(json, item.Data);
- },
- cancellationToken
- );
+ var jsonRpcResponse = new JsonRpcResponse
+ {
+ Id = "response-id",
+ Result = JsonSerializer.SerializeToNode(this.StreamingResponseToReturn, A2AJsonUtilities.DefaultOptions)
+ };
+ var stream = new MemoryStream();
+ var writer = new StreamWriter(stream);
+ await writer.WriteAsync($"data: {JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions)}\n\n");
+#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods; overload doesn't exist downlevel
+ await writer.FlushAsync();
+#pragma warning restore CA2016
stream.Position = 0;
return new HttpResponseMessage(HttpStatusCode.OK)
@@ -1339,7 +1594,11 @@ await SseFormatter.WriteAsync(
}
else
{
- var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("response-id", new AgentMessage());
+ var jsonRpcResponse = new JsonRpcResponse
+ {
+ Id = "response-id",
+ Result = JsonSerializer.SerializeToNode(new SendMessageResponse { Message = new Message() }, A2AJsonUtilities.DefaultOptions)
+ };
return new HttpResponseMessage(HttpStatusCode.OK)
{
diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAIContentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAIContentExtensionsTests.cs
index 358bdfb152..c2e704833a 100644
--- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAIContentExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAIContentExtensionsTests.cs
@@ -42,14 +42,14 @@ public void ToA2AParts_WithMultipleContents_ReturnsListWithAllParts()
Assert.NotNull(result);
Assert.Equal(3, result.Count);
- var firstTextPart = Assert.IsType(result[0]);
- Assert.Equal("First text", firstTextPart.Text);
+ Assert.Equal(PartContentCase.Text, result[0].ContentCase);
+ Assert.Equal("First text", result[0].Text);
- var filePart = Assert.IsType(result[1]);
- Assert.Equal("https://example.com/file1.txt", filePart.File.Uri?.ToString());
+ Assert.Equal(PartContentCase.Url, result[1].ContentCase);
+ Assert.Equal("https://example.com/file1.txt", result[1].Url);
- var secondTextPart = Assert.IsType(result[2]);
- Assert.Equal("Second text", secondTextPart.Text);
+ Assert.Equal(PartContentCase.Text, result[2].ContentCase);
+ Assert.Equal("Second text", result[2].Text);
}
[Fact]
@@ -72,14 +72,14 @@ public void ToA2AParts_WithMixedSupportedAndUnsupportedContent_IgnoresUnsupporte
Assert.NotNull(result);
Assert.Equal(3, result.Count);
- var firstTextPart = Assert.IsType(result[0]);
- Assert.Equal("First text", firstTextPart.Text);
+ Assert.Equal(PartContentCase.Text, result[0].ContentCase);
+ Assert.Equal("First text", result[0].Text);
- var filePart = Assert.IsType(result[1]);
- Assert.Equal("https://example.com/file.txt", filePart.File.Uri?.ToString());
+ Assert.Equal(PartContentCase.Url, result[1].ContentCase);
+ Assert.Equal("https://example.com/file.txt", result[1].Url);
- var secondTextPart = Assert.IsType(result[2]);
- Assert.Equal("Second text", secondTextPart.Text);
+ Assert.Equal(PartContentCase.Text, result[2].ContentCase);
+ Assert.Equal("Second text", result[2].Text);
}
// Mock class for testing unsupported scenarios
diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs
index f644109b38..b45c381bd2 100644
--- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections;
using System.Collections.Generic;
+using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
@@ -26,7 +27,7 @@ public A2AAgentCardExtensionsTests()
{
Name = "Test Agent",
Description = "A test agent for unit testing",
- Url = "http://test-endpoint/agent"
+ SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
};
}
@@ -50,10 +51,10 @@ public async Task RunIAgentAsync_SendsRequestToTheUrlSpecifiedInAgentCardAsync()
using var handler = new HttpMessageHandlerStub();
using var httpClient = new HttpClient(handler, false);
- handler.ResponsesToReturn.Enqueue(new AgentMessage
+ handler.ResponsesToReturn.Enqueue(new Message
{
- Role = MessageRole.Agent,
- Parts = [new TextPart { Text = "Response" }],
+ Role = Role.Agent,
+ Parts = [Part.FromText("Response")],
});
var agent = this._agentCard.AsAIAgent(httpClient);
@@ -66,6 +67,41 @@ public async Task RunIAgentAsync_SendsRequestToTheUrlSpecifiedInAgentCardAsync()
Assert.Equal(new Uri("http://test-endpoint/agent"), handler.CapturedUris[0]);
}
+ [Fact]
+ public async Task AsAIAgent_WithMultipleInterfaces_UsesFirstInterfaceAsync()
+ {
+ // Arrange
+ var card = new AgentCard
+ {
+ Name = "Multi-Interface Agent",
+ Description = "An agent with multiple interfaces",
+ SupportedInterfaces =
+ [
+ new AgentInterface { Url = "http://first/agent" },
+ new AgentInterface { Url = "http://second/agent", ProtocolBinding = "grpc" },
+ new AgentInterface { Url = "http://third/agent", ProtocolBinding = "http" },
+ ]
+ };
+
+ using var handler = new HttpMessageHandlerStub();
+ using var httpClient = new HttpClient(handler, false);
+
+ handler.ResponsesToReturn.Enqueue(new Message
+ {
+ Role = Role.Agent,
+ Parts = [Part.FromText("Response")],
+ });
+
+ var agent = card.AsAIAgent(httpClient);
+
+ // Act
+ await agent.RunAsync("Test input");
+
+ // Assert
+ Assert.Single(handler.CapturedUris);
+ Assert.Equal(new Uri("http://first/agent"), handler.CapturedUris[0]);
+ }
+
internal sealed class HttpMessageHandlerStub : HttpMessageHandler
{
public Queue ResponsesToReturn { get; } = new();
@@ -86,13 +122,18 @@ protected override async Task SendAsync(HttpRequestMessage
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}
- else if (response is AgentMessage message)
+ else if (response is Message message)
{
- var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("response-id", message);
+ var sendMessageResponse = new SendMessageResponse { Message = message };
+ var jsonRpcResponse = new JsonRpcResponse
+ {
+ Id = "response-id",
+ Result = JsonSerializer.SerializeToNode(sendMessageResponse, A2AJsonUtilities.DefaultOptions)
+ };
return new HttpResponseMessage(HttpStatusCode.OK)
{
- Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
+ Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions), Encoding.UTF8, "application/json")
};
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs
index 97c9ca7c05..5fdfb1ff89 100644
--- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs
@@ -40,7 +40,7 @@ public void ToChatMessages_WithEmptyArtifactsAndNoUserInputRequests_ReturnsNull(
{
Id = "task1",
Artifacts = [],
- Status = new AgentTaskStatus { State = TaskState.Completed },
+ Status = new TaskStatus { State = TaskState.Completed },
};
// Act
@@ -58,7 +58,7 @@ public void ToChatMessages_WithNullArtifactsAndNoUserInputRequests_ReturnsNull()
{
Id = "task1",
Artifacts = null,
- Status = new AgentTaskStatus { State = TaskState.Completed },
+ Status = new TaskStatus { State = TaskState.Completed },
};
// Act
@@ -76,7 +76,7 @@ public void ToAIContents_WithEmptyArtifactsAndNoUserInputRequests_ReturnsNull()
{
Id = "task1",
Artifacts = [],
- Status = new AgentTaskStatus { State = TaskState.Completed },
+ Status = new TaskStatus { State = TaskState.Completed },
};
// Act
@@ -94,7 +94,7 @@ public void ToAIContents_WithNullArtifactsAndNoUserInputRequests_ReturnsNull()
{
Id = "task1",
Artifacts = null,
- Status = new AgentTaskStatus { State = TaskState.Completed },
+ Status = new TaskStatus { State = TaskState.Completed },
};
// Act
@@ -110,14 +110,14 @@ public void ToChatMessages_WithValidArtifact_ReturnsChatMessages()
// Arrange
var artifact = new Artifact
{
- Parts = [new TextPart { Text = "response" }],
+ Parts = [Part.FromText("response")],
};
var agentTask = new AgentTask
{
Id = "task1",
Artifacts = [artifact],
- Status = new AgentTaskStatus { State = TaskState.Completed },
+ Status = new TaskStatus { State = TaskState.Completed },
};
// Act
@@ -136,15 +136,15 @@ public void ToAIContents_WithMultipleArtifacts_FlattenAllContents()
// Arrange
var artifact1 = new Artifact
{
- Parts = [new TextPart { Text = "content1" }],
+ Parts = [Part.FromText("content1")],
};
var artifact2 = new Artifact
{
Parts =
[
- new TextPart { Text = "content2" },
- new TextPart { Text = "content3" }
+ Part.FromText("content2"),
+ Part.FromText("content3")
],
};
@@ -152,7 +152,7 @@ public void ToAIContents_WithMultipleArtifacts_FlattenAllContents()
{
Id = "task1",
Artifacts = [artifact1, artifact2],
- Status = new AgentTaskStatus { State = TaskState.Completed },
+ Status = new TaskStatus { State = TaskState.Completed },
};
// Act
diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs
index b18abd4485..1f6cfa65f0 100644
--- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs
@@ -22,9 +22,9 @@ public void ToChatMessage_WithMultiplePartsMetadataAndRawRepresentation_ReturnsC
Name = "comprehensive-artifact",
Parts =
[
- new TextPart { Text = "First part" },
- new TextPart { Text = "Second part" },
- new TextPart { Text = "Third part" }
+ Part.FromText("First part"),
+ Part.FromText("Second part"),
+ Part.FromText("Third part")
],
Metadata = new Dictionary
{
@@ -66,9 +66,9 @@ public void ToAIContents_WithMultipleParts_ReturnsCorrectList()
Name = "test",
Parts =
[
- new TextPart { Text = "Part 1" },
- new TextPart { Text = "Part 2" },
- new TextPart { Text = "Part 3" }
+ Part.FromText("Part 1"),
+ Part.FromText("Part 2"),
+ Part.FromText("Part 3")
],
Metadata = null
};
diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs
index dcc45e8fce..95cb2a67d2 100644
--- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs
@@ -37,7 +37,7 @@ public async Task GetAIAgentAsync_WithValidAgentCard_ReturnsAIAgentAsync()
{
Name = "Test Agent",
Description = "A test agent for unit testing",
- Url = "http://test-endpoint/agent"
+ SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
});
// Act
@@ -60,12 +60,12 @@ public async Task RunIAgentAsync_WithUrlFromAgentCard_SendsRequestToTheUrlAsync(
// Arrange
this._handler.ResponsesToReturn.Enqueue(new AgentCard
{
- Url = "http://test-endpoint/agent"
+ SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
});
- this._handler.ResponsesToReturn.Enqueue(new AgentMessage
+ this._handler.ResponsesToReturn.Enqueue(new Message
{
- Role = MessageRole.Agent,
- Parts = [new TextPart { Text = "Response" }],
+ Role = Role.Agent,
+ Parts = [Part.FromText("Response")],
});
var agent = await this._resolver.GetAIAgentAsync(this._httpClient);
@@ -104,13 +104,18 @@ protected override async Task SendAsync(HttpRequestMessage
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}
- else if (response is AgentMessage message)
+ else if (response is Message message)
{
- var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("response-id", message);
+ var sendMessageResponse = new SendMessageResponse { Message = message };
+ var jsonRpcResponse = new JsonRpcResponse
+ {
+ Id = "response-id",
+ Result = JsonSerializer.SerializeToNode(sendMessageResponse, A2AJsonUtilities.DefaultOptions)
+ };
return new HttpResponseMessage(HttpStatusCode.OK)
{
- Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
+ Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions), Encoding.UTF8, "application/json")
};
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/ChatMessageExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/ChatMessageExtensionsTests.cs
index 8d771c679c..bb502bbea0 100644
--- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/ChatMessageExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/ChatMessageExtensionsTests.cs
@@ -32,20 +32,19 @@ public void ToA2AMessage_WithMessageContainingMultipleContents_AddsAllContentsAs
Assert.NotNull(a2aMessage.MessageId);
Assert.NotEmpty(a2aMessage.MessageId);
- Assert.Equal(MessageRole.User, a2aMessage.Role);
+ Assert.Equal(Role.User, a2aMessage.Role);
Assert.NotNull(a2aMessage.Parts);
Assert.Equal(3, a2aMessage.Parts.Count);
- var filePart = Assert.IsType(a2aMessage.Parts[0]);
- Assert.NotNull(filePart.File);
- Assert.Equal("https://example.com/report.pdf", filePart.File.Uri?.ToString());
+ Assert.Equal(PartContentCase.Url, a2aMessage.Parts[0].ContentCase);
+ Assert.Equal("https://example.com/report.pdf", a2aMessage.Parts[0].Url);
- var secondTextPart = Assert.IsType(a2aMessage.Parts[1]);
- Assert.Equal("please summarize the file content", secondTextPart.Text);
+ Assert.Equal(PartContentCase.Text, a2aMessage.Parts[1].ContentCase);
+ Assert.Equal("please summarize the file content", a2aMessage.Parts[1].Text);
- var thirdTextPart = Assert.IsType(a2aMessage.Parts[2]);
- Assert.Equal("and send it to me over email", thirdTextPart.Text);
+ Assert.Equal(PartContentCase.Text, a2aMessage.Parts[2].ContentCase);
+ Assert.Equal("and send it to me over email", a2aMessage.Parts[2].Text);
}
[Fact]
@@ -71,19 +70,18 @@ public void ToA2AMessage_WithMixedMessages_AddsAllContentsAsParts()
Assert.NotNull(a2aMessage.MessageId);
Assert.NotEmpty(a2aMessage.MessageId);
- Assert.Equal(MessageRole.User, a2aMessage.Role);
+ Assert.Equal(Role.User, a2aMessage.Role);
Assert.NotNull(a2aMessage.Parts);
Assert.Equal(3, a2aMessage.Parts.Count);
- var filePart = Assert.IsType(a2aMessage.Parts[0]);
- Assert.NotNull(filePart.File);
- Assert.Equal("https://example.com/report.pdf", filePart.File.Uri?.ToString());
+ Assert.Equal(PartContentCase.Url, a2aMessage.Parts[0].ContentCase);
+ Assert.Equal("https://example.com/report.pdf", a2aMessage.Parts[0].Url);
- var secondTextPart = Assert.IsType(a2aMessage.Parts[1]);
- Assert.Equal("please summarize the file content", secondTextPart.Text);
+ Assert.Equal(PartContentCase.Text, a2aMessage.Parts[1].ContentCase);
+ Assert.Equal("please summarize the file content", a2aMessage.Parts[1].Text);
- var thirdTextPart = Assert.IsType(a2aMessage.Parts[2]);
- Assert.Equal("and send it to me over email", thirdTextPart.Text);
+ Assert.Equal(PartContentCase.Text, a2aMessage.Parts[2].ContentCase);
+ Assert.Equal("and send it to me over email", a2aMessage.Parts[2].Text);
}
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj
index d33de0613b..97541f6a94 100644
--- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj
+++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj
@@ -1,5 +1,9 @@
+
+ $(TargetFrameworksCore)
+
+
From c1bbaeb31de53dd3aa5204588826476a53db2e55 Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Wed, 15 Apr 2026 12:08:21 +0100
Subject: [PATCH 02/12] Move A2A samples from 04-hosting to 02-agents (#5267)
Move the A2A sample projects (A2AAgent_AsFunctionTools and
A2AAgent_PollingForTaskCompletion) from samples/04-hosting/A2A/ to
samples/02-agents/A2A/ to better align with the sample directory
structure. Update solution file and samples README accordingly.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
dotnet/agent-framework-dotnet.slnx | 8 ++++----
.../A2AAgent_AsFunctionTools.csproj | 0
.../A2A/A2AAgent_AsFunctionTools/Program.cs | 0
.../A2A/A2AAgent_AsFunctionTools/README.md | 0
.../A2AAgent_PollingForTaskCompletion.csproj | 0
.../A2A/A2AAgent_PollingForTaskCompletion/Program.cs | 0
.../A2A/A2AAgent_PollingForTaskCompletion/README.md | 0
dotnet/samples/{04-hosting => 02-agents}/A2A/README.md | 0
dotnet/samples/README.md | 2 +-
9 files changed, 5 insertions(+), 5 deletions(-)
rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj (100%)
rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_AsFunctionTools/Program.cs (100%)
rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_AsFunctionTools/README.md (100%)
rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj (100%)
rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_PollingForTaskCompletion/Program.cs (100%)
rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_PollingForTaskCompletion/README.md (100%)
rename dotnet/samples/{04-hosting => 02-agents}/A2A/README.md (100%)
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 24b596509e..4cb251a543 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -284,10 +284,10 @@
-
-
-
-
+
+
+
+
diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj b/dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj
similarity index 100%
rename from dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj
rename to dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj
diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/Program.cs b/dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/Program.cs
similarity index 100%
rename from dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/Program.cs
rename to dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/Program.cs
diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/README.md b/dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/README.md
similarity index 100%
rename from dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/README.md
rename to dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/README.md
diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj b/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj
similarity index 100%
rename from dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj
rename to dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj
diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/Program.cs b/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/Program.cs
similarity index 100%
rename from dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/Program.cs
rename to dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/Program.cs
diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/README.md b/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/README.md
similarity index 100%
rename from dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/README.md
rename to dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/README.md
diff --git a/dotnet/samples/04-hosting/A2A/README.md b/dotnet/samples/02-agents/A2A/README.md
similarity index 100%
rename from dotnet/samples/04-hosting/A2A/README.md
rename to dotnet/samples/02-agents/A2A/README.md
diff --git a/dotnet/samples/README.md b/dotnet/samples/README.md
index 577b8bccbd..063e5cfc3f 100644
--- a/dotnet/samples/README.md
+++ b/dotnet/samples/README.md
@@ -16,7 +16,7 @@ were local agents. These are supported using various `AIAgent` subclasses.
| [`01-get-started/`](./01-get-started/) | Progressive tutorial: hello agent → hosting |
| [`02-agents/`](./02-agents/) | Deep-dive by concept: tools, middleware, providers, orchestrations |
| [`03-workflows/`](./03-workflows/) | Workflow patterns: sequential, concurrent, state, declarative |
-| [`04-hosting/`](./04-hosting/) | Deployment: Azure Functions, Durable Tasks, A2A |
+| [`04-hosting/`](./04-hosting/) | Deployment: Azure Functions, Durable Tasks |
| [`05-end-to-end/`](./05-end-to-end/) | Full applications, evaluation, demos |
## Getting Started
From 6ad9279f0f33705a1d5f0635413ea46e16fae18a Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Wed, 15 Apr 2026 14:45:11 +0100
Subject: [PATCH 03/12] .NET: Fix stream reconnection for A2AAgent (#5275)
* Add SSE stream reconnection support to A2AAgent
Implement automatic reconnection for SSE streams that disconnect mid-task,
using the Last-Event-ID header to resume from where the stream left off.
Changes:
- Add InvokeStreamingWithReconnectAsync method to A2AAgent with configurable
max retries and delay between attempts
- Add new log messages for reconnection events
- Add A2AAgent_StreamReconnection sample demonstrating the feature
- Update existing polling sample to use simplified SendMessageAsync API
- Add unit tests for stream reconnection logic
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* address comments
* Address PR review feedback
- Dispose SSE enumerator before GetTaskAsync fallback to release HTTP connection
- Wrap StreamWriter in using blocks with leaveOpen:true and explicit UTF-8 encoding
- Print update.Text instead of update object in stream reconnection sample
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
dotnet/agent-framework-dotnet.slnx | 1 +
.../Program.cs | 6 +-
.../A2AAgent_StreamReconnection.csproj | 23 +++
.../A2AAgent_StreamReconnection/Program.cs | 55 ++++++
.../A2A/A2AAgent_StreamReconnection/README.md | 29 +++
dotnet/samples/02-agents/A2A/README.md | 1 +
.../src/Microsoft.Agents.AI.A2A/A2AAgent.cs | 86 ++++++++-
.../A2AAgentLogMessages.cs | 13 ++
.../A2AAgentTests.cs | 177 +++++++++++++++++-
9 files changed, 379 insertions(+), 12 deletions(-)
create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj
create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/Program.cs
create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/README.md
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 4cb251a543..22460827ef 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -288,6 +288,7 @@
+
diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/Program.cs b/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/Program.cs
index e1731604a9..9410785c39 100644
--- a/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/Program.cs
+++ b/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/Program.cs
@@ -18,8 +18,12 @@
AgentSession session = await agent.CreateSessionAsync();
+// AllowBackgroundResponses must be true so the server returns immediately with a continuation token
+// instead of blocking until the task is complete.
+AgentRunOptions options = new() { AllowBackgroundResponses = true };
+
// Start the initial run with a long-running task.
-AgentResponse response = await agent.RunAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session);
+AgentResponse response = await agent.RunAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session, options: options);
// Poll until the response is complete.
while (response.ContinuationToken is { } token)
diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj
new file mode 100644
index 0000000000..e75368ea99
--- /dev/null
+++ b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj
@@ -0,0 +1,23 @@
+
+
+
+ Exe
+ net10.0
+
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/Program.cs b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/Program.cs
new file mode 100644
index 0000000000..9a4a680c62
--- /dev/null
+++ b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/Program.cs
@@ -0,0 +1,55 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens,
+// allowing recovery from stream interruptions without losing progress.
+
+using A2A;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+
+var a2aAgentHost = Environment.GetEnvironmentVariable("A2A_AGENT_HOST") ?? throw new InvalidOperationException("A2A_AGENT_HOST is not set.");
+
+// Initialize an A2ACardResolver to get an A2A agent card.
+A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost));
+
+// Get the agent card
+AgentCard agentCard = await agentCardResolver.GetAgentCardAsync();
+
+// Create an instance of the AIAgent for an existing A2A agent specified by the agent card.
+AIAgent agent = agentCard.AsAIAgent();
+
+AgentSession session = await agent.CreateSessionAsync();
+
+ResponseContinuationToken? continuationToken = null;
+
+await foreach (var update in agent.RunStreamingAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session))
+{
+ // Saving the continuation token to be able to reconnect to the same response stream later.
+ // Note: Continuation tokens are only returned for long-running tasks. If the underlying A2A agent
+ // returns a message instead of a task, the continuation token will not be initialized.
+ // A2A agents do not support stream resumption from a specific point in the stream,
+ // but only reconnection to obtain the same response stream from the beginning.
+ // So, A2A agents will return an initialized continuation token in the first update
+ // representing the beginning of the stream, and it will be null in all subsequent updates.
+ if (update.ContinuationToken is { } token)
+ {
+ continuationToken = token;
+ }
+
+ // Imitating stream interruption
+ break;
+}
+
+// Reconnect to the same response stream using the continuation token obtained from the previous run.
+// As a first update, the agent will return an update representing the current state of the response at the moment of calling
+// RunStreamingAsync with the same continuation token, followed by other updates until the end of the stream is reached.
+if (continuationToken is not null)
+{
+ await foreach (var update in agent.RunStreamingAsync(session, options: new() { ContinuationToken = continuationToken }))
+ {
+ if (!string.IsNullOrEmpty(update.Text))
+ {
+ Console.WriteLine(update.Text);
+ }
+ }
+}
diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/README.md b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/README.md
new file mode 100644
index 0000000000..ca5b0b66ad
--- /dev/null
+++ b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/README.md
@@ -0,0 +1,29 @@
+# A2A Agent Stream Reconnection
+
+This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens, allowing recovery from stream interruptions without losing progress.
+
+The sample:
+
+- Connects to an A2A agent server specified in the `A2A_AGENT_HOST` environment variable
+- Sends a request to the agent and begins streaming the response
+- Captures a continuation token from the stream for later reconnection
+- Simulates a stream interruption by breaking out of the streaming loop
+- Reconnects to the same response stream using the captured continuation token
+- Displays the response received after reconnection
+
+This pattern is useful when network interruptions or other failures may disrupt an ongoing streaming response, and you need to recover and continue processing.
+
+> **Note:** Continuation tokens are only available when the underlying A2A agent returns a task. If the agent returns a message instead, the continuation token will not be initialized and stream reconnection is not applicable.
+
+# Prerequisites
+
+Before you begin, ensure you have the following prerequisites:
+
+- .NET 10.0 SDK or later
+- An A2A agent server running and accessible via HTTP
+
+Set the following environment variable:
+
+```powershell
+$env:A2A_AGENT_HOST="http://localhost:5000" # Replace with your A2A agent server host
+```
diff --git a/dotnet/samples/02-agents/A2A/README.md b/dotnet/samples/02-agents/A2A/README.md
index 55539a8322..2f161748df 100644
--- a/dotnet/samples/02-agents/A2A/README.md
+++ b/dotnet/samples/02-agents/A2A/README.md
@@ -15,6 +15,7 @@ See the README.md for each sample for the prerequisites for that sample.
|---|---|
|[A2A Agent As Function Tools](./A2AAgent_AsFunctionTools/)|This sample demonstrates how to represent an A2A agent as a set of function tools, where each function tool corresponds to a skill of the A2A agent, and register these function tools with another AI agent so it can leverage the A2A agent's skills.|
|[A2A Agent Polling For Task Completion](./A2AAgent_PollingForTaskCompletion/)|This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A agent.|
+|[A2A Agent Stream Reconnection](./A2AAgent_StreamReconnection/)|This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens, allowing recovery from stream interruptions.|
## Running the samples from the console
diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs
index 1e3ce3a273..6e11f0a2cc 100644
--- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs
@@ -155,7 +155,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA
if (GetContinuationToken(messages, options) is { } token)
{
- streamEvents = this._a2aClient.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = token.TaskId }, cancellationToken).ConfigureAwait(false);
+ streamEvents = this.SubscribeToTaskWithFallbackAsync(token.TaskId, cancellationToken).ConfigureAwait(false);
}
else
{
@@ -248,6 +248,75 @@ private async ValueTask GetA2ASessionAsync(AgentSession? sessio
return typedSession;
}
+ ///
+ /// Subscribes to task updates, falling back to
+ /// when the task has already reached a terminal state and the server responds with
+ /// .
+ ///
+ ///
+ /// Per A2A spec §3.1.6, subscribing to a task in a terminal state (completed, failed,
+ /// canceled, or rejected) results in an UnsupportedOperationError.
+ /// See: .
+ ///
+ private async IAsyncEnumerable SubscribeToTaskWithFallbackAsync(
+ string taskId,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var subscribeStream = this._a2aClient.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = taskId }, cancellationToken);
+
+ var enumerator = subscribeStream.GetAsyncEnumerator(cancellationToken);
+
+ // yield return cannot appear inside a try block that has catch clauses,
+ // so we manually advance the enumerator within try/catch and yield outside it.
+ // The outer try/finally (no catch) is allowed to contain yield return in C#.
+ StreamResponse? fallbackResponse = null;
+ bool disposed = false;
+
+ try
+ {
+ while (true)
+ {
+ bool hasNext;
+ try
+ {
+ hasNext = await enumerator.MoveNextAsync().ConfigureAwait(false);
+ }
+ catch (A2AException ex) when (ex.ErrorCode == A2AErrorCode.UnsupportedOperation)
+ {
+ this._logger.LogA2ASubscribeToTaskFallback(this.Id, this.Name, taskId, ex.Message);
+
+ // Dispose the enumerator before the fallback call to release the HTTP/SSE connection.
+ await enumerator.DisposeAsync().ConfigureAwait(false);
+ disposed = true;
+
+ AgentTask agentTask = await this._a2aClient.GetTaskAsync(new GetTaskRequest { Id = taskId }, cancellationToken).ConfigureAwait(false);
+
+ fallbackResponse = new StreamResponse { Task = agentTask };
+ break;
+ }
+
+ if (!hasNext)
+ {
+ break;
+ }
+
+ yield return enumerator.Current;
+ }
+
+ if (fallbackResponse is not null)
+ {
+ yield return fallbackResponse;
+ }
+ }
+ finally
+ {
+ if (!disposed)
+ {
+ await enumerator.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+ }
+
private static void UpdateSession(A2AAgentSession? session, string? contextId, string? taskId = null)
{
if (session is null)
@@ -321,17 +390,17 @@ private AgentResponse ConvertToAgentResponse(Message message)
};
}
- private AgentResponse ConvertToAgentResponse(AgentTask agentTask)
+ private AgentResponse ConvertToAgentResponse(AgentTask task)
{
return new AgentResponse
{
AgentId = this.Id,
- ResponseId = agentTask.Id,
- FinishReason = MapTaskStateToFinishReason(agentTask.Status.State),
- RawRepresentation = agentTask,
- Messages = agentTask.ToChatMessages() ?? [],
- ContinuationToken = CreateContinuationToken(agentTask.Id, agentTask.Status.State),
- AdditionalProperties = agentTask.Metadata?.ToAdditionalProperties(),
+ ResponseId = task.Id,
+ FinishReason = MapTaskStateToFinishReason(task.Status.State),
+ RawRepresentation = task,
+ Messages = task.ToChatMessages() ?? [],
+ ContinuationToken = CreateContinuationToken(task.Id, task.Status.State),
+ AdditionalProperties = task.Metadata?.ToAdditionalProperties(),
};
}
@@ -360,6 +429,7 @@ private AgentResponseUpdate ConvertToAgentResponseUpdate(AgentTask task)
RawRepresentation = task,
Role = ChatRole.Assistant,
Contents = task.ToAIContents(),
+ ContinuationToken = CreateContinuationToken(task.Id, task.Status.State),
AdditionalProperties = task.Metadata?.ToAdditionalProperties(),
};
}
diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentLogMessages.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentLogMessages.cs
index 96d0ba0f9f..7d72013ba3 100644
--- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentLogMessages.cs
+++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentLogMessages.cs
@@ -34,4 +34,17 @@ public static partial void LogAgentChatClientInvokedAgent(
string methodName,
string agentId,
string? agentName);
+
+ ///
+ /// Logs falling back to GetTaskAsync after SubscribeToTaskAsync failed with UnsupportedOperation.
+ ///
+ [LoggerMessage(
+ Level = LogLevel.Warning,
+ Message = "A2AAgent {AgentId}/{AgentName} SubscribeToTask for task '{TaskId}' failed with UnsupportedOperation: {ErrorMessage}. Falling back to GetTaskAsync.")]
+ public static partial void LogA2ASubscribeToTaskFallback(
+ this ILogger logger,
+ string agentId,
+ string? agentName,
+ string taskId,
+ string errorMessage);
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs
index f6a4722699..4ae09e8f5a 100644
--- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs
@@ -673,6 +673,105 @@ public async Task RunStreamingAsync_WithContinuationToken_PassesCorrectTaskIdAsy
Assert.Equal(ExpectedTaskId, subscribeParams.Id);
}
+ [Fact]
+ public async Task RunStreamingAsync_WithContinuationToken_WhenSubscribeFailsWithUnsupportedOperation_FallsBackToGetTaskAsync()
+ {
+ // Arrange
+ const string TaskId = "completed-task-123";
+ const string ContextId = "ctx-completed";
+
+ this._handler.StreamingErrorCodeToReturn = A2AErrorCode.UnsupportedOperation;
+ this._handler.AgentTaskToReturn = new AgentTask
+ {
+ Id = TaskId,
+ ContextId = ContextId,
+ Status = new() { State = TaskState.Completed },
+ Artifacts =
+ [
+ new() { ArtifactId = "art-1", Parts = [new Part { Text = "Final result" }] }
+ ]
+ };
+
+ var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken(TaskId) };
+
+ // Act
+ var updates = new List();
+ await foreach (var update in this._agent.RunStreamingAsync([], null, options))
+ {
+ updates.Add(update);
+ }
+
+ // Assert - should yield one update from GetTaskAsync fallback
+ Assert.Single(updates);
+ var update0 = updates[0];
+ Assert.Equal(TaskId, update0.ResponseId);
+ Assert.Equal(ChatFinishReason.Stop, update0.FinishReason);
+ Assert.IsType(update0.RawRepresentation);
+ Assert.Equal(TaskId, ((AgentTask)update0.RawRepresentation!).Id);
+
+ // Assert - both SubscribeToTask and GetTask were called
+ Assert.Equal(2, this._handler.CapturedJsonRpcRequests.Count);
+ Assert.Equal("SubscribeToTask", this._handler.CapturedJsonRpcRequests[0].Method);
+ Assert.Equal("GetTask", this._handler.CapturedJsonRpcRequests[1].Method);
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_WithContinuationToken_WhenSubscribeFailsWithUnsupportedOperation_UpdatesSessionAsync()
+ {
+ // Arrange
+ const string TaskId = "completed-task-456";
+ const string ContextId = "ctx-completed-456";
+
+ this._handler.StreamingErrorCodeToReturn = A2AErrorCode.UnsupportedOperation;
+ this._handler.AgentTaskToReturn = new AgentTask
+ {
+ Id = TaskId,
+ ContextId = ContextId,
+ Status = new() { State = TaskState.Completed }
+ };
+
+ var session = await this._agent.CreateSessionAsync();
+ var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken(TaskId) };
+
+ // Act
+ await foreach (var _ in this._agent.RunStreamingAsync([], session, options))
+ {
+ // Just iterate through to trigger the logic
+ }
+
+ // Assert - session should be updated with the task state from GetTaskAsync
+ var a2aSession = (A2AAgentSession)session;
+ Assert.Equal(ContextId, a2aSession.ContextId);
+ Assert.Equal(TaskId, a2aSession.TaskId);
+ }
+
+ [Fact]
+ public async Task RunStreamingAsync_WithContinuationToken_WhenSubscribeAndGetTaskBothFail_PropagatesExceptionAsync()
+ {
+ // Arrange
+ const string TaskId = "failed-task-789";
+
+ this._handler.StreamingErrorCodeToReturn = A2AErrorCode.UnsupportedOperation;
+ this._handler.GetTaskErrorCodeToReturn = A2AErrorCode.TaskNotFound;
+
+ var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken(TaskId) };
+
+ // Act & Assert - the A2AException from GetTaskAsync should propagate to the caller
+ var exception = await Assert.ThrowsAsync(async () =>
+ {
+ await foreach (var _ in this._agent.RunStreamingAsync([], null, options))
+ {
+ }
+ });
+
+ Assert.Equal(A2AErrorCode.TaskNotFound, exception.ErrorCode);
+
+ // Assert - both SubscribeToTask and GetTask were called
+ Assert.Equal(2, this._handler.CapturedJsonRpcRequests.Count);
+ Assert.Equal("SubscribeToTask", this._handler.CapturedJsonRpcRequests[0].Method);
+ Assert.Equal("GetTask", this._handler.CapturedJsonRpcRequests[1].Method);
+ }
+
[Fact]
public async Task RunStreamingAsync_WithTaskInSessionAndMessage_AddTaskAsReferencesToMessageAsync()
{
@@ -1512,6 +1611,23 @@ internal sealed class A2AClientHttpMessageHandlerStub : HttpMessageHandler
public StreamResponse? StreamingResponseToReturn { get; set; }
+ ///
+ /// When set, streaming requests for SubscribeToTask will return a JSON-RPC error
+ /// with this error code. Used to simulate UnsupportedOperation errors.
+ ///
+ public A2AErrorCode? StreamingErrorCodeToReturn { get; set; }
+
+ ///
+ /// Error message to include when is set.
+ ///
+ public string StreamingErrorMessage { get; set; } = "Task is in a terminal state and cannot be subscribed to.";
+
+ ///
+ /// When set, GetTask requests will return a JSON-RPC error with this error code.
+ /// Used to simulate failures in the GetTaskAsync fallback path.
+ ///
+ public A2AErrorCode? GetTaskErrorCodeToReturn { get; set; }
+
protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
// Capture the request content
@@ -1538,6 +1654,25 @@ protected override async Task SendAsync(HttpRequestMessage
}
catch { /* Ignore deserialization errors for non-GetTaskRequest requests */ }
+ // Return a JSON-RPC error for GetTask when configured
+ if (this.GetTaskErrorCodeToReturn is not null && this.CapturedJsonRpcRequest?.Method == "GetTask")
+ {
+ var jsonRpcResponse = new JsonRpcResponse
+ {
+ Id = "response-id",
+ Error = new JsonRpcError
+ {
+ Code = (int)this.GetTaskErrorCodeToReturn.Value,
+ Message = "Simulated GetTask error."
+ }
+ };
+
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
+ };
+ }
+
// Return the pre-configured AgentTask response (for tasks/get)
if (this.AgentTaskToReturn is not null && this.CapturedJsonRpcRequest?.Method == "GetTask")
{
@@ -1567,6 +1702,39 @@ protected override async Task SendAsync(HttpRequestMessage
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
};
}
+ // Return a streaming JSON-RPC error (e.g., UnsupportedOperation for SubscribeToTask)
+ else if (this.StreamingErrorCodeToReturn is not null
+ && this.CapturedJsonRpcRequest?.Method is "SubscribeToTask")
+ {
+ var jsonRpcResponse = new JsonRpcResponse
+ {
+ Id = "response-id",
+ Error = new JsonRpcError
+ {
+ Code = (int)this.StreamingErrorCodeToReturn.Value,
+ Message = this.StreamingErrorMessage
+ }
+ };
+
+ var stream = new MemoryStream();
+ using (var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true))
+ {
+ await writer.WriteAsync($"data: {JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions)}\n\n");
+#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods; overload doesn't exist downlevel
+ await writer.FlushAsync();
+#pragma warning restore CA2016
+ }
+
+ stream.Position = 0;
+
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StreamContent(stream)
+ {
+ Headers = { { "Content-Type", "text/event-stream" } }
+ }
+ };
+ }
// Return the pre-configured streaming response
else if (this.StreamingResponseToReturn is not null)
{
@@ -1577,11 +1745,14 @@ protected override async Task SendAsync(HttpRequestMessage
};
var stream = new MemoryStream();
- var writer = new StreamWriter(stream);
- await writer.WriteAsync($"data: {JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions)}\n\n");
+ using (var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true))
+ {
+ await writer.WriteAsync($"data: {JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions)}\n\n");
#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods; overload doesn't exist downlevel
- await writer.FlushAsync();
+ await writer.FlushAsync();
#pragma warning restore CA2016
+ }
+
stream.Position = 0;
return new HttpResponseMessage(HttpStatusCode.OK)
From 7417eeb7e615cc8ea70a7eb0e4ec8929091a23c1 Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Wed, 15 Apr 2026 17:29:36 +0100
Subject: [PATCH 04/12] .NET: Use IA2AClientFactory to create A2AClient (#5277)
* Refactor A2A extensions to use IA2AClientFactory and add ProtocolSelection sample
- Update A2AAgentCardExtensions to accept IA2AClientFactory instead of A2AClientOptions
- Update A2ACardResolverExtensions to accept IA2AClientFactory
- Update A2AClientExtensions to accept IA2AClientFactory
- Update A2AAgent to use IA2AClientFactory for client creation
- Add A2AAgent_ProtocolSelection sample demonstrating protocol selection
- Add comprehensive unit tests for all changes
- Update README files with new sample reference
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Reorder params: options before loggerFactory in A2A extensions
Move A2AClientOptions parameter before ILoggerFactory in AsAIAgent
and GetAIAgentAsync extension methods to follow the repo convention
of keeping LoggerFactory and CancellationToken as the last parameters.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
dotnet/agent-framework-dotnet.slnx | 1 +
.../A2AAgent_ProtocolSelection.csproj | 19 +++++
.../A2A/A2AAgent_ProtocolSelection/Program.cs | 36 +++++++++
.../A2A/A2AAgent_ProtocolSelection/README.md | 27 +++++++
dotnet/samples/02-agents/A2A/README.md | 3 +-
dotnet/samples/02-agents/README.md | 1 +
.../src/Microsoft.Agents.AI.A2A/A2AAgent.cs | 6 +-
.../Extensions/A2AAgentCardExtensions.cs | 15 ++--
.../Extensions/A2ACardResolverExtensions.cs | 8 +-
.../Extensions/A2AClientExtensions.cs | 6 +-
.../A2AAgentTests.cs | 41 ++++++++--
.../Extensions/A2AAgentCardExtensionsTests.cs | 78 +++++++++++++++++--
.../A2ACardResolverExtensionsTests.cs | 37 ++++++++-
.../Extensions/A2AClientExtensionsTests.cs | 36 +++++++++
14 files changed, 282 insertions(+), 32 deletions(-)
create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj
create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/Program.cs
create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/README.md
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 22460827ef..127b065220 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -289,6 +289,7 @@
+
diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj
new file mode 100644
index 0000000000..d21ac952b3
--- /dev/null
+++ b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj
@@ -0,0 +1,19 @@
+
+
+
+ Exe
+ net10.0
+
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/Program.cs b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/Program.cs
new file mode 100644
index 0000000000..4d1612ee36
--- /dev/null
+++ b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/Program.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample demonstrates how to select the A2A protocol binding (HTTP+JSON vs JSON-RPC) when
+// creating an AIAgent from an A2A agent card using A2AClientOptions.PreferredBindings.
+
+using A2A;
+using Microsoft.Agents.AI;
+
+var a2aAgentHost = Environment.GetEnvironmentVariable("A2A_AGENT_HOST") ?? throw new InvalidOperationException("A2A_AGENT_HOST is not set.");
+
+// Initialize an A2ACardResolver to get an A2A agent card.
+A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost));
+
+// Get the agent card
+AgentCard agentCard = await agentCardResolver.GetAgentCardAsync();
+
+// Use A2AClientOptions to explicitly select the HTTP+JSON protocol binding.
+// This tells the A2A client factory to prefer the HTTP+JSON interface when the agent card
+// advertises multiple supported interfaces.
+A2AClientOptions options = new()
+{
+ PreferredBindings = [ProtocolBindingNames.HttpJson]
+};
+
+// To prefer JSON-RPC instead, use:
+// A2AClientOptions options = new()
+// {
+// PreferredBindings = [ProtocolBindingNames.JsonRpc]
+// };
+
+// Create an instance of the AIAgent for an existing A2A agent, using the specified protocol binding.
+AIAgent agent = agentCard.AsAIAgent(options: options);
+
+// Invoke the agent and output the text result.
+AgentResponse response = await agent.RunAsync("Tell me a joke about a pirate.");
+Console.WriteLine(response);
diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/README.md b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/README.md
new file mode 100644
index 0000000000..b50a76240c
--- /dev/null
+++ b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/README.md
@@ -0,0 +1,27 @@
+# A2A Agent Protocol Selection
+
+This sample demonstrates how to select the A2A protocol binding when creating an `AIAgent` from an A2A agent card.
+
+A2A agents can expose multiple interfaces with different protocol bindings (e.g., HTTP+JSON, JSON-RPC). By default, `AsAIAgent()` prefers HTTP+JSON with JSON-RPC as a fallback. This sample shows how to use `A2AClientOptions.PreferredBindings` to explicitly control which protocol binding is used.
+
+The sample:
+
+- Connects to an A2A agent server specified in the `A2A_AGENT_HOST` environment variable
+- Configures `A2AClientOptions` to prefer the HTTP+JSON protocol binding
+- Creates an `AIAgent` from the resolved agent card using the specified binding
+- Sends a message to the agent and displays the response
+
+## Prerequisites
+
+Before you begin, ensure you have the following prerequisites:
+
+- .NET 10.0 SDK or later
+- An A2A agent server running and accessible via HTTP
+
+**Note**: These samples need to be run against a valid A2A server. If no A2A server is available, they can be run against the echo-agent that can be spun up locally by following the guidelines at: https://github.com/a2aproject/a2a-dotnet/blob/main/samples/AgentServer/README.md
+
+Set the following environment variable:
+
+```powershell
+$env:A2A_AGENT_HOST="http://localhost:5000" # Replace with your A2A agent server host
+```
diff --git a/dotnet/samples/02-agents/A2A/README.md b/dotnet/samples/02-agents/A2A/README.md
index 2f161748df..28f2c0a910 100644
--- a/dotnet/samples/02-agents/A2A/README.md
+++ b/dotnet/samples/02-agents/A2A/README.md
@@ -3,7 +3,7 @@
These samples demonstrate how to work with Agent-to-Agent (A2A) specific features in the Agent Framework.
For other samples that demonstrate how to use AIAgent instances,
-see the [Getting Started With Agents](../../02-agents/Agents/README.md) samples.
+see the [Getting Started With Agents](../Agents/README.md) samples.
## Prerequisites
@@ -16,6 +16,7 @@ See the README.md for each sample for the prerequisites for that sample.
|[A2A Agent As Function Tools](./A2AAgent_AsFunctionTools/)|This sample demonstrates how to represent an A2A agent as a set of function tools, where each function tool corresponds to a skill of the A2A agent, and register these function tools with another AI agent so it can leverage the A2A agent's skills.|
|[A2A Agent Polling For Task Completion](./A2AAgent_PollingForTaskCompletion/)|This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A agent.|
|[A2A Agent Stream Reconnection](./A2AAgent_StreamReconnection/)|This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens, allowing recovery from stream interruptions.|
+|[A2A Agent Protocol Selection](./A2AAgent_ProtocolSelection/)|This sample demonstrates how to select the A2A protocol binding (HTTP+JSON vs JSON-RPC) when creating an AIAgent from an A2A agent card using A2AClientOptions.|
## Running the samples from the console
diff --git a/dotnet/samples/02-agents/README.md b/dotnet/samples/02-agents/README.md
index 5ff0db416d..69f649c9b4 100644
--- a/dotnet/samples/02-agents/README.md
+++ b/dotnet/samples/02-agents/README.md
@@ -19,3 +19,4 @@ The getting started samples demonstrate the fundamental concepts and functionali
| [Declarative Agents](./DeclarativeAgents) | Loading and executing AI agents from YAML configuration files |
| [AG-UI](./AGUI/README.md) | Getting started with AG-UI (Agent UI Protocol) servers and clients |
| [Dev UI](./DevUI/README.md) | Interactive web interface for testing and debugging AI agents during development |
+| [A2A Agents](./A2A/README.md) | Working with Agent-to-Agent (A2A) specific features |
diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs
index 6e11f0a2cc..9fd2e8ff47 100644
--- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs
@@ -27,7 +27,7 @@ public sealed class A2AAgent : AIAgent
{
private static readonly AIAgentMetadata s_agentMetadata = new("a2a");
- private readonly A2AClient _a2aClient;
+ private readonly IA2AClient _a2aClient;
private readonly string? _id;
private readonly string? _name;
private readonly string? _description;
@@ -41,7 +41,7 @@ public sealed class A2AAgent : AIAgent
/// The the name of the agent.
/// The description of the agent.
/// Optional logger factory to use for logging.
- public A2AAgent(A2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null)
+ public A2AAgent(IA2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null)
{
_ = Throw.IfNull(a2aClient);
@@ -224,7 +224,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA
///
public override object? GetService(Type serviceType, object? serviceKey = null)
=> base.GetService(serviceType, serviceKey)
- ?? (serviceType == typeof(A2AClient) ? this._a2aClient
+ ?? (serviceType == typeof(IA2AClient) ? this._a2aClient
: serviceType == typeof(AIAgentMetadata) ? s_agentMetadata
: null);
diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs
index 897349f666..086505b2bc 100644
--- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs
@@ -1,7 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
-using System;
-using System.Linq;
using System.Net.Http;
using Microsoft.Agents.AI;
using Microsoft.Extensions.Logging;
@@ -26,16 +24,15 @@ public static class A2AAgentCardExtensions
///
/// The to use for the agent creation.
/// The to use for HTTP requests.
+ ///
+ /// Optional controlling protocol binding preference.
+ /// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback.
+ ///
/// The logger factory for enabling logging within the agent.
/// An instance backed by the A2A agent.
- public static AIAgent AsAIAgent(this AgentCard card, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null)
+ public static AIAgent AsAIAgent(this AgentCard card, HttpClient? httpClient = null, A2AClientOptions? options = null, ILoggerFactory? loggerFactory = null)
{
- // TODO: Refactor to support interface selection from card.SupportedInterfaces.
- var url = card.SupportedInterfaces?.FirstOrDefault()?.Url
- ?? throw new InvalidOperationException("The AgentCard does not have any SupportedInterfaces with a URL.");
-
- // Create the A2A client using the agent URL from the card.
- var a2aClient = new A2AClient(new Uri(url), httpClient);
+ var a2aClient = A2AClientFactory.Create(card, httpClient, options);
return a2aClient.AsAIAgent(name: card.Name, description: card.Description, loggerFactory: loggerFactory);
}
diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs
index 6a32822fea..49b6de1102 100644
--- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs
@@ -34,14 +34,18 @@ public static class A2ACardResolverExtensions
///
/// The to use for the agent creation.
/// The to use for HTTP requests.
+ ///
+ /// Optional controlling protocol binding preference.
+ /// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback.
+ ///
/// The logger factory for enabling logging within the agent.
/// The to monitor for cancellation requests. The default is .
/// An instance backed by the A2A agent.
- public static async Task GetAIAgentAsync(this A2ACardResolver resolver, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default)
+ public static async Task GetAIAgentAsync(this A2ACardResolver resolver, HttpClient? httpClient = null, A2AClientOptions? options = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default)
{
// Obtain the agent card from the resolver.
var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false);
- return agentCard.AsAIAgent(httpClient, loggerFactory);
+ return agentCard.AsAIAgent(httpClient, options, loggerFactory);
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs
index cd93ca0bac..c7386309d3 100644
--- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs
@@ -7,7 +7,7 @@
namespace A2A;
///
-/// Provides extension methods for
+/// Provides extension methods for
/// to simplify the creation of A2A agents.
///
///
@@ -29,12 +29,12 @@ public static class A2AClientExtensions
/// Direct Configuration / Private Discovery
/// discovery mechanism.
///
- /// The to use for the agent.
+ /// The to use for the agent.
/// The unique identifier for the agent.
/// The the name of the agent.
/// The description of the agent.
/// Optional logger factory for enabling logging within the agent.
/// An instance backed by the A2A agent.
- public static AIAgent AsAIAgent(this A2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) =>
+ public static AIAgent AsAIAgent(this IA2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) =>
new A2AAgent(client, id, name, description, loggerFactory);
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs
index 4ae09e8f5a..320b5b030c 100644
--- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs
@@ -55,6 +55,21 @@ public void Constructor_WithNullA2AClient_ThrowsArgumentNullException() =>
// Act & Assert
Assert.Throws(() => new A2AAgent(null!));
+ [Fact]
+ public void Constructor_WithIA2AClient_InitializesCorrectly()
+ {
+ // Arrange
+ IA2AClient ia2aClient = this._a2aClient;
+
+ // Act
+ var agent = new A2AAgent(ia2aClient, "ia2a-id", "IA2A Agent", "An agent from IA2AClient");
+
+ // Assert
+ Assert.Equal("ia2a-id", agent.Id);
+ Assert.Equal("IA2A Agent", agent.Name);
+ Assert.Equal("An agent from IA2AClient", agent.Description);
+ }
+
[Fact]
public void Constructor_WithDefaultParameters_UsesBaseProperties()
{
@@ -1371,19 +1386,33 @@ public async Task RunStreamingAsync_WithInvalidSessionType_ThrowsInvalidOperatio
#region GetService Method Tests
///
- /// Verify that GetService returns A2AClient when requested.
+ /// Verify that GetService returns IA2AClient when requested.
///
[Fact]
- public void GetService_RequestingA2AClient_ReturnsA2AClient()
+ public void GetService_RequestingIA2AClient_ReturnsA2AClient()
{
// Arrange & Act
- var result = this._agent.GetService(typeof(A2AClient));
+ var result = this._agent.GetService(typeof(IA2AClient));
// Assert
Assert.NotNull(result);
Assert.Same(this._a2aClient, result);
}
+ ///
+ /// Verify that GetService returns null when requesting the concrete A2AClient type
+ /// since the agent now exposes IA2AClient instead.
+ ///
+ [Fact]
+ public void GetService_RequestingConcreteA2AClient_ReturnsNull()
+ {
+ // Arrange & Act
+ var result = this._agent.GetService(typeof(A2AClient));
+
+ // Assert
+ Assert.Null(result);
+ }
+
///
/// Verify that GetService returns AIAgentMetadata when requested.
///
@@ -1458,10 +1487,10 @@ public void GetService_RequestingAIAgentType_ReturnsBaseImplementation()
/// Verify that GetService calls base.GetService() first but continues to derived logic when base returns null.
///
[Fact]
- public void GetService_RequestingA2AClientWithServiceKey_CallsBaseFirstThenDerivedLogic()
+ public void GetService_RequestingIA2AClientWithServiceKey_CallsBaseFirstThenDerivedLogic()
{
- // Arrange & Act - Request A2AClient with a service key (base.GetService will return null due to serviceKey)
- var result = this._agent.GetService(typeof(A2AClient), "some-key");
+ // Arrange & Act - Request IA2AClient with a service key (base.GetService will return null due to serviceKey)
+ var result = this._agent.GetService(typeof(IA2AClient), "some-key");
// Assert
Assert.NotNull(result);
diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs
index b45c381bd2..603376b396 100644
--- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs
@@ -57,7 +57,7 @@ public async Task RunIAgentAsync_SendsRequestToTheUrlSpecifiedInAgentCardAsync()
Parts = [Part.FromText("Response")],
});
- var agent = this._agentCard.AsAIAgent(httpClient);
+ var agent = this._agentCard.AsAIAgent(httpClient: httpClient);
// Act
await agent.RunAsync("Test input");
@@ -68,7 +68,7 @@ public async Task RunIAgentAsync_SendsRequestToTheUrlSpecifiedInAgentCardAsync()
}
[Fact]
- public async Task AsAIAgent_WithMultipleInterfaces_UsesFirstInterfaceAsync()
+ public async Task AsAIAgent_WithPreferredBindings_UsesMatchingInterfaceAsync()
{
// Arrange
var card = new AgentCard
@@ -77,9 +77,8 @@ public async Task AsAIAgent_WithMultipleInterfaces_UsesFirstInterfaceAsync()
Description = "An agent with multiple interfaces",
SupportedInterfaces =
[
- new AgentInterface { Url = "http://first/agent" },
- new AgentInterface { Url = "http://second/agent", ProtocolBinding = "grpc" },
- new AgentInterface { Url = "http://third/agent", ProtocolBinding = "http" },
+ new AgentInterface { Url = "http://first/agent", ProtocolBinding = ProtocolBindingNames.HttpJson },
+ new AgentInterface { Url = "http://second/agent", ProtocolBinding = ProtocolBindingNames.JsonRpc },
]
};
@@ -92,14 +91,79 @@ public async Task AsAIAgent_WithMultipleInterfaces_UsesFirstInterfaceAsync()
Parts = [Part.FromText("Response")],
});
- var agent = card.AsAIAgent(httpClient);
+ var options = new A2AClientOptions
+ {
+ PreferredBindings = [ProtocolBindingNames.JsonRpc]
+ };
+
+ var agent = card.AsAIAgent(httpClient, options: options);
// Act
await agent.RunAsync("Test input");
// Assert
Assert.Single(handler.CapturedUris);
- Assert.Equal(new Uri("http://first/agent"), handler.CapturedUris[0]);
+ Assert.Equal(new Uri("http://second/agent"), handler.CapturedUris[0]);
+ }
+
+ [Fact]
+ public void AsAIAgent_WithNullOptions_UsesDefaultBindingPreference()
+ {
+ // Arrange
+ var card = new AgentCard
+ {
+ Name = "Default Options Agent",
+ Description = "Tests default A2AClientOptions behavior",
+ SupportedInterfaces =
+ [
+ new AgentInterface { Url = "http://default/agent" },
+ ]
+ };
+
+ // Act - null options should use defaults (HTTP+JSON first, JSON-RPC as fallback)
+ var agent = card.AsAIAgent(options: null);
+
+ // Assert
+ Assert.NotNull(agent);
+ Assert.IsType(agent);
+ Assert.Equal("Default Options Agent", agent.Name);
+ }
+
+ [Fact]
+ public void AsAIAgent_WithNoMatchingBinding_ThrowsException()
+ {
+ // Arrange
+ var card = new AgentCard
+ {
+ Name = "Unmatched Binding Agent",
+ Description = "Agent with unsupported binding only",
+ SupportedInterfaces =
+ [
+ new AgentInterface { Url = "http://grpc/agent", ProtocolBinding = "GRPC" },
+ ]
+ };
+
+ var options = new A2AClientOptions
+ {
+ PreferredBindings = [ProtocolBindingNames.JsonRpc]
+ };
+
+ // Act & Assert - factory should throw when no matching binding exists
+ Assert.ThrowsAny(() => card.AsAIAgent(options: options));
+ }
+
+ [Fact]
+ public void AsAIAgent_WithNoSupportedInterfaces_ThrowsException()
+ {
+ // Arrange
+ var card = new AgentCard
+ {
+ Name = "No Interfaces Agent",
+ Description = "Agent with no supported interfaces",
+ };
+
+ // Act & Assert
+ Assert.ThrowsAny(() => card.AsAIAgent());
}
internal sealed class HttpMessageHandlerStub : HttpMessageHandler
diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs
index 95cb2a67d2..8a664b7fc9 100644
--- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs
@@ -68,7 +68,7 @@ public async Task RunIAgentAsync_WithUrlFromAgentCard_SendsRequestToTheUrlAsync(
Parts = [Part.FromText("Response")],
});
- var agent = await this._resolver.GetAIAgentAsync(this._httpClient);
+ var agent = await this._resolver.GetAIAgentAsync(httpClient: this._httpClient);
// Act
await agent.RunAsync("Test input");
@@ -78,6 +78,41 @@ public async Task RunIAgentAsync_WithUrlFromAgentCard_SendsRequestToTheUrlAsync(
Assert.Equal(new Uri("http://test-endpoint/agent"), this._handler.CapturedUris[1]);
}
+ [Fact]
+ public async Task GetAIAgentAsync_WithOptions_PassesOptionsToFactoryAsync()
+ {
+ // Arrange
+ this._handler.ResponsesToReturn.Enqueue(new AgentCard
+ {
+ Name = "Options Agent",
+ Description = "Agent with multiple interfaces",
+ SupportedInterfaces =
+ [
+ new AgentInterface { Url = "http://httpjson/agent", ProtocolBinding = ProtocolBindingNames.HttpJson },
+ new AgentInterface { Url = "http://jsonrpc/agent", ProtocolBinding = ProtocolBindingNames.JsonRpc },
+ ]
+ });
+ this._handler.ResponsesToReturn.Enqueue(new Message
+ {
+ Role = Role.Agent,
+ Parts = [Part.FromText("Response")],
+ });
+
+ var options = new A2AClientOptions
+ {
+ PreferredBindings = [ProtocolBindingNames.JsonRpc]
+ };
+
+ var agent = await this._resolver.GetAIAgentAsync(httpClient: this._httpClient, options: options);
+
+ // Act
+ await agent.RunAsync("Test input");
+
+ // Assert
+ Assert.Equal(2, this._handler.CapturedUris.Count);
+ Assert.Equal(new Uri("http://jsonrpc/agent"), this._handler.CapturedUris[1]);
+ }
+
public void Dispose()
{
this._handler.Dispose();
diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AClientExtensionsTests.cs
index 9ad4d982a9..80b5107bf1 100644
--- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AClientExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AClientExtensionsTests.cs
@@ -30,4 +30,40 @@ public void GetAIAgent_WithAllParameters_ReturnsA2AAgentWithSpecifiedProperties(
Assert.Equal(TestName, agent.Name);
Assert.Equal(TestDescription, agent.Description);
}
+
+ [Fact]
+ public void GetAIAgent_WithIA2AClient_ReturnsA2AAgentWithSpecifiedProperties()
+ {
+ // Arrange - use IA2AClient reference type to verify the extension method works with the interface
+ IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint"));
+
+ const string TestId = "ia2a-agent-id";
+ const string TestName = "IA2A Agent";
+ const string TestDescription = "Agent created from IA2AClient";
+
+ // Act
+ var agent = a2aClient.AsAIAgent(TestId, TestName, TestDescription);
+
+ // Assert
+ Assert.NotNull(agent);
+ Assert.IsType(agent);
+ Assert.Equal(TestId, agent.Id);
+ Assert.Equal(TestName, agent.Name);
+ Assert.Equal(TestDescription, agent.Description);
+ }
+
+ [Fact]
+ public void GetAIAgent_WithIA2AClient_ExposesClientViaGetService()
+ {
+ // Arrange
+ IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint"));
+
+ // Act
+ var agent = a2aClient.AsAIAgent();
+
+ // Assert
+ var service = agent.GetService(typeof(IA2AClient));
+ Assert.NotNull(service);
+ Assert.Same(a2aClient, service);
+ }
}
From 275363d15efff5dd5d22f70cd8645330728746ef Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Tue, 21 Apr 2026 15:31:56 +0100
Subject: [PATCH 05/12] .NET: Migrate A2A hosting to A2A SDK v1 (#5363)
* .NET: Migrate A2A hosting to A2A SDK v1
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* remove unused agent card
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../A2AServer/HostAgentFactory.cs | 15 +-
.../A2AClientServer/A2AServer/Program.cs | 6 +-
.../AgentWebChat.AgentHost/Program.cs | 11 +-
.../A2AEndpointRouteBuilderExtensions.cs | 188 +++
.../EndpointRouteBuilderExtensions.cs | 385 -------
.../A2AAgentHandler.cs | 189 +++
.../A2AHostingOptions.cs | 40 +
.../A2AProtocolBinding.cs | 29 +
.../A2ARunDecisionContext.cs | 8 +-
.../AIAgentExtensions.cs | 283 +----
.../AgentRunMode.cs | 4 +-
.../Converters/MessageConverter.cs | 12 +-
...A2AEndpointRouteBuilderExtensionsTests.cs} | 428 ++++---
.../A2AIntegrationTests.cs | 89 --
.../AIAgentExtensionsTests.cs | 1019 +++++++++--------
.../AgentRunModeTests.cs | 148 +++
.../Converters/MessageConverterTests.cs | 34 +-
17 files changed, 1456 insertions(+), 1432 deletions(-)
create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs
delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AHostingOptions.cs
create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AProtocolBinding.cs
rename dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/{EndpointRouteA2ABuilderExtensionsTests.cs => A2AEndpointRouteBuilderExtensionsTests.cs} (55%)
delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AIntegrationTests.cs
create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs
diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs
index 28e0c5fe5e..fd8d838428 100644
--- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs
+++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs
@@ -153,12 +153,23 @@ private static AgentCard GetLogisticsAgentCard(string[] agentUrls)
private static List CreateAgentInterfaces(string[] agentUrls)
{
- return agentUrls.Select(url => new AgentInterface
+ List agentInterfaces = [];
+
+ agentInterfaces.AddRange(agentUrls.Select(url => new AgentInterface
{
Url = url,
ProtocolBinding = "JSONRPC",
ProtocolVersion = "1.0",
- }).ToList();
+ }));
+
+ agentInterfaces.AddRange(agentUrls.Select(url => new AgentInterface
+ {
+ Url = url,
+ ProtocolBinding = "HTTP+JSON",
+ ProtocolVersion = "1.0",
+ }));
+
+ return agentInterfaces;
}
#endregion
}
diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs
index 773d57e9fc..854a48535d 100644
--- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs
+++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs
@@ -3,6 +3,7 @@
using A2A.AspNetCore;
using A2AServer;
using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Hosting.A2A;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
@@ -107,7 +108,8 @@ You specialize in handling queries related to logistics.
app.MapA2A(
hostA2AAgent,
- path: "/",
- agentCard: hostA2AAgentCard);
+ path: "/", protocolBindings: A2AProtocolBinding.JsonRpc | A2AProtocolBinding.HttpJson);
+
+app.MapWellKnownAgentCard(hostA2AAgentCard);
await app.RunAsync();
diff --git a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs
index 15e7cbbd86..1c5a1ba605 100644
--- a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs
+++ b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
+using A2A;
using A2A.AspNetCore;
using AgentWebChat.AgentHost;
using AgentWebChat.AgentHost.Custom;
@@ -156,15 +157,7 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te
// attach a2a with simple message communication
app.MapA2A(pirateAgentBuilder, path: "/a2a/pirate");
-app.MapA2A(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves", agentCard: new()
-{
- Name = "Knights and Knaves",
- Description = "An agent that helps you solve the knights and knaves puzzle.",
- Version = "1.0",
-
- // Url can be not set, and SDK will help assign it.
- // Url = "http://localhost:5390/a2a/knights-and-knaves"
-});
+app.MapA2A(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves");
app.MapDevUI();
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs
new file mode 100644
index 0000000000..fd1ad6db20
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs
@@ -0,0 +1,188 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+using A2A;
+using A2A.AspNetCore;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Hosting;
+using Microsoft.Agents.AI.Hosting.A2A;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.AspNetCore.Builder;
+
+///
+/// Provides extension methods for configuring A2A endpoints for AI agents.
+///
+[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
+public static class A2AEndpointRouteBuilderExtensions
+{
+ ///
+ /// Maps A2A endpoints for the specified agent to the given path.
+ ///
+ /// The to add the A2A endpoints to.
+ /// The configuration builder for .
+ /// The route path prefix for A2A endpoints.
+ /// The A2A protocol binding(s) to expose. When , defaults to .
+ /// The agent run mode that controls how the agent responds to A2A requests. When , defaults to .
+ /// An for further endpoint configuration.
+ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, A2AProtocolBinding? protocolBindings, AgentRunMode? agentRunMode = null)
+ {
+ ArgumentNullException.ThrowIfNull(agentBuilder);
+
+ return endpoints.MapA2A(agentBuilder.Name, path, protocolBindings, agentRunMode);
+ }
+
+ ///
+ /// Maps A2A endpoints for the specified agent to the given path.
+ ///
+ /// The to add the A2A endpoints to.
+ /// The configuration builder for .
+ /// The route path prefix for A2A endpoints.
+ /// An optional callback to configure .
+ /// An for further endpoint configuration.
+ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, Action? configureOptions = null)
+ {
+ ArgumentNullException.ThrowIfNull(agentBuilder);
+
+ return endpoints.MapA2A(agentBuilder.Name, path, configureOptions);
+ }
+
+ ///
+ /// Maps A2A endpoints for the agent with the specified name to the given path.
+ ///
+ /// The to add the A2A endpoints to.
+ /// The name of the agent to use for A2A protocol integration.
+ /// The route path prefix for A2A endpoints.
+ /// The A2A protocol binding(s) to expose. When , defaults to .
+ /// The agent run mode that controls how the agent responds to A2A requests. When , defaults to .
+ /// An for further endpoint configuration.
+ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, A2AProtocolBinding? protocolBindings, AgentRunMode? agentRunMode = null)
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+ ArgumentException.ThrowIfNullOrEmpty(agentName);
+
+ var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName);
+
+ return endpoints.MapA2A(agent, path, protocolBindings, agentRunMode);
+ }
+
+ ///
+ /// Maps A2A endpoints for the agent with the specified name to the given path.
+ ///
+ /// The to add the A2A endpoints to.
+ /// The name of the agent to use for A2A protocol integration.
+ /// The route path prefix for A2A endpoints.
+ /// An optional callback to configure .
+ /// An for further endpoint configuration.
+ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, Action? configureOptions = null)
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+ ArgumentException.ThrowIfNullOrEmpty(agentName);
+
+ var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName);
+
+ return endpoints.MapA2A(agent, path, configureOptions);
+ }
+
+ ///
+ /// Maps A2A endpoints for the specified agent to the given path.
+ ///
+ /// The to add the A2A endpoints to.
+ /// The agent to use for A2A protocol integration.
+ /// The route path prefix for A2A endpoints.
+ /// The A2A protocol binding(s) to expose. When , defaults to .
+ /// The agent run mode that controls how the agent responds to A2A requests. When , defaults to .
+ /// An for further endpoint configuration.
+ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, A2AProtocolBinding? protocolBindings, AgentRunMode? agentRunMode = null)
+ {
+ Action? configureOptions = null;
+
+ if (protocolBindings is not null || agentRunMode is not null)
+ {
+ configureOptions = options =>
+ {
+ options.ProtocolBindings = protocolBindings;
+ options.AgentRunMode = agentRunMode;
+ };
+ }
+
+ return endpoints.MapA2A(agent, path, configureOptions);
+ }
+
+ ///
+ /// Maps A2A endpoints for the specified agent to the given path.
+ ///
+ /// The to add the A2A endpoints to.
+ /// The agent to use for A2A protocol integration.
+ /// The route path prefix for A2A endpoints.
+ /// An optional callback to configure .
+ /// An for further endpoint configuration.
+ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action? configureOptions = null)
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+ ArgumentNullException.ThrowIfNull(agent);
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name));
+
+ A2AHostingOptions? options = null;
+ if (configureOptions is not null)
+ {
+ options = new A2AHostingOptions();
+ configureOptions(options);
+ }
+
+ var a2aServer = CreateA2AServer(endpoints, agent, options);
+
+ return MapA2AEndpoints(endpoints, a2aServer, path, options?.ProtocolBindings);
+ }
+
+ private static A2AServer CreateA2AServer(IEndpointRouteBuilder endpoints, AIAgent agent, A2AHostingOptions? options)
+ {
+ var agentHandler = endpoints.ServiceProvider.GetKeyedService(agent.Name);
+ if (agentHandler is null)
+ {
+ var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(agent.Name);
+ agentHandler = agent.MapA2A(agentSessionStore: agentSessionStore, runMode: options?.AgentRunMode);
+ }
+
+ var loggerFactory = endpoints.ServiceProvider.GetService() ?? NullLoggerFactory.Instance;
+ var taskStore = endpoints.ServiceProvider.GetKeyedService(agent.Name) ?? new InMemoryTaskStore();
+
+ return new A2AServer(
+ agentHandler,
+ taskStore,
+ new ChannelEventNotifier(),
+ loggerFactory.CreateLogger(),
+ options?.ServerOptions);
+ }
+
+ private static IEndpointConventionBuilder MapA2AEndpoints(IEndpointRouteBuilder endpoints, A2AServer a2aServer, string path, A2AProtocolBinding? protocolBindings)
+ {
+ protocolBindings ??= A2AProtocolBinding.HttpJson;
+
+ IEndpointConventionBuilder? result = null;
+
+ if (protocolBindings.Value.HasFlag(A2AProtocolBinding.JsonRpc))
+ {
+ result = endpoints.MapA2A(a2aServer, path);
+ }
+
+ if (protocolBindings.Value.HasFlag(A2AProtocolBinding.HttpJson))
+ {
+ // TODO: The stub AgentCard is temporary and will be removed once the A2A SDK either removes the
+ // agentCard parameter of MapHttpA2A or makes it optional. MapHttpA2A exposes the agent card via a
+ // GET {path}/card endpoint that is not part of the A2A spec, so it is not expected to be consumed
+ // by any agent - returning a stub agent card here is safe.
+ var stubAgentCard = new AgentCard { Name = "A2A Agent" };
+
+ result = endpoints.MapHttpA2A(a2aServer, stubAgentCard, path);
+ }
+
+ return result ?? throw new InvalidOperationException("At least one A2A protocol binding must be specified.");
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs
deleted file mode 100644
index af3ff093ee..0000000000
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs
+++ /dev/null
@@ -1,385 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System;
-using System.Diagnostics.CodeAnalysis;
-using A2A;
-using A2A.AspNetCore;
-using Microsoft.Agents.AI;
-using Microsoft.Agents.AI.Hosting;
-using Microsoft.Agents.AI.Hosting.A2A;
-using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Routing;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Microsoft.Shared.DiagnosticIds;
-
-namespace Microsoft.AspNetCore.Builder;
-
-///
-/// Provides extension methods for configuring A2A (Agent2Agent) communication in a host application builder.
-///
-[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
-public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
-{
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The configuration builder for .
- /// The route group to use for A2A endpoints.
- /// Configured for A2A integration.
- ///
- /// This method can be used to access A2A agents that support the
- /// Curated Registries (Catalog-Based Discovery)
- /// discovery mechanism.
- ///
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path)
- => endpoints.MapA2A(agentBuilder, path, _ => { });
-
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The configuration builder for .
- /// The route group to use for A2A endpoints.
- /// Controls the response behavior of the agent run.
- /// Configured for A2A integration.
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentRunMode agentRunMode)
- {
- ArgumentNullException.ThrowIfNull(agentBuilder);
- return endpoints.MapA2A(agentBuilder.Name, path, agentRunMode);
- }
-
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The name of the agent to use for A2A protocol integration.
- /// The route group to use for A2A endpoints.
- /// Configured for A2A integration.
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path)
- => endpoints.MapA2A(agentName, path, _ => { });
-
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The name of the agent to use for A2A protocol integration.
- /// The route group to use for A2A endpoints.
- /// Controls the response behavior of the agent run.
- /// Configured for A2A integration.
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentRunMode agentRunMode)
- {
- ArgumentNullException.ThrowIfNull(endpoints);
- var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName);
- return endpoints.MapA2A(agent, path, _ => { }, agentRunMode);
- }
-
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The configuration builder for .
- /// The route group to use for A2A endpoints.
- /// The callback to configure .
- /// Configured for A2A integration.
- ///
- /// This method can be used to access A2A agents that support the
- /// Curated Registries (Catalog-Based Discovery)
- /// discovery mechanism.
- ///
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, Action configureTaskManager)
- {
- ArgumentNullException.ThrowIfNull(agentBuilder);
- return endpoints.MapA2A(agentBuilder.Name, path, configureTaskManager);
- }
-
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The name of the agent to use for A2A protocol integration.
- /// The route group to use for A2A endpoints.
- /// The callback to configure .
- /// Configured for A2A integration.
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, Action configureTaskManager)
- {
- ArgumentNullException.ThrowIfNull(endpoints);
- var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName);
- return endpoints.MapA2A(agent, path, configureTaskManager);
- }
-
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The configuration builder for .
- /// The route group to use for A2A endpoints.
- /// Agent card info to return on query.
- /// Configured for A2A integration.
- ///
- /// This method can be used to access A2A agents that support the
- /// Curated Registries (Catalog-Based Discovery)
- /// discovery mechanism.
- ///
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard)
- => endpoints.MapA2A(agentBuilder, path, agentCard, _ => { });
-
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The name of the agent to use for A2A protocol integration.
- /// The route group to use for A2A endpoints.
- /// Agent card info to return on query.
- /// Configured for A2A integration.
- ///
- /// This method can be used to access A2A agents that support the
- /// Curated Registries (Catalog-Based Discovery)
- /// discovery mechanism.
- ///
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard)
- => endpoints.MapA2A(agentName, path, agentCard, _ => { });
-
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The configuration builder for .
- /// The route group to use for A2A endpoints.
- /// Agent card info to return on query.
- /// Controls the response behavior of the agent run.
- /// Configured for A2A integration.
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, AgentRunMode agentRunMode)
- {
- ArgumentNullException.ThrowIfNull(agentBuilder);
- return endpoints.MapA2A(agentBuilder.Name, path, agentCard, agentRunMode);
- }
-
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The name of the agent to use for A2A protocol integration.
- /// The route group to use for A2A endpoints.
- /// Agent card info to return on query.
- /// Controls the response behavior of the agent run.
- /// Configured for A2A integration.
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, AgentRunMode agentRunMode)
- {
- ArgumentNullException.ThrowIfNull(endpoints);
- var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName);
- return endpoints.MapA2A(agent, path, agentCard, agentRunMode);
- }
-
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The configuration builder for .
- /// The route group to use for A2A endpoints.
- /// Agent card info to return on query.
- /// The callback to configure .
- /// Configured for A2A integration.
- ///
- /// This method can be used to access A2A agents that support the
- /// Curated Registries (Catalog-Based Discovery)
- /// discovery mechanism.
- ///
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, Action configureTaskManager)
- {
- ArgumentNullException.ThrowIfNull(agentBuilder);
- return endpoints.MapA2A(agentBuilder.Name, path, agentCard, configureTaskManager);
- }
-
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The name of the agent to use for A2A protocol integration.
- /// The route group to use for A2A endpoints.
- /// Agent card info to return on query.
- /// The callback to configure .
- /// Configured for A2A integration.
- ///
- /// This method can be used to access A2A agents that support the
- /// Curated Registries (Catalog-Based Discovery)
- /// discovery mechanism.
- ///
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action configureTaskManager)
- => endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground);
-
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The name of the agent to use for A2A protocol integration.
- /// The route group to use for A2A endpoints.
- /// Agent card info to return on query.
- /// The callback to configure .
- /// Controls the response behavior of the agent run.
- /// Configured for A2A integration.
- ///
- /// This method can be used to access A2A agents that support the
- /// Curated Registries (Catalog-Based Discovery)
- /// discovery mechanism.
- ///
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action configureTaskManager, AgentRunMode agentRunMode)
- {
- ArgumentNullException.ThrowIfNull(endpoints);
- var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName);
- return endpoints.MapA2A(agent, path, agentCard, configureTaskManager, agentRunMode);
- }
-
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The agent to use for A2A protocol integration.
- /// The route group to use for A2A endpoints.
- /// Configured for A2A integration.
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path)
- => endpoints.MapA2A(agent, path, _ => { });
-
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The agent to use for A2A protocol integration.
- /// The route group to use for A2A endpoints.
- /// Controls the response behavior of the agent run.
- /// Configured for A2A integration.
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentRunMode agentRunMode)
- => endpoints.MapA2A(agent, path, _ => { }, agentRunMode);
-
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The agent to use for A2A protocol integration.
- /// The route group to use for A2A endpoints.
- /// The callback to configure .
- /// Configured for A2A integration.
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager)
- => endpoints.MapA2A(agent, path, configureTaskManager, AgentRunMode.DisallowBackground);
-
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The agent to use for A2A protocol integration.
- /// The route group to use for A2A endpoints.
- /// The callback to configure .
- /// Controls the response behavior of the agent run.
- /// Configured for A2A integration.
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager, AgentRunMode agentRunMode)
- {
- ArgumentNullException.ThrowIfNull(endpoints);
- ArgumentNullException.ThrowIfNull(agent);
-
- var loggerFactory = endpoints.ServiceProvider.GetRequiredService();
- var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(agent.Name);
- var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore, runMode: agentRunMode);
- var endpointConventionBuilder = endpoints.MapA2A(taskManager, path);
-
- configureTaskManager(taskManager);
- return endpointConventionBuilder;
- }
-
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The agent to use for A2A protocol integration.
- /// The route group to use for A2A endpoints.
- /// Agent card info to return on query.
- /// Configured for A2A integration.
- ///
- /// This method can be used to access A2A agents that support the
- /// Curated Registries (Catalog-Based Discovery)
- /// discovery mechanism.
- ///
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard)
- => endpoints.MapA2A(agent, path, agentCard, _ => { });
-
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The agent to use for A2A protocol integration.
- /// The route group to use for A2A endpoints.
- /// Agent card info to return on query.
- /// Controls the response behavior of the agent run.
- /// Configured for A2A integration.
- ///
- /// This method can be used to access A2A agents that support the
- /// Curated Registries (Catalog-Based Discovery)
- /// discovery mechanism.
- ///
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, AgentRunMode agentRunMode)
- => endpoints.MapA2A(agent, path, agentCard, _ => { }, agentRunMode);
-
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The agent to use for A2A protocol integration.
- /// The route group to use for A2A endpoints.
- /// Agent card info to return on query.
- /// The callback to configure .
- /// Configured for A2A integration.
- ///
- /// This method can be used to access A2A agents that support the
- /// Curated Registries (Catalog-Based Discovery)
- /// discovery mechanism.
- ///
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager)
- => endpoints.MapA2A(agent, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground);
-
- ///
- /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
- ///
- /// The to add the A2A endpoints to.
- /// The agent to use for A2A protocol integration.
- /// The route group to use for A2A endpoints.
- /// Agent card info to return on query.
- /// The callback to configure .
- /// Controls the response behavior of the agent run.
- /// Configured for A2A integration.
- ///
- /// This method can be used to access A2A agents that support the
- /// Curated Registries (Catalog-Based Discovery)
- /// discovery mechanism.
- ///
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager, AgentRunMode agentRunMode)
- {
- ArgumentNullException.ThrowIfNull(endpoints);
- ArgumentNullException.ThrowIfNull(agent);
-
- var loggerFactory = endpoints.ServiceProvider.GetRequiredService();
- var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(agent.Name);
- var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory, runMode: agentRunMode);
- var endpointConventionBuilder = endpoints.MapA2A(taskManager, path);
-
- configureTaskManager(taskManager);
-
- return endpointConventionBuilder;
- }
-
- ///
- /// Maps HTTP A2A communication endpoints to the specified path using the provided TaskManager.
- /// TaskManager should be preconfigured before calling this method.
- ///
- /// The to add the A2A endpoints to.
- /// Pre-configured A2A TaskManager to use for A2A endpoints handling.
- /// The route group to use for A2A endpoints.
- /// Configured for A2A integration.
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, ITaskManager taskManager, string path)
- {
- // note: current SDK version registers multiple `.well-known/agent.json` handlers here.
- // it makes app return HTTP 500, but will be fixed once new A2A SDK is released.
- // see https://github.com/microsoft/agent-framework/issues/476 for details
- A2ARouteBuilderExtensions.MapA2A(endpoints, taskManager, path);
- return endpoints.MapHttpA2A(taskManager, path);
- }
-}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs
new file mode 100644
index 0000000000..fd4a6945f1
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs
@@ -0,0 +1,189 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using A2A;
+using Microsoft.Agents.AI.Hosting.A2A.Converters;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI.Hosting.A2A;
+
+///
+/// An implementation that bridges an to the
+/// A2A (Agent2Agent) protocol. Handles message execution and cancellation by delegating to
+/// the underlying agent and translating responses into A2A events.
+///
+[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
+internal sealed class A2AAgentHandler : IAgentHandler
+{
+ private readonly AIHostAgent _hostAgent;
+ private readonly AgentRunMode _runMode;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The hosted agent that provides the execution logic.
+ /// Controls whether the agent runs in background mode.
+ public A2AAgentHandler(
+ AIHostAgent hostAgent,
+ AgentRunMode runMode)
+ {
+ this._hostAgent = hostAgent;
+ this._runMode = runMode;
+ }
+
+ ///
+ public Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
+ {
+ if (context.IsContinuation)
+ {
+ return this.HandleTaskUpdateAsync(context, eventQueue, cancellationToken);
+ }
+
+ return this.HandleNewMessageAsync(context, eventQueue, cancellationToken);
+ }
+
+ ///
+ public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
+ {
+ var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId);
+ await taskUpdater.CancelAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
+ {
+ var contextId = context.ContextId ?? Guid.NewGuid().ToString("N");
+ var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
+
+ // AIAgent does not support resuming from arbitrary prior tasks.
+ // Throw explicitly so the client gets a clear error rather than a response
+ // that silently ignores the referenced task context.
+ if (context.Message?.ReferenceTaskIds is { Count: > 0 })
+ {
+ throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context.");
+ }
+
+ List chatMessages = context.Message is not null ? [context.Message.ToChatMessage()] : [];
+
+ // Decide whether to run in background based on user preferences and agent capabilities
+ var decisionContext = new A2ARunDecisionContext(context);
+ var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
+
+ var options = context.Metadata is not { Count: > 0 }
+ ? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
+ : new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() };
+
+ var response = await this._hostAgent.RunAsync(
+ chatMessages,
+ session: session,
+ options: options,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
+
+ if (response.ContinuationToken is null)
+ {
+ // Return a lightweight message response (no task lifecycle needed).
+ var message = CreateMessageFromResponse(contextId, response);
+ await eventQueue.EnqueueMessageAsync(message, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ // Long-running operation: emit task lifecycle events.
+ var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId);
+ await taskUpdater.SubmitAsync(cancellationToken).ConfigureAwait(false);
+
+ Message? progressMessage = response.Messages.Count > 0
+ ? CreateMessageFromResponse(contextId, response)
+ : null;
+
+ await taskUpdater.StartWorkAsync(progressMessage, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
+ {
+ var contextId = context.ContextId ?? Guid.NewGuid().ToString("N");
+ var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
+
+ List chatMessages = ExtractChatMessagesFromTaskHistory(context.Task);
+
+ var decisionContext = new A2ARunDecisionContext(context);
+ var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
+
+ var options = context.Metadata is not { Count: > 0 }
+ ? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
+ : new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() };
+
+ AgentResponse response;
+ try
+ {
+ response = await this._hostAgent.RunAsync(
+ chatMessages,
+ session: session,
+ options: options,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception)
+ {
+ var failUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId);
+ await failUpdater.FailAsync(message: null, cancellationToken).ConfigureAwait(false);
+ throw;
+ }
+
+ await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
+
+ if (response.ContinuationToken is null)
+ {
+ // Complete the task with an artifact containing the response.
+ var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId);
+ await taskUpdater.AddArtifactAsync(response.Messages.ToParts(), cancellationToken: cancellationToken).ConfigureAwait(false);
+ await taskUpdater.CompleteAsync(message: null, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ // Still working: emit progress status.
+ var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId);
+
+ Message? progressMessage = response.Messages.Count > 0
+ ? CreateMessageFromResponse(contextId, response)
+ : null;
+
+ await taskUpdater.StartWorkAsync(progressMessage, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private static Message CreateMessageFromResponse(string contextId, AgentResponse response) =>
+ new()
+ {
+ MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
+ ContextId = contextId,
+ Role = Role.Agent,
+ Parts = response.Messages.ToParts(),
+ Metadata = response.AdditionalProperties?.ToA2AMetadata()
+ };
+
+ private static List ExtractChatMessagesFromTaskHistory(AgentTask? agentTask)
+ {
+ if (agentTask?.History is not { Count: > 0 })
+ {
+ return [];
+ }
+
+ var chatMessages = new List(agentTask.History.Count);
+ foreach (var message in agentTask.History)
+ {
+ chatMessages.Add(message.ToChatMessage());
+ }
+
+ return chatMessages;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AHostingOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AHostingOptions.cs
new file mode 100644
index 0000000000..ac12fb5cec
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AHostingOptions.cs
@@ -0,0 +1,40 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using A2A;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI.Hosting.A2A;
+
+///
+/// Options for configuring A2A endpoint hosting behavior.
+///
+[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
+public sealed class A2AHostingOptions
+{
+ ///
+ /// Gets or sets the A2A protocol binding(s) to expose.
+ ///
+ ///
+ /// When , defaults to .
+ /// Use the bitwise OR operator to enable multiple bindings
+ /// (e.g., A2AProtocolBinding.HttpJson | A2AProtocolBinding.JsonRpc).
+ ///
+ public A2AProtocolBinding? ProtocolBindings { get; set; }
+
+ ///
+ /// Gets or sets the agent run mode that controls how the agent responds to A2A requests.
+ ///
+ ///
+ /// When , defaults to .
+ ///
+ public AgentRunMode? AgentRunMode { get; set; }
+
+ ///
+ /// Gets or sets the A2A server options used to configure the underlying .
+ ///
+ ///
+ /// When , no custom server options are applied.
+ ///
+ public A2AServerOptions? ServerOptions { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AProtocolBinding.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AProtocolBinding.cs
new file mode 100644
index 0000000000..ad7cd32870
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AProtocolBinding.cs
@@ -0,0 +1,29 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI.Hosting.A2A;
+
+///
+/// Specifies which A2A protocol binding(s) to expose when mapping A2A endpoints.
+///
+///
+/// This is a flags enum. Combine values using the bitwise OR operator to enable multiple bindings
+/// (e.g., A2AProtocolBinding.HttpJson | A2AProtocolBinding.JsonRpc).
+///
+[Flags]
+[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
+public enum A2AProtocolBinding
+{
+ ///
+ /// Expose the agent via the HTTP+JSON/REST protocol binding.
+ ///
+ HttpJson = 1,
+
+ ///
+ /// Expose the agent via the JSON-RPC protocol binding.
+ ///
+ JsonRpc = 2,
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs
index 6ff49f6ecb..3e78afea8c 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs
@@ -9,13 +9,13 @@ namespace Microsoft.Agents.AI.Hosting.A2A;
///
public sealed class A2ARunDecisionContext
{
- internal A2ARunDecisionContext(MessageSendParams messageSendParams)
+ internal A2ARunDecisionContext(RequestContext requestContext)
{
- this.MessageSendParams = messageSendParams;
+ this.RequestContext = requestContext;
}
///
- /// Gets the parameters of the incoming A2A message that triggered this run.
+ /// Gets the request context of the incoming A2A request that triggered this run.
///
- public MessageSendParams MessageSendParams { get; }
+ public RequestContext RequestContext { get; }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs
index 31c520755f..bcecd26fbc 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs
@@ -1,15 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
-using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
-using System.Text.Json;
-using System.Threading;
-using System.Threading.Tasks;
using A2A;
-using Microsoft.Agents.AI.Hosting.A2A.Converters;
-using Microsoft.Extensions.AI;
-using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Hosting.A2A;
@@ -20,27 +13,18 @@ namespace Microsoft.Agents.AI.Hosting.A2A;
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public static class AIAgentExtensions
{
- // Metadata key used to store continuation tokens for long-running background operations
- // in the AgentTask.Metadata dictionary, persisted by the task store.
- private const string ContinuationTokenMetadataKey = "__a2a__continuationToken";
-
///
- /// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified .
+ /// Creates an that bridges the specified to
+ /// the A2A (Agent2Agent) protocol.
///
/// Agent to attach A2A messaging processing capabilities to.
- /// Instance of to configure for A2A messaging. New instance will be created if not passed.
- /// The logger factory to use for creating instances.
/// The store to store session contents and metadata.
/// Controls the response behavior of the agent run.
- /// Optional for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to if not provided.
- /// The configured .
- public static ITaskManager MapA2A(
+ /// An that handles A2A message execution and cancellation.
+ public static IAgentHandler MapA2A(
this AIAgent agent,
- ITaskManager? taskManager = null,
- ILoggerFactory? loggerFactory = null,
AgentSessionStore? agentSessionStore = null,
- AgentRunMode? runMode = null,
- JsonSerializerOptions? jsonSerializerOptions = null)
+ AgentRunMode? runMode = null)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentNullException.ThrowIfNull(agent.Name);
@@ -49,261 +33,8 @@ public static ITaskManager MapA2A(
var hostAgent = new AIHostAgent(
innerAgent: agent,
- sessionStore: agentSessionStore ?? new NoopAgentSessionStore());
-
- taskManager ??= new TaskManager();
-
- // Resolve the JSON serializer options for continuation token serialization. May be custom for the user's agent.
- JsonSerializerOptions continuationTokenJsonOptions = jsonSerializerOptions ?? A2AHostingJsonUtilities.DefaultOptions;
-
- // OnMessageReceived handles both message-only and task-based flows.
- // The A2A SDK prioritizes OnMessageReceived over OnTaskCreated when both are set,
- // so we consolidate all initial message handling here and return either
- // an AgentMessage or AgentTask depending on the agent response.
- // When the agent returns a ContinuationToken (long-running operation), a task is
- // created for stateful tracking. Otherwise a lightweight AgentMessage is returned.
- // See https://github.com/a2aproject/a2a-dotnet/issues/275
- taskManager.OnMessageReceived += (p, ct) => OnMessageReceivedAsync(p, hostAgent, runMode, taskManager, continuationTokenJsonOptions, ct);
-
- // Task flow for subsequent updates and cancellations
- taskManager.OnTaskUpdated += (t, ct) => OnTaskUpdatedAsync(t, hostAgent, taskManager, continuationTokenJsonOptions, ct);
- taskManager.OnTaskCancelled += OnTaskCancelledAsync;
-
- return taskManager;
- }
-
- ///
- /// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified .
- ///
- /// Agent to attach A2A messaging processing capabilities to.
- /// The agent card to return on query.
- /// Instance of to configure for A2A messaging. New instance will be created if not passed.
- /// The logger factory to use for creating instances.
- /// The store to store session contents and metadata.
- /// Controls the response behavior of the agent run.
- /// Optional for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to if not provided.
- /// The configured .
- public static ITaskManager MapA2A(
- this AIAgent agent,
- AgentCard agentCard,
- ITaskManager? taskManager = null,
- ILoggerFactory? loggerFactory = null,
- AgentSessionStore? agentSessionStore = null,
- AgentRunMode? runMode = null,
- JsonSerializerOptions? jsonSerializerOptions = null)
- {
- taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore, runMode, jsonSerializerOptions);
-
- taskManager.OnAgentCardQuery += (context, query) =>
- {
- // A2A SDK assigns the url on its own
- // we can help user if they did not set Url explicitly.
- if (string.IsNullOrEmpty(agentCard.Url))
- {
- agentCard.Url = context.TrimEnd('/');
- }
-
- return Task.FromResult(agentCard);
- };
- return taskManager;
- }
-
- private static async Task OnMessageReceivedAsync(
- MessageSendParams messageSendParams,
- AIHostAgent hostAgent,
- AgentRunMode runMode,
- ITaskManager taskManager,
- JsonSerializerOptions continuationTokenJsonOptions,
- CancellationToken cancellationToken)
- {
- // AIAgent does not support resuming from arbitrary prior tasks.
- // Throw explicitly so the client gets a clear error rather than a response
- // that silently ignores the referenced task context.
- // Follow-ups on the *same* task are handled via OnTaskUpdated instead.
- if (messageSendParams.Message.ReferenceTaskIds is { Count: > 0 })
- {
- throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context. Use OnTaskUpdated for follow-ups on the same task.");
- }
-
- var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N");
- var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
-
- // Decide whether to run in background based on user preferences and agent capabilities
- var decisionContext = new A2ARunDecisionContext(messageSendParams);
- var allowBackgroundResponses = await runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
-
- var options = messageSendParams.Metadata is not { Count: > 0 }
- ? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
- : new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() };
-
- var response = await hostAgent.RunAsync(
- messageSendParams.ToChatMessages(),
- session: session,
- options: options,
- cancellationToken: cancellationToken).ConfigureAwait(false);
-
- await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
-
- if (response.ContinuationToken is null)
- {
- return CreateMessageFromResponse(contextId, response);
- }
-
- var agentTask = await InitializeTaskAsync(contextId, messageSendParams.Message, taskManager, cancellationToken).ConfigureAwait(false);
- StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions);
- await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false);
- return agentTask;
- }
-
- private static async Task OnTaskUpdatedAsync(
- AgentTask agentTask,
- AIHostAgent hostAgent,
- ITaskManager taskManager,
- JsonSerializerOptions continuationTokenJsonOptions,
- CancellationToken cancellationToken)
- {
- var contextId = agentTask.ContextId ?? Guid.NewGuid().ToString("N");
- var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
-
- try
- {
- // Discard any stale continuation token — the incoming user message supersedes
- // any previous background operation. AF agents don't support updating existing
- // background responses (long-running operations); we start a fresh run from the
- // existing session using the full chat history (which includes the new message).
- agentTask.Metadata?.Remove(ContinuationTokenMetadataKey);
-
- await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false);
-
- var response = await hostAgent.RunAsync(
- ExtractChatMessagesFromTaskHistory(agentTask),
- session: session,
- options: new AgentRunOptions { AllowBackgroundResponses = true },
- cancellationToken: cancellationToken).ConfigureAwait(false);
-
- await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
-
- if (response.ContinuationToken is not null)
- {
- StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions);
- await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false);
- }
- else
- {
- await CompleteWithArtifactAsync(agentTask.Id, response, taskManager, cancellationToken).ConfigureAwait(false);
- }
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (Exception)
- {
- await taskManager.UpdateStatusAsync(
- agentTask.Id,
- TaskState.Failed,
- final: true,
- cancellationToken: cancellationToken).ConfigureAwait(false);
- throw;
- }
- }
-
- private static Task OnTaskCancelledAsync(AgentTask agentTask, CancellationToken cancellationToken)
- {
- // Remove the continuation token from metadata if present.
- // The task has already been marked as cancelled by the TaskManager.
- agentTask.Metadata?.Remove(ContinuationTokenMetadataKey);
- return Task.CompletedTask;
- }
-
- private static AgentMessage CreateMessageFromResponse(string contextId, AgentResponse response) =>
- new()
- {
- MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
- ContextId = contextId,
- Role = MessageRole.Agent,
- Parts = response.Messages.ToParts(),
- Metadata = response.AdditionalProperties?.ToA2AMetadata()
- };
-
- // Task outputs should be returned as artifacts rather than messages:
- // https://a2a-protocol.org/latest/specification/#37-messages-and-artifacts
- private static Artifact CreateArtifactFromResponse(AgentResponse response) =>
- new()
- {
- ArtifactId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
- Parts = response.Messages.ToParts(),
- Metadata = response.AdditionalProperties?.ToA2AMetadata()
- };
-
- private static async Task InitializeTaskAsync(
- string contextId,
- AgentMessage originalMessage,
- ITaskManager taskManager,
- CancellationToken cancellationToken)
- {
- AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false);
-
- // Add the original user message to the task history.
- // The A2A SDK does this internally when it creates tasks via OnTaskCreated.
- agentTask.History ??= [];
- agentTask.History.Add(originalMessage);
-
- // Notify subscribers of the Submitted state per the A2A spec: https://a2a-protocol.org/latest/specification/#413-taskstate
- await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Submitted, cancellationToken: cancellationToken).ConfigureAwait(false);
-
- return agentTask;
- }
-
- private static void StoreContinuationToken(
- AgentTask agentTask,
- ResponseContinuationToken token,
- JsonSerializerOptions continuationTokenJsonOptions)
- {
- // Serialize the continuation token into the task's metadata so it survives
- // across requests and is cleaned up with the task itself.
- agentTask.Metadata ??= [];
- agentTask.Metadata[ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement(
- token,
- continuationTokenJsonOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
- }
-
- private static async Task TransitionToWorkingAsync(
- string taskId,
- string contextId,
- AgentResponse response,
- ITaskManager taskManager,
- CancellationToken cancellationToken)
- {
- // Include any intermediate progress messages from the response as a status message.
- AgentMessage? progressMessage = response.Messages.Count > 0 ? CreateMessageFromResponse(contextId, response) : null;
- await taskManager.UpdateStatusAsync(taskId, TaskState.Working, message: progressMessage, cancellationToken: cancellationToken).ConfigureAwait(false);
- }
-
- private static async Task CompleteWithArtifactAsync(
- string taskId,
- AgentResponse response,
- ITaskManager taskManager,
- CancellationToken cancellationToken)
- {
- var artifact = CreateArtifactFromResponse(response);
- await taskManager.ReturnArtifactAsync(taskId, artifact, cancellationToken).ConfigureAwait(false);
- await taskManager.UpdateStatusAsync(taskId, TaskState.Completed, final: true, cancellationToken: cancellationToken).ConfigureAwait(false);
- }
-
- private static List ExtractChatMessagesFromTaskHistory(AgentTask agentTask)
- {
- if (agentTask.History is not { Count: > 0 })
- {
- return [];
- }
-
- var chatMessages = new List(agentTask.History.Count);
- foreach (var message in agentTask.History)
- {
- chatMessages.Add(message.ToChatMessage());
- }
+ sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore());
- return chatMessages;
+ return new A2AAgentHandler(hostAgent, runMode);
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs
index 087df96aae..094a5156c0 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs
@@ -28,7 +28,7 @@ private AgentRunMode(string value, Func
- /// Dissallows the background responses from the agent. Is equivalent to configuring as false.
+ /// Disallows the background responses from the agent. Is equivalent to configuring as false.
/// In the A2A protocol terminology will make responses be returned as AgentMessage.
///
public static AgentRunMode DisallowBackground => new(MessageValue);
@@ -79,7 +79,7 @@ internal ValueTask ShouldRunInBackgroundAsync(A2ARunDecisionContext contex
}
// No delegate provided — fall back to "message" behavior.
- return ValueTask.FromResult(true);
+ return ValueTask.FromResult(false);
}
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs
index 5d2381a235..b2f57fc09e 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs
@@ -31,21 +31,21 @@ public static List ToParts(this IList chatMessages)
return parts;
}
///
- /// Converts A2A MessageSendParams to a collection of Microsoft.Extensions.AI ChatMessage objects.
+ /// Converts A2A SendMessageRequest to a collection of Microsoft.Extensions.AI ChatMessage objects.
///
- /// The A2A message send parameters to convert.
+ /// The A2A send message request to convert.
/// A read-only collection of ChatMessage objects.
- public static List ToChatMessages(this MessageSendParams messageSendParams)
+ public static List ToChatMessages(this SendMessageRequest sendMessageRequest)
{
- if (messageSendParams is null)
+ if (sendMessageRequest is null)
{
return [];
}
var result = new List();
- if (messageSendParams.Message?.Parts is not null)
+ if (sendMessageRequest.Message?.Parts is not null)
{
- result.Add(messageSendParams.Message.ToChatMessage());
+ result.Add(sendMessageRequest.Message.ToChatMessage());
}
return result;
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs
similarity index 55%
rename from dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs
rename to dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs
index a848528888..783fecea96 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
-using A2A;
using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.AI;
@@ -10,9 +9,9 @@
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
///
-/// Tests for MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions.MapA2A method.
+/// Tests for A2AEndpointRouteBuilderExtensions.MapA2A method.
///
-public sealed class EndpointRouteA2ABuilderExtensionsTests
+public sealed class A2AEndpointRouteBuilderExtensionsTests
{
///
/// Verifies that MapA2A throws ArgumentNullException for null endpoints.
@@ -57,7 +56,7 @@ public void MapA2A_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException
}
///
- /// Verifies that MapA2A with IHostedAgentBuilder correctly maps the agent with default task manager configuration.
+ /// Verifies that MapA2A with IHostedAgentBuilder correctly maps the agent with default configuration.
///
[Fact]
public void MapA2A_WithAgentBuilder_DefaultConfiguration_Succeeds()
@@ -73,14 +72,13 @@ public void MapA2A_WithAgentBuilder_DefaultConfiguration_Succeeds()
// Act & Assert - Should not throw
var result = app.MapA2A(agentBuilder, "/a2a");
Assert.NotNull(result);
- Assert.NotNull(app);
}
///
- /// Verifies that MapA2A with IHostedAgentBuilder and custom task manager configuration succeeds.
+ /// Verifies that MapA2A with IHostedAgentBuilder and custom A2AHostingOptions succeeds.
///
[Fact]
- public void MapA2A_WithAgentBuilder_CustomTaskManagerConfiguration_Succeeds()
+ public void MapA2A_WithAgentBuilder_CustomA2AHostingOptionsConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
@@ -91,84 +89,85 @@ public void MapA2A_WithAgentBuilder_CustomTaskManagerConfiguration_Succeeds()
using WebApplication app = builder.Build();
// Act & Assert - Should not throw
- var result = app.MapA2A(agentBuilder, "/a2a", taskManager => { });
+ var result = app.MapA2A(agentBuilder, "/a2a", options => { });
Assert.NotNull(result);
- Assert.NotNull(app);
}
///
- /// Verifies that MapA2A with IHostedAgentBuilder and agent card succeeds.
+ /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using string agent name.
///
[Fact]
- public void MapA2A_WithAgentBuilder_WithAgentCard_Succeeds()
+ public void MapA2A_WithAgentName_NullEndpoints_ThrowsArgumentNullException()
+ {
+ // Arrange
+ AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
+
+ // Act & Assert
+ ArgumentNullException exception = Assert.Throws(() =>
+ endpoints.MapA2A("agent", "/a2a"));
+
+ Assert.Equal("endpoints", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that MapA2A with string agent name correctly maps the agent.
+ ///
+ [Fact]
+ public void MapA2A_WithAgentName_DefaultConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
- IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
+ builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
- var agentCard = new AgentCard
- {
- Name = "Test Agent",
- Description = "A test agent for A2A communication"
- };
-
// Act & Assert - Should not throw
- var result = app.MapA2A(agentBuilder, "/a2a", agentCard);
+ var result = app.MapA2A("agent", "/a2a");
Assert.NotNull(result);
- Assert.NotNull(app);
}
///
- /// Verifies that MapA2A with IHostedAgentBuilder, agent card, and custom task manager configuration succeeds.
+ /// Verifies that MapA2A with string agent name and custom A2AHostingOptions succeeds.
///
[Fact]
- public void MapA2A_WithAgentBuilder_WithAgentCardAndCustomConfiguration_Succeeds()
+ public void MapA2A_WithAgentName_CustomA2AHostingOptionsConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
- IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
+ builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
- var agentCard = new AgentCard
- {
- Name = "Test Agent",
- Description = "A test agent for A2A communication"
- };
-
// Act & Assert - Should not throw
- var result = app.MapA2A(agentBuilder, "/a2a", agentCard, taskManager => { });
+ var result = app.MapA2A("agent", "/a2a", options => { });
Assert.NotNull(result);
- Assert.NotNull(app);
}
///
- /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using string agent name.
+ /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using AIAgent.
///
[Fact]
- public void MapA2A_WithAgentName_NullEndpoints_ThrowsArgumentNullException()
+ public void MapA2A_WithAIAgent_NullEndpoints_ThrowsArgumentNullException()
{
// Arrange
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws(() =>
- endpoints.MapA2A("agent", "/a2a"));
+ endpoints.MapA2A((AIAgent)null!, "/a2a"));
Assert.Equal("endpoints", exception.ParamName);
}
///
- /// Verifies that MapA2A with string agent name correctly maps the agent.
+ /// Verifies that MapA2A with AIAgent correctly maps the agent.
///
[Fact]
- public void MapA2A_WithAgentName_DefaultConfiguration_Succeeds()
+ public void MapA2A_WithAIAgent_DefaultConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
@@ -177,18 +176,18 @@ public void MapA2A_WithAgentName_DefaultConfiguration_Succeeds()
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
+ AIAgent agent = app.Services.GetRequiredKeyedService("agent");
// Act & Assert - Should not throw
- var result = app.MapA2A("agent", "/a2a");
+ var result = app.MapA2A(agent, "/a2a");
Assert.NotNull(result);
- Assert.NotNull(app);
}
///
- /// Verifies that MapA2A with string agent name and custom task manager configuration succeeds.
+ /// Verifies that MapA2A with AIAgent and custom A2AHostingOptions succeeds.
///
[Fact]
- public void MapA2A_WithAgentName_CustomTaskManagerConfiguration_Succeeds()
+ public void MapA2A_WithAIAgent_CustomA2AHostingOptionsConfiguration_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
@@ -197,86 +196,218 @@ public void MapA2A_WithAgentName_CustomTaskManagerConfiguration_Succeeds()
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
+ AIAgent agent = app.Services.GetRequiredKeyedService("agent");
// Act & Assert - Should not throw
- var result = app.MapA2A("agent", "/a2a", taskManager => { });
+ var result = app.MapA2A(agent, "/a2a", options => { });
Assert.NotNull(result);
- Assert.NotNull(app);
}
///
- /// Verifies that MapA2A with string agent name and agent card succeeds.
+ /// Verifies that MapA2A with IHostedAgentBuilder and A2AHostingOptions with AgentRunMode succeeds.
///
[Fact]
- public void MapA2A_WithAgentName_WithAgentCard_Succeeds()
+ public void MapA2A_WithAgentBuilder_CustomOptionsAndRunMode_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
- builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
+ IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
- var agentCard = new AgentCard
- {
- Name = "Test Agent",
- Description = "A test agent for A2A communication"
- };
+ // Act & Assert - Should not throw
+ var result = app.MapA2A(agentBuilder, "/a2a", options => options.AgentRunMode = AgentRunMode.DisallowBackground);
+ Assert.NotNull(result);
+ }
+
+ ///
+ /// Verifies that MapA2A with string agentName and A2AHostingOptions with AgentRunMode succeeds.
+ ///
+ [Fact]
+ public void MapA2A_WithAgentName_CustomOptionsAndRunMode_Succeeds()
+ {
+ // Arrange
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ IChatClient mockChatClient = new DummyChatClient();
+ builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
+ builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
// Act & Assert - Should not throw
- var result = app.MapA2A("agent", "/a2a", agentCard);
+ var result = app.MapA2A("agent", "/a2a", options => options.AgentRunMode = AgentRunMode.DisallowBackground);
Assert.NotNull(result);
+ }
+
+ ///
+ /// Verifies that multiple agents can be mapped to different paths.
+ ///
+ [Fact]
+ public void MapA2A_MultipleAgents_Succeeds()
+ {
+ // Arrange
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ IChatClient mockChatClient = new DummyChatClient();
+ builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
+ IHostedAgentBuilder agent1Builder = builder.AddAIAgent("agent1", "Instructions1", chatClientServiceKey: "chat-client");
+ IHostedAgentBuilder agent2Builder = builder.AddAIAgent("agent2", "Instructions2", chatClientServiceKey: "chat-client");
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+
+ // Act & Assert - Should not throw
+ app.MapA2A(agent1Builder, "/a2a/agent1");
+ app.MapA2A(agent2Builder, "/a2a/agent2");
Assert.NotNull(app);
}
///
- /// Verifies that MapA2A with string agent name, agent card, and custom task manager configuration succeeds.
+ /// Verifies that custom paths can be specified for A2A endpoints.
///
[Fact]
- public void MapA2A_WithAgentName_WithAgentCardAndCustomConfiguration_Succeeds()
+ public void MapA2A_WithCustomPath_AcceptsValidPath()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
- builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
+ IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+
+ // Act & Assert - Should not throw
+ app.MapA2A(agentBuilder, "/custom/a2a/path");
+ Assert.NotNull(app);
+ }
+
+ ///
+ /// Verifies that A2AHostingOptions configuration callback is invoked correctly.
+ ///
+ [Fact]
+ public void MapA2A_WithAgentBuilder_A2AHostingOptionsConfigurationCallbackInvoked()
+ {
+ // Arrange
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ IChatClient mockChatClient = new DummyChatClient();
+ builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
+ IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
- var agentCard = new AgentCard
+ bool configureCallbackInvoked = false;
+
+ // Act
+ app.MapA2A(agentBuilder, "/a2a", options =>
{
- Name = "Test Agent",
- Description = "A test agent for A2A communication"
- };
+ configureCallbackInvoked = true;
+ Assert.NotNull(options);
+ });
+
+ // Assert
+ Assert.True(configureCallbackInvoked);
+ }
+
+ ///
+ /// Verifies that MapA2A with JsonRpc protocolBindings succeeds.
+ ///
+ [Fact]
+ public void MapA2A_WithJsonRpcProtocol_Succeeds()
+ {
+ // Arrange
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ IChatClient mockChatClient = new DummyChatClient();
+ builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
+ IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
// Act & Assert - Should not throw
- var result = app.MapA2A("agent", "/a2a", agentCard, taskManager => { });
+ var result = app.MapA2A(agentBuilder, "/a2a", options => options.ProtocolBindings = A2AProtocolBinding.JsonRpc);
Assert.NotNull(result);
- Assert.NotNull(app);
}
///
- /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using AIAgent.
+ /// Verifies that MapA2A with both protocols succeeds.
///
[Fact]
- public void MapA2A_WithAIAgent_NullEndpoints_ThrowsArgumentNullException()
+ public void MapA2A_WithBothProtocols_Succeeds()
{
// Arrange
- AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ IChatClient mockChatClient = new DummyChatClient();
+ builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
+ IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
- // Act & Assert
- ArgumentNullException exception = Assert.Throws(() =>
- endpoints.MapA2A((AIAgent)null!, "/a2a"));
+ // Act & Assert - Should not throw
+ var result = app.MapA2A(agentBuilder, "/a2a", options => options.ProtocolBindings = A2AProtocolBinding.HttpJson | A2AProtocolBinding.JsonRpc);
+ Assert.NotNull(result);
+ }
- Assert.Equal("endpoints", exception.ParamName);
+ ///
+ /// Verifies that MapA2A with IHostedAgentBuilder and direct protocolBindings parameter succeeds.
+ ///
+ [Fact]
+ public void MapA2A_WithAgentBuilder_DirectProtocol_Succeeds()
+ {
+ // Arrange
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ IChatClient mockChatClient = new DummyChatClient();
+ builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
+ IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+
+ // Act & Assert - Should not throw
+ var result = app.MapA2A(agentBuilder, "/a2a", A2AProtocolBinding.HttpJson);
+ Assert.NotNull(result);
}
///
- /// Verifies that MapA2A with AIAgent correctly maps the agent.
+ /// Verifies that MapA2A with IHostedAgentBuilder and direct protocolBindings and run mode parameters succeeds.
///
[Fact]
- public void MapA2A_WithAIAgent_DefaultConfiguration_Succeeds()
+ public void MapA2A_WithAgentBuilder_DirectProtocolAndRunMode_Succeeds()
+ {
+ // Arrange
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ IChatClient mockChatClient = new DummyChatClient();
+ builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
+ IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+
+ // Act & Assert - Should not throw
+ var result = app.MapA2A(agentBuilder, "/a2a", A2AProtocolBinding.HttpJson, AgentRunMode.AllowBackgroundIfSupported);
+ Assert.NotNull(result);
+ }
+
+ ///
+ /// Verifies that MapA2A with IHostedAgentBuilder, null protocolBindings, and direct run mode parameter succeeds.
+ ///
+ [Fact]
+ public void MapA2A_WithAgentBuilder_NullProtocolAndDirectRunMode_Succeeds()
+ {
+ // Arrange
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ IChatClient mockChatClient = new DummyChatClient();
+ builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
+ IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+
+ // Act & Assert - Should not throw
+ var result = app.MapA2A(agentBuilder, "/a2a", protocolBindings: null, agentRunMode: AgentRunMode.DisallowBackground);
+ Assert.NotNull(result);
+ }
+
+ ///
+ /// Verifies that MapA2A with string agent name and direct protocolBindings parameter succeeds.
+ ///
+ [Fact]
+ public void MapA2A_WithAgentName_DirectProtocol_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
@@ -285,19 +416,17 @@ public void MapA2A_WithAIAgent_DefaultConfiguration_Succeeds()
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
- AIAgent agent = app.Services.GetRequiredKeyedService("agent");
// Act & Assert - Should not throw
- var result = app.MapA2A(agent, "/a2a");
+ var result = app.MapA2A("agent", "/a2a", A2AProtocolBinding.JsonRpc);
Assert.NotNull(result);
- Assert.NotNull(app);
}
///
- /// Verifies that MapA2A with AIAgent and custom task manager configuration succeeds.
+ /// Verifies that MapA2A with string agent name and direct protocolBindings and run mode parameters succeeds.
///
[Fact]
- public void MapA2A_WithAIAgent_CustomTaskManagerConfiguration_Succeeds()
+ public void MapA2A_WithAgentName_DirectProtocolAndRunMode_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
@@ -306,19 +435,17 @@ public void MapA2A_WithAIAgent_CustomTaskManagerConfiguration_Succeeds()
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
- AIAgent agent = app.Services.GetRequiredKeyedService("agent");
// Act & Assert - Should not throw
- var result = app.MapA2A(agent, "/a2a", taskManager => { });
+ var result = app.MapA2A("agent", "/a2a", A2AProtocolBinding.HttpJson, AgentRunMode.AllowBackgroundIfSupported);
Assert.NotNull(result);
- Assert.NotNull(app);
}
///
- /// Verifies that MapA2A with AIAgent and agent card succeeds.
+ /// Verifies that MapA2A with AIAgent and direct protocolBindings parameter succeeds.
///
[Fact]
- public void MapA2A_WithAIAgent_WithAgentCard_Succeeds()
+ public void MapA2A_WithAIAgent_DirectProtocol_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
@@ -329,23 +456,16 @@ public void MapA2A_WithAIAgent_WithAgentCard_Succeeds()
using WebApplication app = builder.Build();
AIAgent agent = app.Services.GetRequiredKeyedService("agent");
- var agentCard = new AgentCard
- {
- Name = "Test Agent",
- Description = "A test agent for A2A communication"
- };
-
// Act & Assert - Should not throw
- var result = app.MapA2A(agent, "/a2a", agentCard);
+ var result = app.MapA2A(agent, "/a2a", A2AProtocolBinding.HttpJson);
Assert.NotNull(result);
- Assert.NotNull(app);
}
///
- /// Verifies that MapA2A with AIAgent, agent card, and custom task manager configuration succeeds.
+ /// Verifies that MapA2A with AIAgent and direct protocolBindings and run mode parameters succeeds.
///
[Fact]
- public void MapA2A_WithAIAgent_WithAgentCardAndCustomConfiguration_Succeeds()
+ public void MapA2A_WithAIAgent_DirectProtocolAndRunMode_Succeeds()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
@@ -356,124 +476,140 @@ public void MapA2A_WithAIAgent_WithAgentCardAndCustomConfiguration_Succeeds()
using WebApplication app = builder.Build();
AIAgent agent = app.Services.GetRequiredKeyedService("agent");
- var agentCard = new AgentCard
- {
- Name = "Test Agent",
- Description = "A test agent for A2A communication"
- };
+ // Act & Assert - Should not throw
+ var result = app.MapA2A(agent, "/a2a", A2AProtocolBinding.HttpJson, AgentRunMode.AllowBackgroundIfSupported);
+ Assert.NotNull(result);
+ }
+
+ ///
+ /// Verifies that MapA2A with AIAgent, null protocolBindings, and direct run mode defaults correctly.
+ ///
+ [Fact]
+ public void MapA2A_WithAIAgent_NullProtocolAndDirectRunMode_Succeeds()
+ {
+ // Arrange
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ IChatClient mockChatClient = new DummyChatClient();
+ builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
+ builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+ AIAgent agent = app.Services.GetRequiredKeyedService("agent");
// Act & Assert - Should not throw
- var result = app.MapA2A(agent, "/a2a", agentCard, taskManager => { });
+ var result = app.MapA2A(agent, "/a2a", protocolBindings: null, agentRunMode: AgentRunMode.DisallowBackground);
Assert.NotNull(result);
- Assert.NotNull(app);
}
///
- /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using ITaskManager.
+ /// Verifies that MapA2A throws ArgumentNullException for null agentName (string overload with configureOptions).
///
[Fact]
- public void MapA2A_WithTaskManager_NullEndpoints_ThrowsArgumentNullException()
+ public void MapA2A_WithAgentName_NullAgentName_ThrowsArgumentNullException()
{
// Arrange
- AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
- ITaskManager taskManager = null!;
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
// Act & Assert
ArgumentNullException exception = Assert.Throws(() =>
- endpoints.MapA2A(taskManager, "/a2a"));
+ app.MapA2A((string)null!, "/a2a"));
- Assert.Equal("endpoints", exception.ParamName);
+ Assert.Equal("agentName", exception.ParamName);
}
///
- /// Verifies that multiple agents can be mapped to different paths.
+ /// Verifies that MapA2A throws ArgumentNullException for null agentName (string overload with protocolBindings).
///
[Fact]
- public void MapA2A_MultipleAgents_Succeeds()
+ public void MapA2A_WithAgentName_NullAgentName_ProtocolOverload_ThrowsArgumentNullException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
- IChatClient mockChatClient = new DummyChatClient();
- builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
- IHostedAgentBuilder agent1Builder = builder.AddAIAgent("agent1", "Instructions1", chatClientServiceKey: "chat-client");
- IHostedAgentBuilder agent2Builder = builder.AddAIAgent("agent2", "Instructions2", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
- // Act & Assert - Should not throw
- app.MapA2A(agent1Builder, "/a2a/agent1");
- app.MapA2A(agent2Builder, "/a2a/agent2");
- Assert.NotNull(app);
+ // Act & Assert
+ ArgumentNullException exception = Assert.Throws(() =>
+ app.MapA2A((string)null!, "/a2a", A2AProtocolBinding.HttpJson));
+
+ Assert.Equal("agentName", exception.ParamName);
}
///
- /// Verifies that custom paths can be specified for A2A endpoints.
+ /// Verifies that MapA2A throws ArgumentException for empty agentName (string overload with configureOptions).
///
[Fact]
- public void MapA2A_WithCustomPath_AcceptsValidPath()
+ public void MapA2A_WithAgentName_EmptyAgentName_ThrowsArgumentException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
- IChatClient mockChatClient = new DummyChatClient();
- builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
- IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
- // Act & Assert - Should not throw
- app.MapA2A(agentBuilder, "/custom/a2a/path");
- Assert.NotNull(app);
+ // Act & Assert
+ ArgumentException exception = Assert.Throws(() =>
+ app.MapA2A(string.Empty, "/a2a"));
+
+ Assert.Equal("agentName", exception.ParamName);
}
///
- /// Verifies that task manager configuration callback is invoked correctly.
+ /// Verifies that MapA2A throws ArgumentException for empty agentName (string overload with protocolBindings).
///
[Fact]
- public void MapA2A_WithAgentBuilder_TaskManagerConfigurationCallbackInvoked()
+ public void MapA2A_WithAgentName_EmptyAgentName_ProtocolOverload_ThrowsArgumentException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
- IChatClient mockChatClient = new DummyChatClient();
- builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
- IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
- bool configureCallbackInvoked = false;
-
- // Act
- app.MapA2A(agentBuilder, "/a2a", taskManager =>
- {
- configureCallbackInvoked = true;
- Assert.NotNull(taskManager);
- });
+ // Act & Assert
+ ArgumentException exception = Assert.Throws(() =>
+ app.MapA2A(string.Empty, "/a2a", A2AProtocolBinding.HttpJson));
- // Assert
- Assert.True(configureCallbackInvoked);
+ Assert.Equal("agentName", exception.ParamName);
}
///
- /// Verifies that agent card with all properties is accepted.
+ /// Verifies that MapA2A throws ArgumentException for null path.
///
[Fact]
- public void MapA2A_WithAgentBuilder_FullAgentCard_Succeeds()
+ public void MapA2A_WithAIAgent_NullPath_ThrowsArgumentException()
{
// Arrange
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IChatClient mockChatClient = new DummyChatClient();
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
- IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
+ builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
builder.Services.AddLogging();
using WebApplication app = builder.Build();
+ AIAgent agent = app.Services.GetRequiredKeyedService("agent");
- var agentCard = new AgentCard
- {
- Name = "Test Agent",
- Description = "A comprehensive test agent"
- };
+ // Act & Assert
+ Assert.Throws(() =>
+ app.MapA2A(agent, null!));
+ }
- // Act & Assert - Should not throw
- var result = app.MapA2A(agentBuilder, "/a2a", agentCard);
- Assert.NotNull(result);
+ ///
+ /// Verifies that MapA2A throws ArgumentException for whitespace-only path.
+ ///
+ [Fact]
+ public void MapA2A_WithAIAgent_WhitespacePath_ThrowsArgumentException()
+ {
+ // Arrange
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ IChatClient mockChatClient = new DummyChatClient();
+ builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
+ builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+ AIAgent agent = app.Services.GetRequiredKeyedService("agent");
+
+ // Act & Assert
+ Assert.Throws(() =>
+ app.MapA2A(agent, " "));
}
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AIntegrationTests.cs
deleted file mode 100644
index f8604c7eac..0000000000
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AIntegrationTests.cs
+++ /dev/null
@@ -1,89 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System;
-using System.Text.Json;
-using System.Threading.Tasks;
-using A2A;
-using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
-using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Hosting.Server;
-using Microsoft.AspNetCore.TestHost;
-using Microsoft.Extensions.AI;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
-
-public sealed class A2AIntegrationTests
-{
- ///
- /// Verifies that calling the A2A card endpoint with MapA2A returns an agent card with a URL populated.
- ///
- [Fact]
- public async Task MapA2A_WithAgentCard_CardEndpointReturnsCardWithUrlAsync()
- {
- // Arrange
- WebApplicationBuilder builder = WebApplication.CreateBuilder();
- builder.WebHost.UseTestServer();
-
- IChatClient mockChatClient = new DummyChatClient();
- builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
- IHostedAgentBuilder agentBuilder = builder.AddAIAgent("test-agent", "Test instructions", chatClientServiceKey: "chat-client");
- builder.Services.AddLogging();
-
- using WebApplication app = builder.Build();
-
- var agentCard = new AgentCard
- {
- Name = "Test Agent",
- Description = "A test agent for A2A communication",
- Version = "1.0"
- };
-
- // Map A2A with the agent card
- app.MapA2A(agentBuilder, "/a2a/test-agent", agentCard);
-
- await app.StartAsync();
-
- try
- {
- // Get the test server client
- TestServer testServer = app.Services.GetRequiredService() as TestServer
- ?? throw new InvalidOperationException("TestServer not found");
- var httpClient = testServer.CreateClient();
-
- // Act - Query the agent card endpoint
- var requestUri = new Uri("/a2a/test-agent/v1/card", UriKind.Relative);
- var response = await httpClient.GetAsync(requestUri);
-
- // Assert
- Assert.True(response.IsSuccessStatusCode, $"Expected successful response but got {response.StatusCode}");
-
- var content = await response.Content.ReadAsStringAsync();
- var jsonDoc = JsonDocument.Parse(content);
- var root = jsonDoc.RootElement;
-
- // Verify the card has expected properties
- Assert.True(root.TryGetProperty("name", out var nameProperty));
- Assert.Equal("Test Agent", nameProperty.GetString());
-
- Assert.True(root.TryGetProperty("description", out var descProperty));
- Assert.Equal("A test agent for A2A communication", descProperty.GetString());
-
- // Verify the card has a URL property and it's not null/empty
- Assert.True(root.TryGetProperty("url", out var urlProperty));
- Assert.NotEqual(JsonValueKind.Null, urlProperty.ValueKind);
-
- var url = urlProperty.GetString();
- Assert.NotNull(url);
- Assert.NotEmpty(url);
- Assert.StartsWith("http", url, StringComparison.OrdinalIgnoreCase);
-
- // agentCard's URL matches the agent endpoint
- Assert.Equal($"{testServer.BaseAddress.ToString().TrimEnd('/')}/a2a/test-agent", url);
- }
- finally
- {
- await app.StopAsync();
- }
- }
-}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs
index 87de6e52cd..c472ea2d01 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs
@@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
-using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using A2A;
@@ -19,74 +18,46 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
public sealed class AIAgentExtensionsTests
{
///
- /// Verifies that when messageSendParams.Metadata is null, the options passed to RunAsync have
- /// AllowBackgroundResponses enabled and no AdditionalProperties.
+ /// Verifies that MapA2A throws ArgumentNullException for null agent.
///
[Fact]
- public async Task MapA2A_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
+ public void MapA2A_NullAgent_ThrowsArgumentNullException()
{
// Arrange
- AgentRunOptions? capturedOptions = null;
- ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
-
- // Act
- await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
- {
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
- Metadata = null
- });
+ AIAgent agent = null!;
- // Assert
- Assert.NotNull(capturedOptions);
- Assert.False(capturedOptions.AllowBackgroundResponses);
- Assert.Null(capturedOptions.AdditionalProperties);
+ // Act & Assert
+ Assert.Throws(() => agent.MapA2A());
}
///
- /// Verifies that when messageSendParams.Metadata has values, the options.AdditionalProperties contains the converted values.
+ /// Verifies that MapA2A returns a non-null IAgentHandler.
///
[Fact]
- public async Task MapA2A_WhenMetadataHasValues_PassesOptionsWithAdditionalPropertiesToRunAsync()
+ public void MapA2A_ValidAgent_ReturnsNonNullHandler()
{
- // Arrange
- AgentRunOptions? capturedOptions = null;
- ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
-
- // Act
- await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
- {
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
- Metadata = new Dictionary
- {
- ["key1"] = JsonSerializer.SerializeToElement("value1"),
- ["key2"] = JsonSerializer.SerializeToElement(42)
- }
- });
+ // Arrange & Act
+ IAgentHandler handler = CreateAgentMock(_ => { }).Object.MapA2A();
// Assert
- Assert.NotNull(capturedOptions);
- Assert.NotNull(capturedOptions.AdditionalProperties);
- Assert.Equal(2, capturedOptions.AdditionalProperties.Count);
- Assert.True(capturedOptions.AdditionalProperties.ContainsKey("key1"));
- Assert.True(capturedOptions.AdditionalProperties.ContainsKey("key2"));
+ Assert.NotNull(handler);
}
///
- /// Verifies that when messageSendParams.Metadata is an empty dictionary, the options passed to RunAsync have
- /// AllowBackgroundResponses enabled and no AdditionalProperties.
+ /// Verifies that when metadata is null, the options passed to RunAsync have
+ /// AllowBackgroundResponses disabled and no AdditionalProperties.
///
[Fact]
- public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
+ public async Task ExecuteAsync_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
- ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
+ IAgentHandler handler = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
// Act
- await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ await InvokeExecuteAsync(handler, new RequestContext
{
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
- Metadata = []
+ TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
@@ -96,10 +67,10 @@ public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesOptionsWithNoAdditi
}
///
- /// Verifies that when the agent response has AdditionalProperties, the returned AgentMessage.Metadata contains the converted values.
+ /// Verifies that when the agent response has AdditionalProperties, the returned Message.Metadata contains the converted values.
///
[Fact]
- public async Task MapA2A_WhenResponseHasAdditionalProperties_ReturnsAgentMessageWithMetadataAsync()
+ public async Task ExecuteAsync_WhenResponseHasAdditionalProperties_ReturnsMessageWithMetadataAsync()
{
// Arrange
AdditionalPropertiesDictionary additionalProps = new()
@@ -111,652 +82,454 @@ public async Task MapA2A_WhenResponseHasAdditionalProperties_ReturnsAgentMessage
{
AdditionalProperties = additionalProps
};
- ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
+ IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ var events = await CollectEventsAsync(handler, new RequestContext
{
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
+ TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
- AgentMessage agentMessage = Assert.IsType(a2aResponse);
- Assert.NotNull(agentMessage.Metadata);
- Assert.Equal(2, agentMessage.Metadata.Count);
- Assert.True(agentMessage.Metadata.ContainsKey("responseKey1"));
- Assert.True(agentMessage.Metadata.ContainsKey("responseKey2"));
- Assert.Equal("responseValue1", agentMessage.Metadata["responseKey1"].GetString());
- Assert.Equal(123, agentMessage.Metadata["responseKey2"].GetInt32());
+ Message message = Assert.Single(events.Messages);
+ Assert.NotNull(message.Metadata);
+ Assert.Equal(2, message.Metadata.Count);
+ Assert.True(message.Metadata.ContainsKey("responseKey1"));
+ Assert.True(message.Metadata.ContainsKey("responseKey2"));
}
///
- /// Verifies that when the agent response has null AdditionalProperties, the returned AgentMessage.Metadata is null.
+ /// Verifies that when the agent response has null AdditionalProperties, the returned Message.Metadata is null.
///
[Fact]
- public async Task MapA2A_WhenResponseHasNullAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync()
+ public async Task ExecuteAsync_WhenResponseHasNullAdditionalProperties_ReturnsMessageWithNullMetadataAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
{
AdditionalProperties = null
};
- ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
+ IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ var events = await CollectEventsAsync(handler, new RequestContext
{
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
+ TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
- AgentMessage agentMessage = Assert.IsType(a2aResponse);
- Assert.Null(agentMessage.Metadata);
+ Message message = Assert.Single(events.Messages);
+ Assert.Null(message.Metadata);
}
///
- /// Verifies that when the agent response has empty AdditionalProperties, the returned AgentMessage.Metadata is null.
+ /// Verifies that when the agent response has empty AdditionalProperties, the returned Message.Metadata is null.
///
[Fact]
- public async Task MapA2A_WhenResponseHasEmptyAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync()
+ public async Task ExecuteAsync_WhenResponseHasEmptyAdditionalProperties_ReturnsMessageWithNullMetadataAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
{
AdditionalProperties = []
};
- ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
+ IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ var events = await CollectEventsAsync(handler, new RequestContext
{
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
+ TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
- AgentMessage agentMessage = Assert.IsType(a2aResponse);
- Assert.Null(agentMessage.Metadata);
+ Message message = Assert.Single(events.Messages);
+ Assert.Null(message.Metadata);
}
///
- /// Verifies that when runMode is Message, the result is always an AgentMessage even when
- /// the agent would otherwise support background responses.
+ /// Verifies that when runMode is DisallowBackground, AllowBackgroundResponses is false.
///
[Fact]
- public async Task MapA2A_MessageMode_AlwaysReturnsAgentMessageAsync()
+ public async Task ExecuteAsync_DisallowBackgroundMode_SetsAllowBackgroundResponsesFalseAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
- ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options)
+ IAgentHandler handler = CreateAgentMock(options => capturedOptions = options)
.Object.MapA2A(runMode: AgentRunMode.DisallowBackground);
// Act
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ await InvokeExecuteAsync(handler, new RequestContext
{
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
+ TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
- Assert.IsType(a2aResponse);
Assert.NotNull(capturedOptions);
Assert.False(capturedOptions.AllowBackgroundResponses);
}
///
- /// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken),
- /// the result is an AgentMessage because the response type is determined solely by ContinuationToken presence.
+ /// Verifies that in AllowBackgroundIfSupported mode, AllowBackgroundResponses is true.
///
[Fact]
- public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageAsync()
+ public async Task ExecuteAsync_AllowBackgroundIfSupportedMode_SetsAllowBackgroundResponsesTrueAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
- ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options)
+ IAgentHandler handler = CreateAgentMock(options => capturedOptions = options)
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported);
// Act
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ await InvokeExecuteAsync(handler, new RequestContext
{
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
+ TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
- Assert.IsType(a2aResponse);
Assert.NotNull(capturedOptions);
Assert.True(capturedOptions.AllowBackgroundResponses);
}
///
- /// Verifies that a custom Dynamic delegate returning false produces an AgentMessage
- /// even when the agent completes immediately (no ContinuationToken).
+ /// Verifies that a custom Dynamic delegate returning false sets AllowBackgroundResponses to false.
///
[Fact]
- public async Task MapA2A_DynamicMode_WithFalseCallback_ReturnsAgentMessageAsync()
+ public async Task ExecuteAsync_DynamicMode_WithFalseCallback_SetsAllowBackgroundResponsesFalseAsync()
{
// Arrange
- AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Quick reply")]);
- ITaskManager taskManager = CreateAgentMockWithResponse(response)
+ AgentRunOptions? capturedOptions = null;
+ IAgentHandler handler = CreateAgentMock(options => capturedOptions = options)
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false)));
// Act
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ await InvokeExecuteAsync(handler, new RequestContext
{
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
+ TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
- Assert.IsType(a2aResponse);
+ Assert.NotNull(capturedOptions);
+ Assert.False(capturedOptions.AllowBackgroundResponses);
}
-#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
-
///
- /// Verifies that when the agent returns a ContinuationToken, an AgentTask in Working state is returned.
+ /// Verifies that a custom Dynamic delegate returning true sets AllowBackgroundResponses to true.
///
[Fact]
- public async Task MapA2A_WhenResponseHasContinuationToken_ReturnsAgentTaskInWorkingStateAsync()
+ public async Task ExecuteAsync_DynamicMode_WithTrueCallback_SetsAllowBackgroundResponsesTrueAsync()
{
// Arrange
- AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
- {
- ContinuationToken = CreateTestContinuationToken()
- };
- ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
+ AgentRunOptions? capturedOptions = null;
+ IAgentHandler handler = CreateAgentMock(options => capturedOptions = options)
+ .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)));
// Act
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ await InvokeExecuteAsync(handler, new RequestContext
{
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
+ TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
// Assert
- AgentTask agentTask = Assert.IsType(a2aResponse);
- Assert.Equal(TaskState.Working, agentTask.Status.State);
+ Assert.NotNull(capturedOptions);
+ Assert.True(capturedOptions.AllowBackgroundResponses);
}
- ///
- /// Verifies that when the agent returns a ContinuationToken, the returned task includes
- /// intermediate messages from the initial response in its status message.
- ///
- [Fact]
- public async Task MapA2A_WhenResponseHasContinuationToken_TaskStatusHasIntermediateMessageAsync()
- {
- // Arrange
- AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
- {
- ContinuationToken = CreateTestContinuationToken()
- };
- ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
-
- // Act
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
- {
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
- });
-
- // Assert
- AgentTask agentTask = Assert.IsType(a2aResponse);
- Assert.NotNull(agentTask.Status.Message);
- TextPart textPart = Assert.IsType(Assert.Single(agentTask.Status.Message.Parts));
- Assert.Equal("Starting work...", textPart.Text);
- }
+#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
///
- /// Verifies that when the agent returns a ContinuationToken, the continuation token
- /// is serialized into the AgentTask.Metadata for persistence.
+ /// Verifies that when the agent returns a ContinuationToken, task status events are emitted.
///
[Fact]
- public async Task MapA2A_WhenResponseHasContinuationToken_StoresTokenInTaskMetadataAsync()
+ public async Task ExecuteAsync_WhenResponseHasContinuationToken_EmitsTaskStatusEventsAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
{
ContinuationToken = CreateTestContinuationToken()
};
- ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
+ IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ var events = await CollectEventsAsync(handler, new RequestContext
{
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
+ StreamingResponse = false,
+ TaskId = "task-1",
+ ContextId = "ctx-1",
+ Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
});
- // Assert
- AgentTask agentTask = Assert.IsType(a2aResponse);
- Assert.NotNull(agentTask.Metadata);
- Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
+ // Assert - should have emitted status update events (Submitted + Working)
+ Assert.True(events.StatusUpdates.Count >= 1);
+ Assert.Empty(events.Messages);
}
///
- /// Verifies that when a task is created (Working or Completed), the original user message
- /// is added to the task history, matching the A2A SDK's behavior when it creates tasks internally.
+ /// Verifies that when the incoming message has a ContextId, it is used for the response
+ /// rather than generating a new one.
///
[Fact]
- public async Task MapA2A_WhenTaskIsCreated_OriginalMessageIsInHistoryAsync()
+ public async Task ExecuteAsync_WhenMessageHasContextId_UsesProvidedContextIdAsync()
{
// Arrange
- AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
- {
- ContinuationToken = CreateTestContinuationToken()
- };
- ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
- AgentMessage originalMessage = new() { MessageId = "user-msg-1", Role = MessageRole.User, Parts = [new TextPart { Text = "Do something" }] };
+ AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
+ IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ var events = await CollectEventsAsync(handler, new RequestContext
{
- Message = originalMessage
+ StreamingResponse = false,
+ TaskId = "",
+ ContextId = "my-context-123",
+ Message = new Message
+ {
+ MessageId = "test-id",
+ ContextId = "my-context-123",
+ Role = Role.User,
+ Parts = [new Part { Text = "Hello" }]
+ }
});
// Assert
- AgentTask agentTask = Assert.IsType(a2aResponse);
- Assert.NotNull(agentTask.History);
- Assert.Contains(agentTask.History, m => m.MessageId == "user-msg-1" && m.Role == MessageRole.User);
+ Message message = Assert.Single(events.Messages);
+ Assert.Equal("my-context-123", message.ContextId);
}
///
- /// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken),
- /// the returned AgentMessage preserves the original context ID.
+ /// Verifies that on continuation when the agent completes (no ContinuationToken), task is completed with artifact.
///
[Fact]
- public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageWithContextIdAsync()
+ public async Task ExecuteAsync_OnContinuation_WhenComplete_EmitsArtifactAndCompletedAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]);
- ITaskManager taskManager = CreateAgentMockWithResponse(response)
- .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported);
- AgentMessage originalMessage = new() { MessageId = "user-msg-2", ContextId = "ctx-123", Role = MessageRole.User, Parts = [new TextPart { Text = "Quick task" }] };
+ IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ var events = await CollectEventsAsync(handler, new RequestContext
{
- Message = originalMessage
- });
-
- // Assert
- AgentMessage agentMessage = Assert.IsType(a2aResponse);
- Assert.Equal("ctx-123", agentMessage.ContextId);
- }
+ StreamingResponse = false,
+ Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
+ TaskId = "task-1",
+ ContextId = "ctx-1",
- ///
- /// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token
- /// and the agent returns a completed response (null ContinuationToken), the task is updated to Completed.
- ///
- [Fact]
- public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationCompletes_TaskIsCompletedAsync()
- {
- // Arrange
- int callCount = 0;
- Mock agentMock = CreateAgentMockWithSequentialResponses(
- // First call: return response with ContinuationToken (long-running)
- new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
- {
- ContinuationToken = CreateTestContinuationToken()
- },
- // Second call (via OnTaskUpdated): return completed response
- new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]),
- ref callCount);
- ITaskManager taskManager = agentMock.Object.MapA2A();
-
- // Act — trigger OnMessageReceived to create the task
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
- {
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
+ Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
});
- AgentTask agentTask = Assert.IsType(a2aResponse);
- Assert.Equal(TaskState.Working, agentTask.Status.State);
- // Act — invoke OnTaskUpdated to check on the background operation
- await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
-
- // Assert — task should now be completed
- AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
- Assert.NotNull(updatedTask);
- Assert.Equal(TaskState.Completed, updatedTask.Status.State);
- Assert.NotNull(updatedTask.Artifacts);
- Artifact artifact = Assert.Single(updatedTask.Artifacts);
- TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts));
- Assert.Equal("Done!", textPart.Text);
+ // Assert - should have artifact + completed status
+ Assert.True(events.ArtifactUpdates.Count > 0);
+ Assert.True(events.StatusUpdates.Count > 0);
+ Assert.Empty(events.Messages);
}
///
- /// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token
- /// and the agent returns another ContinuationToken, the task stays in Working state.
+ /// Verifies that when the agent throws during a continuation,
+ /// the handler emits a Failed status and re-throws the exception.
///
[Fact]
- public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationStillWorking_TaskRemainsWorkingAsync()
+ public async Task ExecuteAsync_OnContinuation_WhenAgentThrows_EmitsFailedStatusAsync()
{
// Arrange
int callCount = 0;
- Mock agentMock = CreateAgentMockWithSequentialResponses(
- // First call: return response with ContinuationToken
- new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
- {
- ContinuationToken = CreateTestContinuationToken()
- },
- // Second call (via OnTaskUpdated): still working, return another token
- new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")])
- {
- ContinuationToken = CreateTestContinuationToken()
- },
- ref callCount);
- ITaskManager taskManager = agentMock.Object.MapA2A();
-
- // Act — trigger OnMessageReceived to create the task
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
- {
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
- });
- AgentTask agentTask = Assert.IsType(a2aResponse);
+ Mock agentMock = CreateAgentMockWithCallCount(ref callCount, _ =>
+ throw new InvalidOperationException("Agent failed"));
+ IAgentHandler handler = agentMock.Object.MapA2A();
+
+ // Act & Assert
+ var events = new EventCollector();
+ var eventQueue = new AgentEventQueue();
+ var readerTask = ReadEventsAsync(eventQueue, events);
+ await Assert.ThrowsAsync(() =>
+ handler.ExecuteAsync(
+ new RequestContext
+ {
+ StreamingResponse = false,
+ Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
+ TaskId = "task-1",
+ ContextId = "ctx-1",
- // Act — invoke OnTaskUpdated; agent still working
- await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
+ Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
+ },
+ eventQueue,
+ CancellationToken.None));
+ eventQueue.Complete(null);
+ await readerTask;
- // Assert — task should still be in Working state
- AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
- Assert.NotNull(updatedTask);
- Assert.Equal(TaskState.Working, updatedTask.Status.State);
+ // Assert - should have emitted Failed status
+ Assert.True(events.StatusUpdates.Count > 0);
}
///
- /// Verifies the full lifecycle: agent starts background work, first poll returns still working,
- /// second poll returns completed.
+ /// Verifies that when the agent throws OperationCanceledException during a continuation,
+ /// no Failed status is emitted.
///
[Fact]
- public async Task MapA2A_OnTaskUpdated_MultiplePolls_EventuallyCompletesAsync()
+ public async Task ExecuteAsync_OnContinuation_WhenOperationCancelled_DoesNotEmitFailedAsync()
{
// Arrange
int callCount = 0;
- Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
- {
- return invocation switch
- {
- // First call: start background work
- 1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
- {
- ContinuationToken = CreateTestContinuationToken()
- },
- // Second call: still working
- 2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")])
+ Mock agentMock = CreateAgentMockWithCallCount(ref callCount, _ =>
+ throw new OperationCanceledException("Cancelled"));
+ IAgentHandler handler = agentMock.Object.MapA2A();
+
+ // Act & Assert
+ var events = new EventCollector();
+ var eventQueue = new AgentEventQueue();
+ var readerTask = ReadEventsAsync(eventQueue, events);
+ await Assert.ThrowsAsync(() =>
+ handler.ExecuteAsync(
+ new RequestContext
{
- ContinuationToken = CreateTestContinuationToken()
+ StreamingResponse = false,
+ Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
+ TaskId = "task-1",
+ ContextId = "ctx-1",
+
+ Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
},
- // Third call: done
- _ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "All done!")])
- };
- });
- ITaskManager taskManager = agentMock.Object.MapA2A();
+ eventQueue,
+ CancellationToken.None));
+ eventQueue.Complete(null);
+ await readerTask;
- // Act — create the task
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
- {
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Do work" }] }
- });
- AgentTask agentTask = Assert.IsType(a2aResponse);
- Assert.Equal(TaskState.Working, agentTask.Status.State);
-
- // Act — first poll: still working
- AgentTask? currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
- Assert.NotNull(currentTask);
- await InvokeOnTaskUpdatedAsync(taskManager, currentTask);
- currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
- Assert.NotNull(currentTask);
- Assert.Equal(TaskState.Working, currentTask.Status.State);
-
- // Act — second poll: completed
- await InvokeOnTaskUpdatedAsync(taskManager, currentTask);
- currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
- Assert.NotNull(currentTask);
- Assert.Equal(TaskState.Completed, currentTask.Status.State);
-
- // Assert — final output as artifact
- Assert.NotNull(currentTask.Artifacts);
- Artifact artifact = Assert.Single(currentTask.Artifacts);
- TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts));
- Assert.Equal("All done!", textPart.Text);
+ // Assert - should NOT have emitted any status (OperationCanceledException is re-thrown without marking Failed)
+ Assert.Empty(events.StatusUpdates);
}
///
- /// Verifies that when the agent throws during a background operation poll,
- /// the task is updated to Failed state.
+ /// Verifies that ReferenceTaskIds throws NotSupportedException.
///
[Fact]
- public async Task MapA2A_OnTaskUpdated_WhenAgentThrows_TaskIsFailedAsync()
+ public async Task ExecuteAsync_WithReferenceTaskIds_ThrowsNotSupportedExceptionAsync()
{
// Arrange
- int callCount = 0;
- Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
- {
- if (invocation == 1)
+ IAgentHandler handler = CreateAgentMock(_ => { }).Object.MapA2A();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ InvokeExecuteAsync(handler, new RequestContext
{
- return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
+ TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message
{
- ContinuationToken = CreateTestContinuationToken()
- };
- }
-
- throw new InvalidOperationException("Agent failed");
- });
- ITaskManager taskManager = agentMock.Object.MapA2A();
-
- // Act — create the task
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
- {
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
- });
- AgentTask agentTask = Assert.IsType(a2aResponse);
-
- // Act — poll the task; agent throws
- await Assert.ThrowsAsync(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask));
-
- // Assert — task should be Failed
- AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
- Assert.NotNull(updatedTask);
- Assert.Equal(TaskState.Failed, updatedTask.Status.State);
+ MessageId = "test-id",
+ Role = Role.User,
+ Parts = [new Part { Text = "Hello" }],
+ ReferenceTaskIds = ["other-task-id"]
+ }
+ }));
}
///
- /// Verifies that in Task mode with a ContinuationToken, the result is an AgentTask in Working state.
+ /// Verifies that when ContextId is null, a new one is generated and used in the response.
///
[Fact]
- public async Task MapA2A_TaskMode_WhenContinuationToken_ReturnsWorkingAgentTaskAsync()
+ public async Task ExecuteAsync_WhenContextIdIsNull_GeneratesContextIdAsync()
{
// Arrange
- AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Working on it...")])
- {
- ContinuationToken = CreateTestContinuationToken()
- };
- ITaskManager taskManager = CreateAgentMockWithResponse(response)
- .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported);
+ AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
+ IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ var events = await CollectEventsAsync(handler, new RequestContext
{
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
+ StreamingResponse = false,
+ TaskId = "",
+ ContextId = null!,
+ Message = new Message
+ {
+ MessageId = "test-id",
+ Role = Role.User,
+ Parts = [new Part { Text = "Hello" }]
+ }
});
// Assert
- AgentTask agentTask = Assert.IsType(a2aResponse);
- Assert.Equal(TaskState.Working, agentTask.Status.State);
- Assert.NotNull(agentTask.Metadata);
- Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
+ Message message = Assert.Single(events.Messages);
+ Assert.NotNull(message.ContextId);
+ Assert.NotEmpty(message.ContextId);
}
///
- /// Verifies that when the agent returns a ContinuationToken with no progress messages,
- /// the task transitions to Working state with a null status message.
+ /// Verifies that when Message is null, the handler still succeeds with empty chat messages.
///
[Fact]
- public async Task MapA2A_WhenContinuationTokenWithNoMessages_TaskStatusHasNullMessageAsync()
+ public async Task ExecuteAsync_WhenMessageIsNull_SucceedsWithEmptyMessagesAsync()
{
// Arrange
- AgentResponse response = new([])
- {
- ContinuationToken = CreateTestContinuationToken()
- };
- ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
+ AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
+ IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ var events = await CollectEventsAsync(handler, new RequestContext
{
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
+ StreamingResponse = false,
+ TaskId = "",
+ ContextId = "ctx",
+ Message = null!
});
// Assert
- AgentTask agentTask = Assert.IsType(a2aResponse);
- Assert.Equal(TaskState.Working, agentTask.Status.State);
- Assert.Null(agentTask.Status.Message);
+ Message message = Assert.Single(events.Messages);
+ Assert.Equal("ctx", message.ContextId);
}
///
- /// Verifies that when OnTaskUpdated is invoked on a completed task with a follow-up message
- /// and no continuation token in metadata, the task processes history and completes with a new artifact.
+ /// Verifies that the dynamic AllowBackgroundWhen delegate receives the correct RequestContext.
///
[Fact]
- public async Task MapA2A_OnTaskUpdated_WhenNoContinuationToken_ProcessesHistoryAndCompletesAsync()
+ public async Task ExecuteAsync_DynamicMode_DelegateReceivesRequestContextAsync()
{
// Arrange
- int callCount = 0;
- Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
- {
- return invocation switch
+ A2ARunDecisionContext? capturedContext = null;
+ IAgentHandler handler = CreateAgentMock(_ => { })
+ .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((ctx, _) =>
{
- // First call: create a task with ContinuationToken
- 1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
- {
- ContinuationToken = CreateTestContinuationToken()
- },
- // Second call (via OnTaskUpdated): complete the background operation
- 2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]),
- // Third call (follow-up via OnTaskUpdated): complete follow-up
- _ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Follow-up done!")])
- };
- });
- ITaskManager taskManager = agentMock.Object.MapA2A();
+ capturedContext = ctx;
+ return ValueTask.FromResult(false);
+ }));
- // Act — create a working task (with continuation token)
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ var requestContext = new RequestContext
{
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
- });
- AgentTask agentTask = Assert.IsType(a2aResponse);
-
- // Act — first OnTaskUpdated: completes the background operation
- await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
- agentTask = (await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None))!;
- Assert.Equal(TaskState.Completed, agentTask.Status.State);
-
- // Simulate a follow-up message by adding it to history and re-submitting via OnTaskUpdated
- agentTask.History ??= [];
- agentTask.History.Add(new AgentMessage { MessageId = "follow-up", Role = MessageRole.User, Parts = [new TextPart { Text = "Follow up" }] });
-
- // Act — invoke OnTaskUpdated without a continuation token in metadata
- await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
-
- // Assert
- AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
- Assert.NotNull(updatedTask);
- Assert.Equal(TaskState.Completed, updatedTask.Status.State);
- Assert.NotNull(updatedTask.Artifacts);
- Assert.Equal(2, updatedTask.Artifacts.Count);
- Artifact artifact = updatedTask.Artifacts[1];
- TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts));
- Assert.Equal("Follow-up done!", textPart.Text);
- }
-
- ///
- /// Verifies that when a task is cancelled, the continuation token is removed from metadata.
- ///
- [Fact]
- public async Task MapA2A_OnTaskCancelled_RemovesContinuationTokenFromMetadataAsync()
- {
- // Arrange
- AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting...")])
- {
- ContinuationToken = CreateTestContinuationToken()
+ TaskId = "my-task", ContextId = "my-ctx", StreamingResponse = false,
+ Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
};
- ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
-
- // Act — create a working task with a continuation token
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
- {
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
- });
- AgentTask agentTask = Assert.IsType(a2aResponse);
- Assert.NotNull(agentTask.Metadata);
- Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
-
- // Act — cancel the task
- await taskManager.CancelTaskAsync(new TaskIdParams { Id = agentTask.Id }, CancellationToken.None);
-
- // Assert — continuation token should be removed from metadata
- Assert.False(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
- }
-
- ///
- /// Verifies that when the agent throws an OperationCanceledException during a poll,
- /// it is re-thrown without marking the task as Failed.
- ///
- [Fact]
- public async Task MapA2A_OnTaskUpdated_WhenOperationCancelled_DoesNotMarkFailedAsync()
- {
- // Arrange
- int callCount = 0;
- Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
- {
- if (invocation == 1)
- {
- return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
- {
- ContinuationToken = CreateTestContinuationToken()
- };
- }
-
- throw new OperationCanceledException("Cancelled");
- });
- ITaskManager taskManager = agentMock.Object.MapA2A();
-
- // Act — create the task
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
- {
- Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
- });
- AgentTask agentTask = Assert.IsType(a2aResponse);
- // Act — poll the task; agent throws OperationCanceledException
- await Assert.ThrowsAsync(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask));
+ // Act
+ await InvokeExecuteAsync(handler, requestContext);
- // Assert — task should still be Working, not Failed
- AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
- Assert.NotNull(updatedTask);
- Assert.Equal(TaskState.Working, updatedTask.Status.State);
+ // Assert
+ Assert.NotNull(capturedContext);
+ Assert.Same(requestContext, capturedContext.RequestContext);
}
///
- /// Verifies that when the incoming message has a ContextId, it is used for the task
- /// rather than generating a new one.
+ /// Verifies that CancelAsync emits a Canceled status event.
///
[Fact]
- public async Task MapA2A_WhenMessageHasContextId_UsesProvidedContextIdAsync()
+ public async Task CancelAsync_EmitsCanceledStatusAsync()
{
// Arrange
- AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
- ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
+ IAgentHandler handler = CreateAgentMock(_ => { }).Object.MapA2A();
+ var events = new EventCollector();
+ var eventQueue = new AgentEventQueue();
+ var readerTask = ReadEventsAsync(eventQueue, events);
// Act
- A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
- {
- Message = new AgentMessage
+ await handler.CancelAsync(
+ new RequestContext
{
- MessageId = "test-id",
- ContextId = "my-context-123",
- Role = MessageRole.User,
- Parts = [new TextPart { Text = "Hello" }]
- }
- });
+ StreamingResponse = false,
+ Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
+ TaskId = "task-1",
+ ContextId = "ctx-1",
+ Task = new AgentTask { Id = "task-1", ContextId = "ctx-1" }
+ },
+ eventQueue,
+ CancellationToken.None);
// Assert
- AgentMessage agentMessage = Assert.IsType(a2aResponse);
- Assert.Equal("my-context-123", agentMessage.ContextId);
+ eventQueue.Complete(null);
+ await readerTask;
+ Assert.True(events.StatusUpdates.Count > 0);
}
#pragma warning restore MEAI001
@@ -803,49 +576,211 @@ private static Mock CreateAgentMockWithResponse(AgentResponse response)
return agentMock;
}
- private static async Task InvokeOnMessageReceivedAsync(ITaskManager taskManager, MessageSendParams messageSendParams)
+ private static Mock CreateAgentMockWithCallCount(
+ ref int callCount,
+ Func responseFactory)
{
- Func>? handler = taskManager.OnMessageReceived;
- Assert.NotNull(handler);
- return await handler.Invoke(messageSendParams, CancellationToken.None);
+ StrongBox callCountBox = new(callCount);
+
+ Mock agentMock = new() { CallBase = true };
+ agentMock.SetupGet(x => x.Name).Returns("TestAgent");
+ agentMock
+ .Protected()
+ .Setup>("CreateSessionCoreAsync", ItExpr.IsAny())
+ .ReturnsAsync(new TestAgentSession());
+ agentMock
+ .Protected()
+ .Setup>("RunCoreAsync",
+ ItExpr.IsAny>(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(() =>
+ {
+ int currentCall = Interlocked.Increment(ref callCountBox.Value);
+ return responseFactory(currentCall);
+ });
+
+ return agentMock;
}
- private static async Task InvokeOnTaskUpdatedAsync(ITaskManager taskManager, AgentTask agentTask)
+ private static async Task InvokeExecuteAsync(IAgentHandler handler, RequestContext context)
{
- Func? handler = taskManager.OnTaskUpdated;
- Assert.NotNull(handler);
- await handler.Invoke(agentTask, CancellationToken.None);
+ var eventQueue = new AgentEventQueue();
+ await handler.ExecuteAsync(context, eventQueue, CancellationToken.None);
+ eventQueue.Complete(null);
}
-#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+ private static async Task CollectEventsAsync(IAgentHandler handler, RequestContext context)
+ {
+ var events = new EventCollector();
+ var eventQueue = new AgentEventQueue();
+ var readerTask = ReadEventsAsync(eventQueue, events);
+
+ await handler.ExecuteAsync(context, eventQueue, CancellationToken.None);
+ eventQueue.Complete(null);
+ await readerTask;
+
+ return events;
+ }
+
+ private static async Task ReadEventsAsync(AgentEventQueue eventQueue, EventCollector collector)
+ {
+ await foreach (var response in eventQueue)
+ {
+ switch (response.PayloadCase)
+ {
+ case StreamResponseCase.Message:
+ collector.Messages.Add(response.Message!);
+ break;
+ case StreamResponseCase.Task:
+ collector.Tasks.Add(response.Task!);
+ break;
+ case StreamResponseCase.StatusUpdate:
+ collector.StatusUpdates.Add(response.StatusUpdate!);
+ break;
+ case StreamResponseCase.ArtifactUpdate:
+ collector.ArtifactUpdates.Add(response.ArtifactUpdate!);
+ break;
+ }
+ }
+ }
+
+#pragma warning disable MEAI001
private static ResponseContinuationToken CreateTestContinuationToken()
{
return ResponseContinuationToken.FromBytes(new byte[] { 0x01, 0x02, 0x03 });
}
#pragma warning restore MEAI001
- private static Mock CreateAgentMockWithSequentialResponses(
- AgentResponse firstResponse,
- AgentResponse secondResponse,
- ref int callCount)
+ private sealed class EventCollector
{
- return CreateAgentMockWithCallCount(ref callCount, invocation =>
- invocation == 1 ? firstResponse : secondResponse);
+ public List Messages { get; } = [];
+ public List Tasks { get; } = [];
+ public List StatusUpdates { get; } = [];
+ public List ArtifactUpdates { get; } = [];
}
- private static Mock CreateAgentMockWithCallCount(
- ref int callCount,
- Func responseFactory)
+ private sealed class TestAgentSession : AgentSession;
+
+ ///
+ /// Verifies that when no session store is provided, MapA2A uses InMemoryAgentSessionStore
+ /// and the handler can execute successfully.
+ ///
+ [Fact]
+ public async Task MapA2A_WithNullSessionStore_UsesInMemorySessionStoreAndExecutesSuccessfullyAsync()
{
- // Use a StrongBox to allow the lambda to capture a mutable reference
- StrongBox callCountBox = new(callCount);
+ // Arrange
+ AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
+ IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(agentSessionStore: null);
+
+ // Act
+ var events = await CollectEventsAsync(handler, new RequestContext
+ {
+ StreamingResponse = false,
+ TaskId = "",
+ ContextId = "ctx-1",
+ Message = new Message
+ {
+ MessageId = "test-id",
+ Role = Role.User,
+ Parts = [new Part { Text = "Hello" }]
+ }
+ });
+
+ // Assert
+ Message message = Assert.Single(events.Messages);
+ Assert.Equal("Reply", message.Parts![0].Text);
+ }
+
+ ///
+ /// Verifies that when a custom session store is provided, it is used instead of the
+ /// default InMemoryAgentSessionStore.
+ ///
+ [Fact]
+ public async Task MapA2A_WithCustomSessionStore_UsesProvidedSessionStoreAsync()
+ {
+ // Arrange
+ var mockSessionStore = new Mock();
+ mockSessionStore
+ .Setup(x => x.GetSessionAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new TestAgentSession());
+ mockSessionStore
+ .Setup(x => x.SaveSessionAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(ValueTask.CompletedTask);
+
+ AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
+ IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(agentSessionStore: mockSessionStore.Object);
+
+ // Act
+ await InvokeExecuteAsync(handler, new RequestContext
+ {
+ StreamingResponse = false,
+ TaskId = "",
+ ContextId = "ctx-1",
+ Message = new Message
+ {
+ MessageId = "test-id",
+ Role = Role.User,
+ Parts = [new Part { Text = "Hello" }]
+ }
+ });
+
+ // Assert - verify the custom session store was called
+ mockSessionStore.Verify(
+ x => x.GetSessionAsync(
+ It.IsAny(),
+ It.Is(s => s == "ctx-1"),
+ It.IsAny()),
+ Times.Once);
+ mockSessionStore.Verify(
+ x => x.SaveSessionAsync(
+ It.IsAny(),
+ It.Is(s => s == "ctx-1"),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Once);
+ }
+
+ ///
+ /// Verifies that when no session store is provided, the default InMemoryAgentSessionStore
+ /// persists sessions across multiple calls with the same context ID.
+ ///
+ [Fact]
+ public async Task MapA2A_WithNullSessionStore_SessionIsPersistedAcrossCallsAsync()
+ {
+ // Arrange - track how many times CreateSessionCoreAsync is called
+ int createSessionCallCount = 0;
+ var sessionInstance = new TestAgentSession();
Mock agentMock = new() { CallBase = true };
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
agentMock
.Protected()
.Setup>("CreateSessionCoreAsync", ItExpr.IsAny())
- .ReturnsAsync(new TestAgentSession());
+ .Callback(() => Interlocked.Increment(ref createSessionCallCount))
+ .ReturnsAsync(() => new TestAgentSession());
+ agentMock
+ .Protected()
+ .Setup>("SerializeSessionCoreAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(System.Text.Json.JsonDocument.Parse("{}").RootElement);
+ agentMock
+ .Protected()
+ .Setup>("DeserializeSessionCoreAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(sessionInstance);
agentMock
.Protected()
.Setup>("RunCoreAsync",
@@ -853,14 +788,110 @@ private static Mock CreateAgentMockWithCallCount(
ItExpr.IsAny(),
ItExpr.IsAny(),
ItExpr.IsAny())
- .ReturnsAsync(() =>
+ .ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Reply")]));
+
+ IAgentHandler handler = agentMock.Object.MapA2A(agentSessionStore: null);
+
+ var context = new RequestContext
+ {
+ StreamingResponse = false,
+ TaskId = "",
+ ContextId = "ctx-persistent",
+ Message = new Message
{
- int currentCall = Interlocked.Increment(ref callCountBox.Value);
- return responseFactory(currentCall);
- });
+ MessageId = "test-id",
+ Role = Role.User,
+ Parts = [new Part { Text = "Hello" }]
+ }
+ };
- return agentMock;
+ // Act - call twice with the same context ID
+ await InvokeExecuteAsync(handler, context);
+ await InvokeExecuteAsync(handler, context);
+
+ // Assert - CreateSessionCoreAsync should be called once (first call creates, second retrieves from store)
+ Assert.Equal(1, createSessionCallCount);
}
- private sealed class TestAgentSession : AgentSession;
+ ///
+ /// Verifies that when the AllowBackgroundWhen delegate throws, the exception propagates
+ /// and the agent is not invoked.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_DynamicMode_WhenCallbackThrows_PropagatesExceptionAsync()
+ {
+ // Arrange
+ bool agentInvoked = false;
+ IAgentHandler handler = CreateAgentMock(_ => agentInvoked = true)
+ .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, _) =>
+ throw new InvalidOperationException("Callback failed")));
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() =>
+ InvokeExecuteAsync(handler, new RequestContext
+ {
+ TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
+ }));
+
+ Assert.False(agentInvoked);
+ }
+
+ ///
+ /// Verifies that the CancellationToken is propagated to the AllowBackgroundWhen delegate.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_DynamicMode_CancellationTokenIsPropagatedToCallbackAsync()
+ {
+ // Arrange
+ CancellationToken capturedToken = default;
+ using var cts = new CancellationTokenSource();
+ IAgentHandler handler = CreateAgentMock(_ => { })
+ .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, ct) =>
+ {
+ capturedToken = ct;
+ return ValueTask.FromResult(false);
+ }));
+
+ // Act
+ var eventQueue = new AgentEventQueue();
+ await handler.ExecuteAsync(
+ new RequestContext
+ {
+ TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
+ },
+ eventQueue,
+ cts.Token);
+ eventQueue.Complete(null);
+
+ // Assert
+ Assert.Equal(cts.Token, capturedToken);
+ }
+
+ ///
+ /// Verifies that the agent run mode is applied on the continuation/task-update path,
+ /// not just the new message path.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_OnContinuation_RunModeIsAppliedAsync()
+ {
+ // Arrange
+ AgentRunOptions? capturedOptions = null;
+ IAgentHandler handler = CreateAgentMock(options => capturedOptions = options)
+ .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported);
+
+ // Act
+ await InvokeExecuteAsync(handler, new RequestContext
+ {
+ StreamingResponse = false,
+ TaskId = "task-1",
+ ContextId = "ctx-1",
+ Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
+
+ Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
+ });
+
+ // Assert
+ Assert.NotNull(capturedOptions);
+ Assert.True(capturedOptions.AllowBackgroundResponses);
+ }
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs
new file mode 100644
index 0000000000..6128e0eefc
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs
@@ -0,0 +1,148 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using A2A;
+
+namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
+
+///
+/// Unit tests for the class.
+///
+public sealed class AgentRunModeTests
+{
+ ///
+ /// Verifies that AllowBackgroundWhen throws ArgumentNullException for null delegate.
+ ///
+ [Fact]
+ public void AllowBackgroundWhen_NullDelegate_ThrowsArgumentNullException()
+ {
+ // Arrange & Act & Assert
+ Assert.Throws(() =>
+ AgentRunMode.AllowBackgroundWhen(null!));
+ }
+
+ ///
+ /// Verifies that DisallowBackground equals another DisallowBackground instance.
+ ///
+ [Fact]
+ public void Equals_DisallowBackground_AreEqual()
+ {
+ // Arrange
+ var mode1 = AgentRunMode.DisallowBackground;
+ var mode2 = AgentRunMode.DisallowBackground;
+
+ // Act & Assert
+ Assert.True(mode1.Equals(mode2));
+ Assert.True(mode1 == mode2);
+ Assert.False(mode1 != mode2);
+ Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode());
+ }
+
+ ///
+ /// Verifies that AllowBackgroundIfSupported equals another AllowBackgroundIfSupported instance.
+ ///
+ [Fact]
+ public void Equals_AllowBackgroundIfSupported_AreEqual()
+ {
+ // Arrange
+ var mode1 = AgentRunMode.AllowBackgroundIfSupported;
+ var mode2 = AgentRunMode.AllowBackgroundIfSupported;
+
+ // Act & Assert
+ Assert.True(mode1.Equals(mode2));
+ Assert.True(mode1 == mode2);
+ }
+
+ ///
+ /// Verifies that DisallowBackground and AllowBackgroundIfSupported are not equal.
+ ///
+ [Fact]
+ public void Equals_DifferentModes_AreNotEqual()
+ {
+ // Arrange
+ var disallow = AgentRunMode.DisallowBackground;
+ var allow = AgentRunMode.AllowBackgroundIfSupported;
+
+ // Act & Assert
+ Assert.False(disallow.Equals(allow));
+ Assert.False(disallow == allow);
+ Assert.True(disallow != allow);
+ }
+
+ ///
+ /// Verifies that Equals returns false for null.
+ ///
+ [Fact]
+ public void Equals_Null_ReturnsFalse()
+ {
+ // Arrange
+ var mode = AgentRunMode.DisallowBackground;
+
+ // Act & Assert
+ Assert.False(mode.Equals(null));
+ Assert.False(mode.Equals((object?)null));
+ Assert.False(mode == null);
+ Assert.True(mode != null);
+ }
+
+ ///
+ /// Verifies that two null AgentRunMode values are equal.
+ ///
+ [Fact]
+ public void Equals_BothNull_AreEqual()
+ {
+ // Arrange
+ AgentRunMode? mode1 = null;
+ AgentRunMode? mode2 = null;
+
+ // Act & Assert
+ Assert.True(mode1 == mode2);
+ Assert.False(mode1 != mode2);
+ }
+
+ ///
+ /// Verifies that ToString returns expected values.
+ ///
+ [Fact]
+ public void ToString_ReturnsExpectedValues()
+ {
+ // Act & Assert
+ Assert.Equal("message", AgentRunMode.DisallowBackground.ToString());
+ Assert.Equal("task", AgentRunMode.AllowBackgroundIfSupported.ToString());
+ Assert.Equal("dynamic", AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)).ToString());
+ }
+
+ ///
+ /// Verifies that Equals works correctly with object parameter.
+ ///
+ [Fact]
+ public void Equals_WithObjectParameter_WorksCorrectly()
+ {
+ // Arrange
+ var mode = AgentRunMode.DisallowBackground;
+
+ // Act & Assert
+ Assert.True(mode.Equals((object)AgentRunMode.DisallowBackground));
+ Assert.False(mode.Equals((object)AgentRunMode.AllowBackgroundIfSupported));
+ Assert.False(mode.Equals("not a run mode"));
+ }
+
+ ///
+ /// Verifies that two AllowBackgroundWhen instances with different delegates are considered equal,
+ /// because equality is based on the mode value ("dynamic"), not the delegate.
+ ///
+ [Fact]
+ public void Equals_AllowBackgroundWhen_DifferentDelegates_AreEqual()
+ {
+ // Arrange
+ var mode1 = AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true));
+ var mode2 = AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false));
+
+ // Act & Assert
+ Assert.True(mode1.Equals(mode2));
+ Assert.True(mode1 == mode2);
+ Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode());
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs
index 69eaf3a535..1106802463 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs
@@ -10,66 +10,66 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests.Converters;
public class MessageConverterTests
{
[Fact]
- public void ToChatMessages_MessageSendParams_Null_ReturnsEmptyCollection()
+ public void ToChatMessages_SendMessageRequest_Null_ReturnsEmptyCollection()
{
- MessageSendParams? messageSendParams = null;
+ SendMessageRequest? sendMessageRequest = null;
- var result = messageSendParams!.ToChatMessages();
+ var result = sendMessageRequest!.ToChatMessages();
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
- public void ToChatMessages_MessageSendParams_WithNullMessage_ReturnsEmptyCollection()
+ public void ToChatMessages_SendMessageRequest_WithNullMessage_ReturnsEmptyCollection()
{
- var messageSendParams = new MessageSendParams
+ var sendMessageRequest = new SendMessageRequest
{
Message = null!
};
- var result = messageSendParams.ToChatMessages();
+ var result = sendMessageRequest.ToChatMessages();
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
- public void ToChatMessages_MessageSendParams_WithMessageWithoutParts_ReturnsEmptyCollection()
+ public void ToChatMessages_SendMessageRequest_WithMessageWithoutParts_ReturnsEmptyCollection()
{
- var messageSendParams = new MessageSendParams
+ var sendMessageRequest = new SendMessageRequest
{
- Message = new AgentMessage
+ Message = new Message
{
MessageId = "test-id",
- Role = MessageRole.User,
+ Role = Role.User,
Parts = null!
}
};
- var result = messageSendParams.ToChatMessages();
+ var result = sendMessageRequest.ToChatMessages();
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
- public void ToChatMessages_MessageSendParams_WithValidTextMessage_ReturnsCorrectChatMessage()
+ public void ToChatMessages_SendMessageRequest_WithValidTextMessage_ReturnsCorrectChatMessage()
{
- var messageSendParams = new MessageSendParams
+ var sendMessageRequest = new SendMessageRequest
{
- Message = new AgentMessage
+ Message = new Message
{
MessageId = "test-id",
- Role = MessageRole.User,
+ Role = Role.User,
Parts =
[
- new TextPart { Text = "Hello, world!" }
+ new Part { Text = "Hello, world!" }
]
}
};
- var result = messageSendParams.ToChatMessages();
+ var result = sendMessageRequest.ToChatMessages();
Assert.NotNull(result);
Assert.Single(result);
From c54483f81efbe51ae71ea261344c8ef68609360a Mon Sep 17 00:00:00 2001
From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Date: Wed, 22 Apr 2026 10:38:48 +0100
Subject: [PATCH 06/12] .NET: Split A2A endpoint mapping into protocol-specific
methods (#5413)
* .NET: Refactor A2A hosting registration into A2AServerServiceCollectionExtensions
- Rename A2AHostingOptions to A2AServerRegistrationOptions
- Move server registration logic from A2AEndpointRouteBuilderExtensions
and AIAgentExtensions into new A2AServerServiceCollectionExtensions
- Remove A2AProtocolBinding and AIAgentExtensions (consolidated)
- Update samples and tests to use the new registration API
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* address copilot comments
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../A2AClientServer/A2AServer/Program.cs | 15 +-
.../AgentWebChat.AgentHost/Program.cs | 9 +-
.../A2AEndpointRouteBuilderExtensions.cs | 166 +++----
...ft.Agents.AI.Hosting.A2A.AspNetCore.csproj | 9 +-
.../A2AProtocolBinding.cs | 29 --
...ons.cs => A2AServerRegistrationOptions.cs} | 14 +-
.../A2AServerServiceCollectionExtensions.cs | 160 +++++++
.../AIAgentExtensions.cs | 40 --
...nsionsTests.cs => A2AAgentHandlerTests.cs} | 384 ++++++++--------
.../A2AEndpointRouteBuilderExtensionsTests.cs | 413 +++++-------------
10 files changed, 547 insertions(+), 692 deletions(-)
delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AProtocolBinding.cs
rename dotnet/src/Microsoft.Agents.AI.Hosting.A2A/{A2AHostingOptions.cs => A2AServerRegistrationOptions.cs} (63%)
create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs
delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs
rename dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/{AIAgentExtensionsTests.cs => A2AAgentHandlerTests.cs} (88%)
diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs
index 854a48535d..c12a1c9431 100644
--- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs
+++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs
@@ -3,7 +3,6 @@
using A2A.AspNetCore;
using A2AServer;
using Microsoft.Agents.AI;
-using Microsoft.Agents.AI.Hosting.A2A;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
@@ -26,10 +25,6 @@
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient().AddLogging();
-var app = builder.Build();
-
-var httpClient = app.Services.GetRequiredService().CreateClient();
-var logger = app.Logger;
IConfigurationRoot configuration = new ConfigurationBuilder()
.AddEnvironmentVariables()
@@ -39,7 +34,7 @@
string? apiKey = configuration["OPENAI_API_KEY"];
string model = configuration["OPENAI_CHAT_MODEL_NAME"] ?? "gpt-5.4-mini";
string? endpoint = configuration["AZURE_AI_PROJECT_ENDPOINT"];
-string[] agentUrls = (app.Configuration["urls"] ?? "http://localhost:5000").Split(';');
+string[] agentUrls = (builder.Configuration["urls"] ?? "http://localhost:5000").Split(';');
var invoiceQueryPlugin = new InvoiceQuery();
IList tools =
@@ -106,9 +101,11 @@ You specialize in handling queries related to logistics.
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
}
-app.MapA2A(
- hostA2AAgent,
- path: "/", protocolBindings: A2AProtocolBinding.JsonRpc | A2AProtocolBinding.HttpJson);
+builder.AddA2AServer(hostA2AAgent);
+
+var app = builder.Build();
+app.MapA2AHttpJson(hostA2AAgent, "/");
+app.MapA2AJsonRpc(hostA2AAgent, "/");
app.MapWellKnownAgentCard(hostA2AAgentCard);
diff --git a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs
index 1c5a1ba605..c18dbad3a4 100644
--- a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs
+++ b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs
@@ -147,6 +147,9 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te
instructions: "you are a dependency inject agent. Tell me all about dependency injection.");
});
+pirateAgentBuilder.AddA2AServer();
+knightsKnavesAgentBuilder.AddA2AServer();
+
var app = builder.Build();
app.MapOpenApi();
@@ -155,9 +158,9 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te
// Configure the HTTP request pipeline.
app.UseExceptionHandler();
-// attach a2a with simple message communication
-app.MapA2A(pirateAgentBuilder, path: "/a2a/pirate");
-app.MapA2A(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves");
+// Expose A2A servers over HTTP with JSON payloads
+app.MapA2AHttpJson(pirateAgentBuilder, path: "/a2a/pirate");
+app.MapA2AHttpJson(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves");
app.MapDevUI();
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs
index fd1ad6db20..a57ed07890 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs
@@ -6,183 +6,133 @@
using A2A.AspNetCore;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hosting;
-using Microsoft.Agents.AI.Hosting.A2A;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.AspNetCore.Builder;
///