diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 46e0d61924..8fedb11f7f 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -42,15 +42,15 @@
-
+
-
+
-
+
@@ -104,8 +104,8 @@
-
-
+
+
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index b3a95ea1f6..f1ade4eedc 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -344,11 +344,13 @@
-
-
-
-
-
+
+
+
+
+
+
+
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 86%
rename from dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj
rename to dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj
index 1bccc99d4f..d91b20e34b 100644
--- a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj
+++ b/dotnet/samples/02-agents/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/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/Program.cs b/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/Program.cs
similarity index 83%
rename from dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/Program.cs
rename to dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/Program.cs
index e1731604a9..9410785c39 100644
--- a/dotnet/samples/04-hosting/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/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/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/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/04-hosting/A2A/README.md b/dotnet/samples/02-agents/A2A/README.md
similarity index 77%
rename from dotnet/samples/04-hosting/A2A/README.md
rename to dotnet/samples/02-agents/A2A/README.md
index 55539a8322..28f2c0a910 100644
--- a/dotnet/samples/04-hosting/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
@@ -15,6 +15,8 @@ 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/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 e4661f3217..db2412b648 100644
--- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs
+++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs
@@ -14,7 +14,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
@@ -26,16 +26,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)
@@ -43,9 +43,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}"),
};
@@ -53,7 +53,7 @@ internal static class HostAgentFactory
}
#region private
- private static AgentCard GetInvoiceAgentCard()
+ private static AgentCard GetInvoiceAgentCard(string[] agentUrls)
{
var capabilities = new AgentCapabilities()
{
@@ -82,10 +82,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()
{
@@ -114,10 +115,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()
{
@@ -146,7 +148,29 @@ private static AgentCard GetLogisticsAgentCard()
DefaultOutputModes = ["text"],
Capabilities = capabilities,
Skills = [logisticsQuery],
+ SupportedInterfaces = CreateAgentInterfaces(agentUrls)
};
}
+
+ private static List CreateAgentInterfaces(string[] agentUrls)
+ {
+ List agentInterfaces = [];
+
+ agentInterfaces.AddRange(agentUrls.Select(url => new AgentInterface
+ {
+ Url = url,
+ ProtocolBinding = ProtocolBindingNames.JsonRpc,
+ ProtocolVersion = "1.0",
+ }));
+
+ agentInterfaces.AddRange(agentUrls.Select(url => new AgentInterface
+ {
+ Url = url,
+ ProtocolBinding = ProtocolBindingNames.HttpJson,
+ 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 8dcb3d1a34..c12a1c9431 100644
--- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs
+++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs
@@ -25,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()
@@ -38,14 +34,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 = (builder.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 +51,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 +65,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 +81,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 +92,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 +101,12 @@ 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(
- hostA2AAgent,
- path: "/",
- agentCard: hostA2AAgentCard,
- taskManager => app.MapWellKnownAgentCard(taskManager, "/"));
+builder.AddA2AServer(hostA2AAgent);
+
+var app = builder.Build();
+app.MapA2AHttpJson(hostA2AAgent, "/");
+app.MapA2AJsonRpc(hostA2AAgent, "/");
+
+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..d6957a9b8e 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,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
-using A2A.AspNetCore;
using AgentWebChat.AgentHost;
using AgentWebChat.AgentHost.Custom;
using AgentWebChat.AgentHost.Utilities;
@@ -146,6 +145,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();
@@ -154,17 +156,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", 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"
-});
+// 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/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/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
diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs
index 9d98857e9b..9fd2e8ff47 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;
@@ -28,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;
@@ -42,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);
@@ -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.SubscribeToTaskWithFallbackAsync(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)
+ switch (streamResponse.PayloadCase)
{
- contextId = message.ContextId;
-
- yield return this.ConvertToAgentResponseUpdate(message);
- }
- else if (sseEvent.Data is AgentTask task)
- {
- 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}");
}
}
@@ -240,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);
@@ -264,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)
@@ -284,7 +337,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 +377,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 task)
+ {
+ return new AgentResponse
+ {
+ AgentId = this.Id,
+ ResponseId = task.Id,
+ FinishReason = MapTaskStateToFinishReason(task.Status.State),
+ RawRepresentation = task,
+ Messages = task.ToChatMessages() ?? [],
+ ContinuationToken = CreateContinuationToken(task.Id, task.Status.State),
+ AdditionalProperties = task.Metadata?.ToAdditionalProperties(),
+ };
+ }
+
+ private AgentResponseUpdate ConvertToAgentResponseUpdate(Message message)
{
return new AgentResponseUpdate
{
@@ -349,32 +429,35 @@ private AgentResponseUpdate ConvertToAgentResponseUpdate(AgentTask task)
RawRepresentation = task,
Role = ChatRole.Assistant,
Contents = task.ToAIContents(),
+ ContinuationToken = CreateContinuationToken(task.Id, task.Status.State),
AdditionalProperties = task.Metadata?.ToAdditionalProperties(),
};
}
- 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/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/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs
index 5233adb88f..845e1dc6e3 100644
--- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs
+++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs
@@ -52,7 +52,7 @@ internal static A2AContinuationToken FromToken(ResponseContinuationToken token)
{
case "taskId":
reader.Read();
- taskId = reader.GetString()!;
+ taskId = reader.GetString() ?? throw new JsonException("The 'taskId' property must contain a non-null string value.");
break;
default:
throw new JsonException($"Unrecognized property '{propertyName}'.");
diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs
index 1998d020b5..086505b2bc 100644
--- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
-using System;
using System.Net.Http;
using Microsoft.Agents.AI;
using Microsoft.Extensions.Logging;
@@ -25,12 +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)
{
- // Create the A2A client using the agent URL from the card.
- var a2aClient = new A2AClient(new Uri(card.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/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/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..7bfd0db8df
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs
@@ -0,0 +1,138 @@
+// 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.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.AspNetCore.Builder;
+
+///
+/// Provides extension methods for mapping A2A protocol endpoints for AI agents.
+///
+[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
+public static class A2AEndpointRouteBuilderExtensions
+{
+ ///
+ /// Maps A2A HTTP+JSON endpoints for the specified agent to the given path.
+ /// An for the agent must be registered first by calling
+ /// AddA2AServer during service registration.
+ ///
+ /// The to add the A2A endpoints to.
+ /// The configuration builder for the agent.
+ /// The route path prefix for A2A endpoints.
+ /// An for further endpoint configuration.
+ public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path)
+ {
+ ArgumentNullException.ThrowIfNull(agentBuilder);
+
+ return endpoints.MapA2AHttpJson(agentBuilder.Name, path);
+ }
+
+ ///
+ /// Maps A2A HTTP+JSON endpoints for the specified agent to the given path.
+ /// An for the agent must be registered first by calling
+ /// AddA2AServer during service registration.
+ ///
+ /// The to add the A2A endpoints to.
+ /// The agent whose name identifies the registered A2A server.
+ /// The route path prefix for A2A endpoints.
+ /// An for further endpoint configuration.
+ public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, AIAgent agent, string path)
+ {
+ ArgumentNullException.ThrowIfNull(agent);
+ ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name));
+
+ return endpoints.MapA2AHttpJson(agent.Name, path);
+ }
+
+ ///
+ /// Maps A2A HTTP+JSON endpoints for the agent with the specified name to the given path.
+ /// An for the agent must be registered first by calling
+ /// AddA2AServer during service registration.
+ ///
+ /// 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 for further endpoint configuration.
+ public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, string agentName, string path)
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+ ArgumentException.ThrowIfNullOrWhiteSpace(agentName);
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+
+ var a2aServer = endpoints.ServiceProvider.GetKeyedService(agentName)
+ ?? throw new InvalidOperationException(
+ $"No A2AServer is registered for agent '{agentName}'. " +
+ $"Call services.AddA2AServer(\"{agentName}\") or agentBuilder.AddA2AServer() during service registration to register one.");
+
+ // 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" };
+
+ return endpoints.MapHttpA2A(a2aServer, stubAgentCard, path);
+ }
+
+ ///
+ /// Maps A2A JSON-RPC endpoints for the specified agent to the given path.
+ /// An for the agent must be registered first by calling
+ /// AddA2AServer during service registration.
+ ///
+ /// The to add the A2A endpoints to.
+ /// The configuration builder for the agent.
+ /// The route path prefix for A2A endpoints.
+ /// An for further endpoint configuration.
+ public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path)
+ {
+ ArgumentNullException.ThrowIfNull(agentBuilder);
+
+ return endpoints.MapA2AJsonRpc(agentBuilder.Name, path);
+ }
+
+ ///
+ /// Maps A2A JSON-RPC endpoints for the specified agent to the given path.
+ /// An for the agent must be registered first by calling
+ /// AddA2AServer during service registration.
+ ///
+ /// The to add the A2A endpoints to.
+ /// The agent whose name identifies the registered A2A server.
+ /// The route path prefix for A2A endpoints.
+ /// An for further endpoint configuration.
+ public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, AIAgent agent, string path)
+ {
+ ArgumentNullException.ThrowIfNull(agent);
+ ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name));
+
+ return endpoints.MapA2AJsonRpc(agent.Name, path);
+ }
+
+ ///
+ /// Maps A2A JSON-RPC endpoints for the agent with the specified name to the given path.
+ /// An for the agent must be registered first by calling
+ /// AddA2AServer during service registration.
+ ///
+ /// 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 for further endpoint configuration.
+ public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, string agentName, string path)
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+ ArgumentException.ThrowIfNullOrWhiteSpace(agentName);
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+
+ var a2aServer = endpoints.ServiceProvider.GetKeyedService(agentName)
+ ?? throw new InvalidOperationException(
+ $"No A2AServer is registered for agent '{agentName}'. " +
+ $"Call services.AddA2AServer(\"{agentName}\") or agentBuilder.AddA2AServer() during service registration to register one.");
+
+ return endpoints.MapA2A(a2aServer, path);
+ }
+}
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.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj
index 4829b56b9e..200aa29ccc 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj
@@ -1,9 +1,12 @@
-
+
$(TargetFrameworksCore)
Microsoft.Agents.AI.Hosting.A2A.AspNetCore
preview
+
+ $(NoWarn);RT0002
@@ -13,7 +16,7 @@
true
true
-
+
@@ -21,7 +24,7 @@
-
+
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..65bc4c30bd
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs
@@ -0,0 +1,192 @@
+// 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)
+ {
+ ArgumentNullException.ThrowIfNull(hostAgent);
+ ArgumentNullException.ThrowIfNull(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.None).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/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/A2AServerRegistrationOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerRegistrationOptions.cs
new file mode 100644
index 0000000000..7bd30f9a7c
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerRegistrationOptions.cs
@@ -0,0 +1,30 @@
+// 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 server registration.
+///
+[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
+public sealed class A2AServerRegistrationOptions
+{
+ ///
+ /// 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/A2AServerServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs
new file mode 100644
index 0000000000..29ab28c250
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs
@@ -0,0 +1,160 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+using A2A;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Hosting;
+using Microsoft.Agents.AI.Hosting.A2A;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+///
+/// Provides extension methods for registering A2A server instances in the dependency injection container.
+///
+[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
+public static class A2AServerServiceCollectionExtensions
+{
+ ///
+ /// Registers an in the dependency injection container, keyed by the agent name
+ /// specified in the . This method only registers the server; to expose it
+ /// as an HTTP endpoint, call one of the MapA2AHttpJson or MapA2AJsonRpc endpoint mapping
+ /// methods during application startup.
+ ///
+ /// The agent builder whose name identifies the agent.
+ /// An optional callback to configure .
+ /// The for chaining.
+ public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action? configureOptions = null)
+ {
+ ArgumentNullException.ThrowIfNull(agentBuilder);
+
+ agentBuilder.ServiceCollection.AddA2AServer(agentBuilder.Name, configureOptions);
+
+ return agentBuilder;
+ }
+
+ ///
+ /// Registers an in the dependency injection container, keyed by the specified
+ /// agent name. This method only registers the server; to expose it as an HTTP endpoint, call one of the
+ /// MapA2AHttpJson or MapA2AJsonRpc endpoint mapping methods during application startup.
+ ///
+ /// The host application builder to configure.
+ /// The name of the agent to create an A2A server for.
+ /// An optional callback to configure .
+ /// The for chaining.
+ public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action? configureOptions = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.Services.AddA2AServer(agentName, configureOptions);
+
+ return builder;
+ }
+
+ ///
+ /// Registers an in the dependency injection container for the specified
+ /// instance, keyed by the agent's . This method only
+ /// registers the server; to expose it as an HTTP endpoint, call one of the MapA2AHttpJson or
+ /// MapA2AJsonRpc endpoint mapping methods during application startup.
+ ///
+ /// The host application builder to configure.
+ /// The agent instance to create an A2A server for.
+ /// An optional callback to configure .
+ /// The for chaining.
+ public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action? configureOptions = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.Services.AddA2AServer(agent, configureOptions);
+
+ return builder;
+ }
+
+ ///
+ /// Registers an in the dependency injection container, keyed by the specified
+ /// agent name. This method only registers the server; to expose it as an HTTP endpoint, call one of the
+ /// MapA2AHttpJson or MapA2AJsonRpc endpoint mapping methods during application startup.
+ ///
+ /// The service collection to add the A2A server to.
+ /// The name of the agent to create an A2A server for.
+ /// An optional callback to configure .
+ /// The for chaining.
+ public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action? configureOptions = null)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentException.ThrowIfNullOrWhiteSpace(agentName);
+
+ A2AServerRegistrationOptions? options = null;
+ if (configureOptions is not null)
+ {
+ options = new A2AServerRegistrationOptions();
+ configureOptions(options);
+ }
+
+ services.AddKeyedSingleton(agentName, (sp, _) =>
+ {
+ var agent = sp.GetRequiredKeyedService(agentName);
+ return CreateA2AServer(sp, agent, options);
+ });
+
+ return services;
+ }
+
+ ///
+ /// Registers an in the dependency injection container for the specified
+ /// instance, keyed by the agent's . This method only
+ /// registers the server; to expose it as an HTTP endpoint, call one of the MapA2AHttpJson or
+ /// MapA2AJsonRpc endpoint mapping methods during application startup.
+ ///
+ /// The service collection to add the A2A server to.
+ /// The agent instance to create an A2A server for.
+ /// An optional callback to configure .
+ /// The for chaining.
+ public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action? configureOptions = null)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(agent);
+ ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name));
+
+ A2AServerRegistrationOptions? options = null;
+ if (configureOptions is not null)
+ {
+ options = new A2AServerRegistrationOptions();
+ configureOptions(options);
+ }
+
+ services.AddKeyedSingleton(agent.Name, (sp, _) => CreateA2AServer(sp, agent, options));
+
+ return services;
+ }
+
+ private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAgent agent, A2AServerRegistrationOptions? options)
+ {
+ var agentHandler = serviceProvider.GetKeyedService(agent.Name);
+ if (agentHandler is null)
+ {
+ var agentSessionStore = serviceProvider.GetKeyedService(agent.Name);
+ var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground;
+
+ var hostAgent = new AIHostAgent(
+ innerAgent: agent,
+ sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore());
+
+ agentHandler = new A2AAgentHandler(hostAgent, runMode);
+ }
+
+ var loggerFactory = serviceProvider.GetService() ?? NullLoggerFactory.Instance;
+ var taskStore = serviceProvider.GetKeyedService(agent.Name) ?? new InMemoryTaskStore();
+
+ return new A2AServer(
+ agentHandler,
+ taskStore,
+ new ChannelEventNotifier(),
+ loggerFactory.CreateLogger(),
+ options?.ServerOptions);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs
deleted file mode 100644
index 31c520755f..0000000000
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs
+++ /dev/null
@@ -1,309 +0,0 @@
-// 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;
-
-///
-/// Provides extension methods for attaching A2A (Agent2Agent) messaging capabilities to an .
-///
-[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 .
- ///
- /// 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(
- this AIAgent agent,
- ITaskManager? taskManager = null,
- ILoggerFactory? loggerFactory = null,
- AgentSessionStore? agentSessionStore = null,
- AgentRunMode? runMode = null,
- JsonSerializerOptions? jsonSerializerOptions = null)
- {
- ArgumentNullException.ThrowIfNull(agent);
- ArgumentNullException.ThrowIfNull(agent.Name);
-
- runMode ??= AgentRunMode.DisallowBackground;
-
- 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());
- }
-
- return chatMessages;
- }
-}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs
index 087df96aae..3abb90afb6 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs
@@ -2,6 +2,7 @@
using System;
using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
@@ -28,7 +29,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,18 +80,22 @@ internal ValueTask ShouldRunInBackgroundAsync(A2ARunDecisionContext contex
}
// No delegate provided — fall back to "message" behavior.
- return ValueTask.FromResult(true);
+ return ValueTask.FromResult(false);
}
///
public bool Equals(AgentRunMode? other) =>
- other is not null && string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase);
+ other is not null
+ && string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase)
+ && ReferenceEquals(this._runInBackground, other._runInBackground);
///
public override bool Equals(object? obj) => this.Equals(obj as AgentRunMode);
///
- public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(this._value);
+ public override int GetHashCode() => HashCode.Combine(
+ StringComparer.OrdinalIgnoreCase.GetHashCode(this._value),
+ RuntimeHelpers.GetHashCode(this._runInBackground));
///
public override string ToString() => this._value;
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.A2A.UnitTests/A2AAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs
index 514922dd26..f8d855b5a3 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;
@@ -57,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()
{
@@ -89,14 +102,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 +124,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 +136,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 +149,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 +196,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 +210,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 +241,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 +263,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 +276,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 +289,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 +322,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 +335,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 +353,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 +377,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 +419,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 +447,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 +460,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 +487,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 +497,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 +521,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 +576,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 +630,200 @@ 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_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_WhenSubscribeFailsWithNonUnsupportedError_PropagatesWithoutFallbackAsync()
+ {
+ // Arrange
+ const string TaskId = "error-task-123";
+
+ this._handler.StreamingErrorCodeToReturn = A2AErrorCode.TaskNotFound;
+
+ var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken(TaskId) };
+
+ // Act & Assert - the A2AException should propagate directly without fallback to GetTask
+ var exception = await Assert.ThrowsAsync(async () =>
+ {
+ await foreach (var _ in this._agent.RunStreamingAsync([], null, options))
+ {
+ }
+ });
+
+ Assert.Equal(A2AErrorCode.TaskNotFound, exception.ErrorCode);
+
+ // Assert - only SubscribeToTask was called, no fallback to GetTask
+ Assert.Single(this._handler.CapturedJsonRpcRequests);
+ Assert.Equal("SubscribeToTask", this._handler.CapturedJsonRpcRequests[0].Method);
+ }
+
+ [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()
{
// 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 +836,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 +846,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 +877,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 +908,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 +919,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 +969,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 +1012,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 +1095,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 +1127,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 +1159,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 +1194,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 +1233,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 +1270,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]
@@ -1042,19 +1411,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.
///
@@ -1129,10 +1512,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);
@@ -1256,6 +1639,7 @@ await Assert.ThrowsAnyAsync(async () =>
public void Dispose()
{
+ this._a2aClient.Dispose();
this._handler.Dispose();
this._httpClient.Dispose();
}
@@ -1269,13 +1653,34 @@ 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 GetTaskRequest? CapturedGetTaskRequest { get; set; }
+
+ public SendMessageResponse? ResponseToReturn { get; set; }
- public TaskIdParams? CapturedTaskIdParams { get; set; }
+ public AgentTask? AgentTaskToReturn { get; set; }
- public A2AEvent? ResponseToReturn { get; set; }
+ public StreamResponse? StreamingResponseToReturn { get; set; }
- public A2AEvent? 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)
{
@@ -1286,46 +1691,121 @@ 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 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")
+ {
+ 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)
{
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
};
}
- // Return the pre-configured streaming response
- else if (this.StreamingResponseToReturn is not null)
+ // 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
+ }
- await SseFormatter.WriteAsync(
- new SseItem[]
- {
- new(JsonRpcResponse.CreateJsonRpcResponse("response-id", this.StreamingResponseToReturn!))
- }.ToAsyncEnumerable(),
- stream,
- (item, writer) =>
+ stream.Position = 0;
+
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StreamContent(stream)
{
- using Utf8JsonWriter json = new(writer, new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping });
- JsonSerializer.Serialize(json, item.Data);
- },
- cancellationToken
- );
+ Headers = { { "Content-Type", "text/event-stream" } }
+ }
+ };
+ }
+ // Return the pre-configured streaming response
+ else if (this.StreamingResponseToReturn is not null)
+ {
+ var jsonRpcResponse = new JsonRpcResponse
+ {
+ Id = "response-id",
+ Result = JsonSerializer.SerializeToNode(this.StreamingResponseToReturn, A2AJsonUtilities.DefaultOptions)
+ };
+
+ 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;
@@ -1339,7 +1819,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/A2AContinuationTokenTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AContinuationTokenTests.cs
index 1bb0d99e00..30d65b12f1 100644
--- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AContinuationTokenTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AContinuationTokenTests.cs
@@ -106,6 +106,18 @@ public void FromToken_WithEmptyData_ThrowsArgumentException()
Assert.Throws(() => A2AContinuationToken.FromToken(emptyToken));
}
+ [Fact]
+ public void FromToken_WithNullTaskIdValue_ThrowsJsonException()
+ {
+ // Arrange
+ var jsonWithNullTaskId = System.Text.Encoding.UTF8.GetBytes("{ \"taskId\": null }").AsMemory();
+ var mockToken = new MockResponseContinuationToken(jsonWithNullTaskId);
+
+ // Act & Assert
+ var ex = Assert.Throws(() => A2AContinuationToken.FromToken(mockToken));
+ Assert.Contains("taskId", ex.Message);
+ }
+
[Fact]
public void FromToken_WithMissingTaskIdProperty_ThrowsException()
{
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..f709e95ea7 100644
--- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs
@@ -26,7 +26,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,13 +50,13 @@ 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);
+ var agent = this._agentCard.AsAIAgent(httpClient: httpClient);
// Act
await agent.RunAsync("Test input");
@@ -66,6 +66,105 @@ public async Task RunIAgentAsync_SendsRequestToTheUrlSpecifiedInAgentCardAsync()
Assert.Equal(new Uri("http://test-endpoint/agent"), handler.CapturedUris[0]);
}
+ [Fact]
+ public async Task AsAIAgent_WithPreferredBindings_UsesMatchingInterfaceAsync()
+ {
+ // Arrange
+ var card = new AgentCard
+ {
+ Name = "Multi-Interface Agent",
+ Description = "An agent with multiple interfaces",
+ SupportedInterfaces =
+ [
+ new AgentInterface { Url = "http://first/agent", ProtocolBinding = ProtocolBindingNames.HttpJson },
+ new AgentInterface { Url = "http://second/agent", ProtocolBinding = ProtocolBindingNames.JsonRpc },
+ ]
+ };
+
+ 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 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://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
{
public Queue ResponsesToReturn { get; } = new();
@@ -86,13 +185,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..8a664b7fc9 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,15 +60,15 @@ 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);
+ 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();
@@ -104,13 +139,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/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);
+ }
}
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)
+
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs
new file mode 100644
index 0000000000..6558144184
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs
@@ -0,0 +1,966 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using A2A;
+using Microsoft.Extensions.AI;
+using Moq;
+using Moq.Protected;
+
+namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
+
+///
+/// Unit tests for the class.
+///
+public sealed class A2AAgentHandlerTests
+{
+ ///
+ /// Verifies that when metadata is null, the options passed to RunAsync have
+ /// AllowBackgroundResponses disabled and no AdditionalProperties.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
+ {
+ // Arrange
+ AgentRunOptions? capturedOptions = null;
+ A2AAgentHandler handler = CreateHandler(CreateAgentMock(options => capturedOptions = options));
+
+ // Act
+ await InvokeExecuteAsync(handler, new RequestContext
+ {
+ TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
+ });
+
+ // Assert
+ Assert.NotNull(capturedOptions);
+ Assert.False(capturedOptions.AllowBackgroundResponses);
+ Assert.Null(capturedOptions.AdditionalProperties);
+ }
+
+ ///
+ /// Verifies that when metadata is non-empty, the options passed to RunAsync have
+ /// AdditionalProperties populated with the converted metadata values.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_WhenMetadataIsNonEmpty_PassesOptionsWithAdditionalPropertiesToRunAsync()
+ {
+ // Arrange
+ AgentRunOptions? capturedOptions = null;
+ A2AAgentHandler handler = CreateHandler(CreateAgentMock(options => capturedOptions = options));
+
+ // Act
+ await InvokeExecuteAsync(handler, new RequestContext
+ {
+ TaskId = "", ContextId = "ctx", StreamingResponse = false,
+ Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] },
+ Metadata = new Dictionary
+ {
+ ["key1"] = JsonSerializer.SerializeToElement("value1"),
+ ["key2"] = JsonSerializer.SerializeToElement(42)
+ }
+ });
+
+ // Assert
+ Assert.NotNull(capturedOptions);
+ Assert.False(capturedOptions.AllowBackgroundResponses);
+ Assert.NotNull(capturedOptions.AdditionalProperties);
+ Assert.Equal(2, capturedOptions.AdditionalProperties.Count);
+ Assert.Equal("value1", capturedOptions.AdditionalProperties["key1"]?.ToString());
+ }
+
+ ///
+ /// Verifies that when the agent response has AdditionalProperties, the returned Message.Metadata contains the converted values.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_WhenResponseHasAdditionalProperties_ReturnsMessageWithMetadataAsync()
+ {
+ // Arrange
+ AdditionalPropertiesDictionary additionalProps = new()
+ {
+ ["responseKey1"] = "responseValue1",
+ ["responseKey2"] = 123
+ };
+ AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
+ {
+ AdditionalProperties = additionalProps
+ };
+ A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
+
+ // Act
+ var events = await CollectEventsAsync(handler, new RequestContext
+ {
+ TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
+ });
+
+ // Assert
+ 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 Message.Metadata is null.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_WhenResponseHasNullAdditionalProperties_ReturnsMessageWithNullMetadataAsync()
+ {
+ // Arrange
+ AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
+ {
+ AdditionalProperties = null
+ };
+ A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
+
+ // Act
+ var events = await CollectEventsAsync(handler, new RequestContext
+ {
+ TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
+ });
+
+ // Assert
+ Message message = Assert.Single(events.Messages);
+ Assert.Null(message.Metadata);
+ }
+
+ ///
+ /// Verifies that when the agent response has empty AdditionalProperties, the returned Message.Metadata is null.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_WhenResponseHasEmptyAdditionalProperties_ReturnsMessageWithNullMetadataAsync()
+ {
+ // Arrange
+ AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
+ {
+ AdditionalProperties = []
+ };
+ A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
+
+ // Act
+ var events = await CollectEventsAsync(handler, new RequestContext
+ {
+ TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
+ });
+
+ // Assert
+ Message message = Assert.Single(events.Messages);
+ Assert.Null(message.Metadata);
+ }
+
+ ///
+ /// Verifies that when runMode is DisallowBackground, AllowBackgroundResponses is false.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_DisallowBackgroundMode_SetsAllowBackgroundResponsesFalseAsync()
+ {
+ // Arrange
+ AgentRunOptions? capturedOptions = null;
+ A2AAgentHandler handler = CreateHandler(
+ CreateAgentMock(options => capturedOptions = options),
+ runMode: AgentRunMode.DisallowBackground);
+
+ // Act
+ await InvokeExecuteAsync(handler, new RequestContext
+ {
+ TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
+ });
+
+ // Assert
+ Assert.NotNull(capturedOptions);
+ Assert.False(capturedOptions.AllowBackgroundResponses);
+ }
+
+ ///
+ /// Verifies that in AllowBackgroundIfSupported mode, AllowBackgroundResponses is true.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_AllowBackgroundIfSupportedMode_SetsAllowBackgroundResponsesTrueAsync()
+ {
+ // Arrange
+ AgentRunOptions? capturedOptions = null;
+ A2AAgentHandler handler = CreateHandler(
+ CreateAgentMock(options => capturedOptions = options),
+ runMode: AgentRunMode.AllowBackgroundIfSupported);
+
+ // Act
+ await InvokeExecuteAsync(handler, new RequestContext
+ {
+ TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
+ });
+
+ // Assert
+ Assert.NotNull(capturedOptions);
+ Assert.True(capturedOptions.AllowBackgroundResponses);
+ }
+
+ ///
+ /// Verifies that a custom Dynamic delegate returning false sets AllowBackgroundResponses to false.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_DynamicMode_WithFalseCallback_SetsAllowBackgroundResponsesFalseAsync()
+ {
+ // Arrange
+ AgentRunOptions? capturedOptions = null;
+ A2AAgentHandler handler = CreateHandler(
+ CreateAgentMock(options => capturedOptions = options),
+ runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false)));
+
+ // Act
+ await InvokeExecuteAsync(handler, new RequestContext
+ {
+ TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
+ });
+
+ // Assert
+ Assert.NotNull(capturedOptions);
+ Assert.False(capturedOptions.AllowBackgroundResponses);
+ }
+
+ ///
+ /// Verifies that a custom Dynamic delegate returning true sets AllowBackgroundResponses to true.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_DynamicMode_WithTrueCallback_SetsAllowBackgroundResponsesTrueAsync()
+ {
+ // Arrange
+ AgentRunOptions? capturedOptions = null;
+ A2AAgentHandler handler = CreateHandler(
+ CreateAgentMock(options => capturedOptions = options),
+ runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)));
+
+ // Act
+ await InvokeExecuteAsync(handler, new RequestContext
+ {
+ TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
+ });
+
+ // Assert
+ Assert.NotNull(capturedOptions);
+ Assert.True(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, task status events are emitted.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_WhenResponseHasContinuationToken_EmitsTaskStatusEventsAsync()
+ {
+ // Arrange
+ AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
+ {
+ ContinuationToken = CreateTestContinuationToken()
+ };
+ A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
+
+ // Act
+ var events = await CollectEventsAsync(handler, new RequestContext
+ {
+ StreamingResponse = false,
+ TaskId = "task-1",
+ ContextId = "ctx-1",
+ Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
+ });
+
+ // Assert - should have emitted status update events (Submitted + Working)
+ Assert.True(events.StatusUpdates.Count >= 1);
+ Assert.Empty(events.Messages);
+ }
+
+ ///
+ /// 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 ExecuteAsync_WhenMessageHasContextId_UsesProvidedContextIdAsync()
+ {
+ // Arrange
+ AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
+ A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
+
+ // Act
+ var events = await CollectEventsAsync(handler, new RequestContext
+ {
+ 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
+ Message message = Assert.Single(events.Messages);
+ Assert.Equal("my-context-123", message.ContextId);
+ }
+
+ ///
+ /// Verifies that on continuation when the agent completes (no ContinuationToken), task is completed with artifact.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_OnContinuation_WhenComplete_EmitsArtifactAndCompletedAsync()
+ {
+ // Arrange
+ AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]);
+ A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
+
+ // Act
+ var events = await CollectEventsAsync(handler, new RequestContext
+ {
+ 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" }] }] }
+ });
+
+ // 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 the agent throws during a continuation,
+ /// the handler emits a Failed status and re-throws the exception.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_OnContinuation_WhenAgentThrows_EmitsFailedStatusAsync()
+ {
+ // Arrange
+ int callCount = 0;
+ Mock agentMock = CreateAgentMockWithCallCount(ref callCount, _ =>
+ throw new InvalidOperationException("Agent failed"));
+ A2AAgentHandler handler = CreateHandler(agentMock);
+
+ // 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",
+
+ 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 - should have emitted Failed status
+ Assert.True(events.StatusUpdates.Count > 0);
+ }
+
+ ///
+ /// Verifies that when the agent throws during a continuation and the cancellation token
+ /// is already cancelled, the handler still emits a Failed status and re-throws the
+ /// original exception (not an OperationCanceledException from FailAsync).
+ ///
+ [Fact]
+ public async Task ExecuteAsync_OnContinuation_WhenAgentThrowsWithCancelledToken_StillEmitsFailedStatusAsync()
+ {
+ // Arrange
+ int callCount = 0;
+ Mock agentMock = CreateAgentMockWithCallCount(ref callCount, _ =>
+ throw new InvalidOperationException("Agent failed"));
+ A2AAgentHandler handler = CreateHandler(agentMock);
+
+ using var cts = new CancellationTokenSource();
+ cts.Cancel(); // Pre-cancel the token
+
+ // Act & Assert - the original InvalidOperationException should be thrown, not OperationCanceledException
+ 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",
+
+ Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
+ },
+ eventQueue,
+ cts.Token));
+ eventQueue.Complete(null);
+ await readerTask;
+
+ // Assert - should have emitted Failed status even with a cancelled token
+ Assert.True(events.StatusUpdates.Count > 0);
+ }
+
+ ///
+ /// Verifies that when the agent throws OperationCanceledException during a continuation,
+ /// no Failed status is emitted.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_OnContinuation_WhenOperationCancelled_DoesNotEmitFailedAsync()
+ {
+ // Arrange
+ int callCount = 0;
+ Mock agentMock = CreateAgentMockWithCallCount(ref callCount, _ =>
+ throw new OperationCanceledException("Cancelled"));
+ A2AAgentHandler handler = CreateHandler(agentMock);
+
+ // 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",
+
+ 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 - should NOT have emitted any status (OperationCanceledException is re-thrown without marking Failed)
+ Assert.Empty(events.StatusUpdates);
+ }
+
+ ///
+ /// Verifies that ReferenceTaskIds throws NotSupportedException.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_WithReferenceTaskIds_ThrowsNotSupportedExceptionAsync()
+ {
+ // Arrange
+ A2AAgentHandler handler = CreateHandler(CreateAgentMock(_ => { }));
+
+ // 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" }],
+ ReferenceTaskIds = ["other-task-id"]
+ }
+ }));
+ }
+
+ ///
+ /// Verifies that when ContextId is null, a new one is generated and used in the response.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_WhenContextIdIsNull_GeneratesContextIdAsync()
+ {
+ // Arrange
+ AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
+ A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
+
+ // Act
+ var events = await CollectEventsAsync(handler, new RequestContext
+ {
+ StreamingResponse = false,
+ TaskId = "",
+ ContextId = null!,
+ Message = new Message
+ {
+ MessageId = "test-id",
+ Role = Role.User,
+ Parts = [new Part { Text = "Hello" }]
+ }
+ });
+
+ // Assert
+ Message message = Assert.Single(events.Messages);
+ Assert.NotNull(message.ContextId);
+ Assert.NotEmpty(message.ContextId);
+ }
+
+ ///
+ /// Verifies that when Message is null, the handler still succeeds with empty chat messages.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_WhenMessageIsNull_SucceedsWithEmptyMessagesAsync()
+ {
+ // Arrange
+ AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
+ A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
+
+ // Act
+ var events = await CollectEventsAsync(handler, new RequestContext
+ {
+ StreamingResponse = false,
+ TaskId = "",
+ ContextId = "ctx",
+ Message = null!
+ });
+
+ // Assert
+ Message message = Assert.Single(events.Messages);
+ Assert.Equal("ctx", message.ContextId);
+ }
+
+ ///
+ /// Verifies that the dynamic AllowBackgroundWhen delegate receives the correct RequestContext.
+ ///
+ [Fact]
+ public async Task ExecuteAsync_DynamicMode_DelegateReceivesRequestContextAsync()
+ {
+ // Arrange
+ A2ARunDecisionContext? capturedContext = null;
+ A2AAgentHandler handler = CreateHandler(
+ CreateAgentMock(_ => { }),
+ runMode: AgentRunMode.AllowBackgroundWhen((ctx, _) =>
+ {
+ capturedContext = ctx;
+ return ValueTask.FromResult(false);
+ }));
+
+ var requestContext = new RequestContext
+ {
+ TaskId = "my-task", ContextId = "my-ctx", StreamingResponse = false,
+ Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
+ };
+
+ // Act
+ await InvokeExecuteAsync(handler, requestContext);
+
+ // Assert
+ Assert.NotNull(capturedContext);
+ Assert.Same(requestContext, capturedContext.RequestContext);
+ }
+
+ ///
+ /// Verifies that CancelAsync emits a Canceled status event.
+ ///
+ [Fact]
+ public async Task CancelAsync_EmitsCanceledStatusAsync()
+ {
+ // Arrange
+ A2AAgentHandler handler = CreateHandler(CreateAgentMock(_ => { }));
+ var events = new EventCollector();
+ var eventQueue = new AgentEventQueue();
+ var readerTask = ReadEventsAsync(eventQueue, events);
+
+ // Act
+ await handler.CancelAsync(
+ new RequestContext
+ {
+ 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
+ eventQueue.Complete(null);
+ await readerTask;
+ Assert.True(events.StatusUpdates.Count > 0);
+ }
+
+#pragma warning restore MEAI001
+
+ ///
+ /// Verifies that when no session store is provided, the handler uses InMemoryAgentSessionStore
+ /// and can execute successfully.
+ ///
+ [Fact]
+ public async Task Handler_WithNullSessionStore_UsesInMemorySessionStoreAndExecutesSuccessfullyAsync()
+ {
+ // Arrange
+ AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
+ A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), 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 Handler_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")]);
+ A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), 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 Handler_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())
+ .Callback(() => Interlocked.Increment(ref createSessionCallCount))
+ .ReturnsAsync(() => new TestAgentSession());
+ agentMock
+ .Protected()
+ .Setup>("SerializeSessionCoreAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(JsonDocument.Parse("{}").RootElement);
+ agentMock
+ .Protected()
+ .Setup>("DeserializeSessionCoreAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(sessionInstance);
+ agentMock
+ .Protected()
+ .Setup>("RunCoreAsync",
+ ItExpr.IsAny>(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Reply")]));
+
+ A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: null);
+
+ var context = new RequestContext
+ {
+ StreamingResponse = false,
+ TaskId = "",
+ ContextId = "ctx-persistent",
+ Message = new Message
+ {
+ MessageId = "test-id",
+ Role = Role.User,
+ Parts = [new Part { Text = "Hello" }]
+ }
+ };
+
+ // 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);
+ }
+
+ ///
+ /// 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;
+ A2AAgentHandler handler = CreateHandler(
+ CreateAgentMock(_ => agentInvoked = true),
+ 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();
+ A2AAgentHandler handler = CreateHandler(
+ CreateAgentMock(_ => { }),
+ 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;
+ A2AAgentHandler handler = CreateHandler(
+ CreateAgentMock(options => capturedOptions = options),
+ 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);
+ }
+
+ private static A2AAgentHandler CreateHandler(
+ Mock agentMock,
+ AgentRunMode? runMode = null,
+ AgentSessionStore? agentSessionStore = null)
+ {
+ runMode ??= AgentRunMode.DisallowBackground;
+
+ var hostAgent = new AIHostAgent(
+ innerAgent: agentMock.Object,
+ sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore());
+
+ return new A2AAgentHandler(hostAgent, runMode);
+ }
+
+ private static Mock CreateAgentMock(Action optionsCallback)
+ {
+ 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())
+ .Callback, AgentSession?, AgentRunOptions?, CancellationToken>(
+ (_, _, options, _) => optionsCallback(options))
+ .ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Test response")]));
+
+ return agentMock;
+ }
+
+ private static Mock CreateAgentMockWithResponse(AgentResponse response)
+ {
+ 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(response);
+
+ return agentMock;
+ }
+
+ private static Mock CreateAgentMockWithCallCount(
+ ref int callCount,
+ Func responseFactory)
+ {
+ 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 InvokeExecuteAsync(A2AAgentHandler handler, RequestContext context)
+ {
+ var eventQueue = new AgentEventQueue();
+ await handler.ExecuteAsync(context, eventQueue, CancellationToken.None);
+ eventQueue.Complete(null);
+ }
+
+ private static async Task CollectEventsAsync(A2AAgentHandler 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 sealed class EventCollector
+ {
+ public List Messages { get; } = [];
+ public List Tasks { get; } = [];
+ public List StatusUpdates { get; } = [];
+ public List ArtifactUpdates { get; } = [];
+ }
+
+ private sealed class TestAgentSession : AgentSession;
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs
new file mode 100644
index 0000000000..e5fa337e86
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs
@@ -0,0 +1,559 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Moq;
+
+namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
+
+///
+/// Tests for A2AEndpointRouteBuilderExtensions and A2AServerServiceCollectionExtensions methods.
+///
+public sealed class A2AEndpointRouteBuilderExtensionsTests
+{
+ ///
+ /// Verifies that MapA2AHttpJson throws ArgumentNullException for null endpoints.
+ ///
+ [Fact]
+ public void MapA2AHttpJson_WithAgentBuilder_NullEndpoints_ThrowsArgumentNullException()
+ {
+ // 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");
+
+ // Act & Assert
+ ArgumentNullException exception = Assert.Throws(() =>
+ endpoints.MapA2AHttpJson(agentBuilder, "/a2a"));
+
+ Assert.Equal("endpoints", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that MapA2AHttpJson throws ArgumentNullException for null agentBuilder.
+ ///
+ [Fact]
+ public void MapA2AHttpJson_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException()
+ {
+ // 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();
+ IHostedAgentBuilder agentBuilder = null!;
+
+ // Act & Assert
+ ArgumentNullException exception = Assert.Throws(() =>
+ app.MapA2AHttpJson(agentBuilder, "/a2a"));
+
+ Assert.Equal("agentBuilder", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that MapA2AHttpJson with IHostedAgentBuilder correctly maps the agent with default configuration.
+ ///
+ [Fact]
+ public void MapA2AHttpJson_WithAgentBuilder_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");
+ agentBuilder.AddA2AServer();
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+
+ // Act & Assert - Should not throw
+ var result = app.MapA2AHttpJson(agentBuilder, "/a2a");
+ Assert.NotNull(result);
+ }
+
+ ///
+ /// Verifies that MapA2AHttpJson with string agent name correctly maps the agent.
+ ///
+ [Fact]
+ public void MapA2AHttpJson_WithAgentName_DefaultConfiguration_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.AddA2AServer("agent");
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+
+ // Act & Assert - Should not throw
+ var result = app.MapA2AHttpJson("agent", "/a2a");
+ Assert.NotNull(result);
+ }
+
+ ///
+ /// Verifies that MapA2AJsonRpc with IHostedAgentBuilder correctly maps the agent.
+ ///
+ [Fact]
+ public void MapA2AJsonRpc_WithAgentBuilder_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");
+ agentBuilder.AddA2AServer();
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+
+ // Act & Assert - Should not throw
+ var result = app.MapA2AJsonRpc(agentBuilder, "/a2a");
+ Assert.NotNull(result);
+ }
+
+ ///
+ /// Verifies that MapA2AJsonRpc with string agent name correctly maps the agent.
+ ///
+ [Fact]
+ public void MapA2AJsonRpc_WithAgentName_DefaultConfiguration_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.AddA2AServer("agent");
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+
+ // Act & Assert - Should not throw
+ var result = app.MapA2AJsonRpc("agent", "/a2a");
+ Assert.NotNull(result);
+ }
+
+ ///
+ /// Verifies that both MapA2AHttpJson and MapA2AJsonRpc can be called for the same agent.
+ ///
+ [Fact]
+ public void MapA2AHttpJson_And_MapA2AJsonRpc_SameAgent_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");
+ agentBuilder.AddA2AServer();
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+
+ // Act & Assert - Should not throw
+ var httpResult = app.MapA2AHttpJson(agentBuilder, "/a2a");
+ var rpcResult = app.MapA2AJsonRpc(agentBuilder, "/a2a");
+ Assert.NotNull(httpResult);
+ Assert.NotNull(rpcResult);
+ }
+
+ ///
+ /// Verifies that multiple agents can be mapped to different paths.
+ ///
+ [Fact]
+ public void MapA2AHttpJson_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");
+ agent1Builder.AddA2AServer();
+ agent2Builder.AddA2AServer();
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+
+ // Act & Assert - Should not throw
+ app.MapA2AHttpJson(agent1Builder, "/a2a/agent1");
+ app.MapA2AHttpJson(agent2Builder, "/a2a/agent2");
+ Assert.NotNull(app);
+ }
+
+ ///
+ /// Verifies that custom paths can be specified for A2A endpoints.
+ ///
+ [Fact]
+ public void MapA2AHttpJson_WithCustomPath_AcceptsValidPath()
+ {
+ // Arrange
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ IChatClient mockChatClient = new DummyChatClient();
+ builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
+ IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
+ agentBuilder.AddA2AServer();
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+
+ // Act & Assert - Should not throw
+ app.MapA2AHttpJson(agentBuilder, "/custom/a2a/path");
+ Assert.NotNull(app);
+ }
+
+ ///
+ /// Verifies that AddA2AServer with custom A2AServerRegistrationOptions succeeds.
+ ///
+ [Fact]
+ public void AddA2AServer_WithCustomOptions_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");
+ agentBuilder.AddA2AServer(options => options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported);
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+
+ // Act & Assert - Should not throw
+ var result = app.MapA2AHttpJson(agentBuilder, "/a2a");
+ Assert.NotNull(result);
+ }
+
+ ///
+ /// Verifies that MapA2AHttpJson throws ArgumentNullException for null endpoints when using string agent name.
+ ///
+ [Fact]
+ public void MapA2AHttpJson_WithAgentName_NullEndpoints_ThrowsArgumentNullException()
+ {
+ // Arrange
+ AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
+
+ // Act & Assert
+ ArgumentNullException exception = Assert.Throws(() =>
+ endpoints.MapA2AHttpJson("agent", "/a2a"));
+
+ Assert.Equal("endpoints", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that MapA2AJsonRpc throws ArgumentNullException for null endpoints when using string agent name.
+ ///
+ [Fact]
+ public void MapA2AJsonRpc_WithAgentName_NullEndpoints_ThrowsArgumentNullException()
+ {
+ // Arrange
+ AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
+
+ // Act & Assert
+ ArgumentNullException exception = Assert.Throws(() =>
+ endpoints.MapA2AJsonRpc("agent", "/a2a"));
+
+ Assert.Equal("endpoints", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that MapA2AHttpJson throws ArgumentNullException for null agentName.
+ ///
+ [Fact]
+ public void MapA2AHttpJson_WithAgentName_NullAgentName_ThrowsArgumentNullException()
+ {
+ // Arrange
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+
+ // Act & Assert
+ ArgumentNullException exception = Assert.Throws(() =>
+ app.MapA2AHttpJson((string)null!, "/a2a"));
+
+ Assert.Equal("agentName", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that MapA2AHttpJson throws ArgumentException for empty agentName.
+ ///
+ [Fact]
+ public void MapA2AHttpJson_WithAgentName_EmptyAgentName_ThrowsArgumentException()
+ {
+ // Arrange
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+
+ // Act & Assert
+ ArgumentException exception = Assert.Throws(() =>
+ app.MapA2AHttpJson(string.Empty, "/a2a"));
+
+ Assert.Equal("agentName", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that MapA2AHttpJson throws ArgumentNullException for null path.
+ ///
+ [Fact]
+ public void MapA2AHttpJson_NullPath_ThrowsArgumentNullException()
+ {
+ // Arrange
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ IChatClient mockChatClient = new DummyChatClient();
+ builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
+ IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
+ agentBuilder.AddA2AServer();
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+
+ // Act & Assert
+ Assert.Throws(() =>
+ app.MapA2AHttpJson(agentBuilder, null!));
+ }
+
+ ///
+ /// Verifies that MapA2AHttpJson throws ArgumentException for whitespace-only path.
+ ///
+ [Fact]
+ public void MapA2AHttpJson_WhitespacePath_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");
+ agentBuilder.AddA2AServer();
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+
+ // Act & Assert
+ Assert.Throws(() =>
+ app.MapA2AHttpJson(agentBuilder, " "));
+ }
+
+ ///
+ /// Verifies that AddA2AServer throws ArgumentNullException for null services.
+ ///
+ [Fact]
+ public void AddA2AServer_NullServices_ThrowsArgumentNullException()
+ {
+ // Arrange
+ IServiceCollection services = null!;
+
+ // Act & Assert
+ ArgumentNullException exception = Assert.Throws(() =>
+ services.AddA2AServer("agent"));
+
+ Assert.Equal("services", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that AddA2AServer throws ArgumentNullException for null agentName.
+ ///
+ [Fact]
+ public void AddA2AServer_NullAgentName_ThrowsArgumentNullException()
+ {
+ // Arrange
+ IServiceCollection services = new ServiceCollection();
+
+ // Act & Assert
+ ArgumentNullException exception = Assert.Throws(() =>
+ services.AddA2AServer((string)null!));
+
+ Assert.Equal("agentName", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that AddA2AServer throws ArgumentException for empty agentName.
+ ///
+ [Fact]
+ public void AddA2AServer_EmptyAgentName_ThrowsArgumentException()
+ {
+ // Arrange
+ IServiceCollection services = new ServiceCollection();
+
+ // Act & Assert
+ ArgumentException exception = Assert.Throws(() =>
+ services.AddA2AServer(string.Empty));
+
+ Assert.Equal("agentName", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that AddA2AServer on IHostedAgentBuilder throws ArgumentNullException for null builder.
+ ///
+ [Fact]
+ public void AddA2AServer_NullAgentBuilder_ThrowsArgumentNullException()
+ {
+ // Arrange
+ IHostedAgentBuilder agentBuilder = null!;
+
+ // Act & Assert
+ ArgumentNullException exception = Assert.Throws(() =>
+ agentBuilder.AddA2AServer());
+
+ Assert.Equal("agentBuilder", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that MapA2AHttpJson throws ArgumentNullException for null AIAgent.
+ ///
+ [Fact]
+ public void MapA2AHttpJson_WithAIAgent_NullAgent_ThrowsArgumentNullException()
+ {
+ // Arrange
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+ AIAgent agent = null!;
+
+ // Act & Assert
+ ArgumentNullException exception = Assert.Throws(() =>
+ app.MapA2AHttpJson(agent, "/a2a"));
+
+ Assert.Equal("agent", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that MapA2AHttpJson throws ArgumentNullException for AIAgent with null Name.
+ ///
+ [Fact]
+ public void MapA2AHttpJson_WithAIAgent_NullName_ThrowsArgumentException()
+ {
+ // Arrange
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+ var agentMock = new Mock();
+ agentMock.Setup(a => a.Name).Returns((string?)null);
+
+ // Act & Assert
+ ArgumentNullException exception = Assert.Throws(() =>
+ app.MapA2AHttpJson(agentMock.Object, "/a2a"));
+
+ Assert.Equal("agent.Name", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that MapA2AHttpJson throws ArgumentException for AIAgent with whitespace Name.
+ ///
+ [Fact]
+ public void MapA2AHttpJson_WithAIAgent_WhitespaceName_ThrowsArgumentException()
+ {
+ // Arrange
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+ var agentMock = new Mock();
+ agentMock.Setup(a => a.Name).Returns(" ");
+
+ // Act & Assert
+ ArgumentException exception = Assert.Throws(() =>
+ app.MapA2AHttpJson(agentMock.Object, "/a2a"));
+
+ Assert.Equal("agent.Name", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that MapA2AJsonRpc throws ArgumentNullException for null AIAgent.
+ ///
+ [Fact]
+ public void MapA2AJsonRpc_WithAIAgent_NullAgent_ThrowsArgumentNullException()
+ {
+ // Arrange
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+ AIAgent agent = null!;
+
+ // Act & Assert
+ ArgumentNullException exception = Assert.Throws(() =>
+ app.MapA2AJsonRpc(agent, "/a2a"));
+
+ Assert.Equal("agent", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that MapA2AJsonRpc throws ArgumentNullException for AIAgent with null Name.
+ ///
+ [Fact]
+ public void MapA2AJsonRpc_WithAIAgent_NullName_ThrowsArgumentException()
+ {
+ // Arrange
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+ var agentMock = new Mock();
+ agentMock.Setup(a => a.Name).Returns((string?)null);
+
+ // Act & Assert
+ ArgumentNullException exception = Assert.Throws(() =>
+ app.MapA2AJsonRpc(agentMock.Object, "/a2a"));
+
+ Assert.Equal("agent.Name", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that MapA2AJsonRpc throws ArgumentException for AIAgent with whitespace Name.
+ ///
+ [Fact]
+ public void MapA2AJsonRpc_WithAIAgent_WhitespaceName_ThrowsArgumentException()
+ {
+ // Arrange
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+ builder.Services.AddLogging();
+ using WebApplication app = builder.Build();
+ var agentMock = new Mock();
+ agentMock.Setup(a => a.Name).Returns(" ");
+
+ // Act & Assert
+ ArgumentException exception = Assert.Throws(() =>
+ app.MapA2AJsonRpc(agentMock.Object, "/a2a"));
+
+ Assert.Equal("agent.Name", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that MapA2AHttpJson throws InvalidOperationException when no A2AServer has been
+ /// registered for the specified agent via AddA2AServer.
+ ///
+ [Fact]
+ public void MapA2AHttpJson_WithoutAddA2AServer_ThrowsInvalidOperationException()
+ {
+ // 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
+ InvalidOperationException exception = Assert.Throws(() =>
+ app.MapA2AHttpJson("agent", "/a2a"));
+
+ Assert.Contains("agent", exception.Message);
+ Assert.Contains("AddA2AServer", exception.Message);
+ }
+
+ ///
+ /// Verifies that MapA2AJsonRpc throws InvalidOperationException when no A2AServer has been
+ /// registered for the specified agent via AddA2AServer.
+ ///
+ [Fact]
+ public void MapA2AJsonRpc_WithoutAddA2AServer_ThrowsInvalidOperationException()
+ {
+ // 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
+ InvalidOperationException exception = Assert.Throws(() =>
+ app.MapA2AJsonRpc("agent", "/a2a"));
+
+ Assert.Contains("agent", exception.Message);
+ Assert.Contains("AddA2AServer", exception.Message);
+ }
+}
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/A2AServerServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs
new file mode 100644
index 0000000000..aae07e8e6f
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs
@@ -0,0 +1,459 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using A2A;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Moq;
+using Moq.Protected;
+
+namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
+
+///
+/// Unit tests for the class.
+///
+public sealed class A2AServerServiceCollectionExtensionsTests
+{
+ ///
+ /// Verifies that AddA2AServer with an agent name registers a keyed A2AServer
+ /// that can be resolved from the service provider.
+ ///
+ [Fact]
+ public async Task AddA2AServer_WithAgentName_ResolvesKeyedA2AServerAsync()
+ {
+ // Arrange
+ const string AgentName = "test-agent";
+ var services = new ServiceCollection();
+ services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
+
+ // Act
+ services.AddA2AServer(AgentName);
+
+ // Assert
+ await using var provider = services.BuildServiceProvider();
+ var server = provider.GetKeyedService(AgentName);
+ Assert.NotNull(server);
+ }
+
+ ///
+ /// Verifies that AddA2AServer with an agent instance registers a keyed A2AServer
+ /// that can be resolved from the service provider using the agent's name.
+ ///
+ [Fact]
+ public async Task AddA2AServer_WithAgentInstance_ResolvesKeyedA2AServerAsync()
+ {
+ // Arrange
+ const string AgentName = "instance-agent";
+ var agentMock = CreateAgentMock(AgentName);
+ var services = new ServiceCollection();
+
+ // Act
+ services.AddA2AServer(agentMock.Object);
+
+ // Assert
+ await using var provider = services.BuildServiceProvider();
+ var server = provider.GetKeyedService(AgentName);
+ Assert.NotNull(server);
+ }
+
+ ///
+ /// Verifies that when no ITaskStore or AgentSessionStore are registered,
+ /// AddA2AServer falls back to in-memory defaults and resolves successfully.
+ ///
+ [Fact]
+ public async Task AddA2AServer_WithNoCustomStores_FallsBackToInMemoryDefaultsAsync()
+ {
+ // Arrange
+ const string AgentName = "default-stores-agent";
+ var services = new ServiceCollection();
+ services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
+
+ // Act
+ services.AddA2AServer(AgentName);
+
+ // Assert - resolution succeeds without any stores registered
+ await using var provider = services.BuildServiceProvider();
+ var server = provider.GetKeyedService(AgentName);
+ Assert.NotNull(server);
+ }
+
+ ///
+ /// Verifies that when a custom ITaskStore is registered, AddA2AServer uses it
+ /// instead of the default InMemoryTaskStore.
+ ///
+ [Fact]
+ public async Task AddA2AServer_WithCustomTaskStore_ResolvesSuccessfullyAsync()
+ {
+ // Arrange
+ const string AgentName = "custom-taskstore-agent";
+ var services = new ServiceCollection();
+ services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
+
+ var mockTaskStore = new Mock();
+ services.AddKeyedSingleton(AgentName, mockTaskStore.Object);
+
+ // Act
+ services.AddA2AServer(AgentName);
+
+ // Assert
+ await using var provider = services.BuildServiceProvider();
+ var server = provider.GetKeyedService(AgentName);
+ Assert.NotNull(server);
+ }
+
+ ///
+ /// Verifies that when a custom AgentSessionStore is registered, AddA2AServer uses it
+ /// instead of the default InMemoryAgentSessionStore.
+ ///
+ [Fact]
+ public async Task AddA2AServer_WithCustomAgentSessionStore_ResolvesSuccessfullyAsync()
+ {
+ // Arrange
+ const string AgentName = "custom-sessionstore-agent";
+ var services = new ServiceCollection();
+ services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
+
+ var mockSessionStore = new Mock();
+ services.AddKeyedSingleton(AgentName, mockSessionStore.Object);
+
+ // Act
+ services.AddA2AServer(AgentName);
+
+ // Assert
+ await using var provider = services.BuildServiceProvider();
+ var server = provider.GetKeyedService(AgentName);
+ Assert.NotNull(server);
+ }
+
+ ///
+ /// Verifies that when a custom IAgentHandler is registered, AddA2AServer uses it
+ /// instead of creating a default A2AAgentHandler.
+ ///
+ [Fact]
+ public async Task AddA2AServer_WithCustomAgentHandler_ResolvesSuccessfullyAsync()
+ {
+ // Arrange
+ const string AgentName = "custom-handler-agent";
+ var services = new ServiceCollection();
+ services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
+
+ var mockHandler = new Mock();
+ services.AddKeyedSingleton(AgentName, mockHandler.Object);
+
+ // Act
+ services.AddA2AServer(AgentName);
+
+ // Assert
+ await using var provider = services.BuildServiceProvider();
+ var server = provider.GetKeyedService(AgentName);
+ Assert.NotNull(server);
+ }
+
+ ///
+ /// Verifies that the configureOptions callback is invoked when provided.
+ ///
+ [Fact]
+ public async Task AddA2AServer_WithConfigureOptions_InvokesCallbackAsync()
+ {
+ // Arrange
+ const string AgentName = "options-agent";
+ var services = new ServiceCollection();
+ services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
+
+ bool callbackInvoked = false;
+
+ // Act
+ services.AddA2AServer(AgentName, options =>
+ {
+ callbackInvoked = true;
+ options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported;
+ });
+
+ // Assert - callback is invoked during resolution
+ await using var provider = services.BuildServiceProvider();
+ var server = provider.GetKeyedService(AgentName);
+ Assert.NotNull(server);
+ Assert.True(callbackInvoked);
+ }
+
+ ///
+ /// Verifies that AddA2AServer with a null configureOptions does not throw.
+ ///
+ [Fact]
+ public async Task AddA2AServer_WithNullConfigureOptions_ResolvesSuccessfullyAsync()
+ {
+ // Arrange
+ const string AgentName = "null-options-agent";
+ var services = new ServiceCollection();
+ services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
+
+ // Act
+ services.AddA2AServer(AgentName, configureOptions: null);
+
+ // Assert
+ await using var provider = services.BuildServiceProvider();
+ var server = provider.GetKeyedService(AgentName);
+ Assert.NotNull(server);
+ }
+
+ ///
+ /// Verifies that AddA2AServer throws when the agent name is null.
+ ///
+ [Fact]
+ public void AddA2AServer_WithNullAgentName_ThrowsArgumentException()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+
+ // Act & Assert
+ Assert.ThrowsAny(() => services.AddA2AServer(agentName: null!));
+ }
+
+ ///
+ /// Verifies that AddA2AServer throws when the agent name is whitespace.
+ ///
+ [Fact]
+ public void AddA2AServer_WithWhitespaceAgentName_ThrowsArgumentException()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+
+ // Act & Assert
+ Assert.ThrowsAny(() => services.AddA2AServer(agentName: " "));
+ }
+
+ ///
+ /// Verifies that AddA2AServer throws when the services parameter is null.
+ ///
+ [Fact]
+ public void AddA2AServer_WithNullServices_ThrowsArgumentNullException()
+ {
+ // Arrange
+ IServiceCollection services = null!;
+
+ // Act & Assert
+ Assert.Throws(() => services.AddA2AServer("agent"));
+ }
+
+ ///
+ /// Verifies that AddA2AServer with an agent instance throws when the agent is null.
+ ///
+ [Fact]
+ public void AddA2AServer_WithNullAgent_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+
+ // Act & Assert
+ Assert.Throws(() => services.AddA2AServer(agent: null!));
+ }
+
+ ///
+ /// Verifies that AddA2AServer with an agent instance throws when the agent's Name is null.
+ ///
+ [Fact]
+ public void AddA2AServer_WithAgent_NullName_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ var agentMock = new Mock();
+ agentMock.Setup(a => a.Name).Returns((string?)null);
+
+ // Act & Assert
+ ArgumentNullException exception = Assert.Throws(() =>
+ services.AddA2AServer(agentMock.Object));
+
+ Assert.Equal("agent.Name", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that AddA2AServer with an agent instance throws when the agent's Name is whitespace.
+ ///
+ [Fact]
+ public void AddA2AServer_WithAgent_WhitespaceName_ThrowsArgumentException()
+ {
+ // Arrange
+ var services = new ServiceCollection();
+ var agentMock = new Mock();
+ agentMock.Setup(a => a.Name).Returns(" ");
+
+ // Act & Assert
+ ArgumentException exception = Assert.Throws(() =>
+ services.AddA2AServer(agentMock.Object));
+
+ Assert.Equal("agent.Name", exception.ParamName);
+ }
+
+ ///
+ /// Verifies that when a custom is registered as a keyed service,
+ /// the uses it to process requests instead of the default handler.
+ ///
+ [Fact]
+ public async Task AddA2AServer_WithCustomHandler_CustomHandlerIsInvokedOnRequestAsync()
+ {
+ // Arrange
+ const string AgentName = "custom-handler-wiring";
+ var services = new ServiceCollection();
+ services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
+
+ var mockHandler = new Mock();
+ mockHandler
+ .Setup(h => h.ExecuteAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns((RequestContext _, AgentEventQueue eq, CancellationToken ct) =>
+ eq.EnqueueMessageAsync(
+ new Message { MessageId = "resp", Role = Role.Agent, Parts = [new Part { Text = "Reply" }] }, ct).AsTask());
+
+ services.AddKeyedSingleton(AgentName, mockHandler.Object);
+
+ services.AddA2AServer(AgentName);
+ await using var provider = services.BuildServiceProvider();
+ var server = provider.GetRequiredKeyedService(AgentName);
+
+ // Act
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ var response = await server.SendMessageAsync(CreateTestSendMessageRequest(), cts.Token);
+
+ // Assert - the custom handler was invoked, not the default A2AAgentHandler
+ mockHandler.Verify(
+ h => h.ExecuteAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Once);
+ Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase);
+ Assert.NotNull(response.Message);
+ }
+
+ ///
+ /// Verifies that when a custom is registered as a keyed service
+ /// and no custom is registered, the default handler uses the custom
+ /// session store for session management during request processing.
+ ///
+ [Fact]
+ public async Task AddA2AServer_WithCustomSessionStore_NoHandler_SessionStoreIsUsedOnRequestAsync()
+ {
+ // Arrange
+ const string AgentName = "custom-sessionstore-wiring";
+ var services = new ServiceCollection();
+ services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
+
+ 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);
+
+ services.AddKeyedSingleton(AgentName, mockSessionStore.Object);
+
+ services.AddA2AServer(AgentName);
+ await using var provider = services.BuildServiceProvider();
+ var server = provider.GetRequiredKeyedService(AgentName);
+
+ // Act
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ var response = await server.SendMessageAsync(CreateTestSendMessageRequest(), cts.Token);
+
+ // Assert - the custom session store was used, not InMemoryAgentSessionStore
+ mockSessionStore.Verify(
+ x => x.GetSessionAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Once);
+ Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase);
+ Assert.NotNull(response.Message);
+ }
+
+ ///
+ /// Verifies that when no custom stores or handlers are registered, the server uses
+ /// the default in-memory stores and processes requests successfully end-to-end.
+ ///
+ [Fact]
+ public async Task AddA2AServer_WithNoCustomStores_DefaultStoresProcessRequestSuccessfullyAsync()
+ {
+ // Arrange
+ const string AgentName = "default-stores-request";
+ var services = new ServiceCollection();
+ services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMockForRequests(AgentName).Object);
+
+ services.AddA2AServer(AgentName);
+ await using var provider = services.BuildServiceProvider();
+ var server = provider.GetRequiredKeyedService(AgentName);
+
+ // Act
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ var response = await server.SendMessageAsync(CreateTestSendMessageRequest(), cts.Token);
+
+ // Assert - request was processed successfully with default in-memory stores
+ Assert.NotNull(response);
+ Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase);
+ Assert.NotNull(response.Message);
+ }
+
+ private static SendMessageRequest CreateTestSendMessageRequest() =>
+ new()
+ {
+ Message = new Message
+ {
+ MessageId = "test-id",
+ Role = Role.User,
+ Parts = [new Part { Text = "Hello" }]
+ }
+ };
+
+ private static Mock CreateAgentMock(string name)
+ {
+ Mock agentMock = new() { CallBase = true };
+ agentMock.SetupGet(x => x.Name).Returns(name);
+ agentMock
+ .Protected()
+ .Setup>("CreateSessionCoreAsync", ItExpr.IsAny())
+ .ReturnsAsync(new TestAgentSession());
+ agentMock
+ .Protected()
+ .Setup>("RunCoreAsync",
+ ItExpr.IsAny>(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Test response")]));
+
+ return agentMock;
+ }
+
+ ///
+ /// Creates a mock with session serialization support, suitable for
+ /// tests that exercise the full request processing path with .
+ ///
+ private static Mock CreateAgentMockForRequests(string name)
+ {
+ Mock agentMock = CreateAgentMock(name);
+ agentMock
+ .Protected()
+ .Setup>("SerializeSessionCoreAsync",
+ ItExpr.IsAny(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(JsonDocument.Parse("{}").RootElement);
+
+ return agentMock;
+ }
+
+ private sealed class TestAgentSession : AgentSession;
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs
deleted file mode 100644
index 87de6e52cd..0000000000
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs
+++ /dev/null
@@ -1,866 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System;
-using System.Collections.Generic;
-using System.Runtime.CompilerServices;
-using System.Text.Json;
-using System.Threading;
-using System.Threading.Tasks;
-using A2A;
-using Microsoft.Extensions.AI;
-using Moq;
-using Moq.Protected;
-
-namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
-
-///
-/// Unit tests for the class.
-///
-public sealed class AIAgentExtensionsTests
-{
- ///
- /// Verifies that when messageSendParams.Metadata is null, the options passed to RunAsync have
- /// AllowBackgroundResponses enabled and no AdditionalProperties.
- ///
- [Fact]
- public async Task MapA2A_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
- {
- // 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
- });
-
- // Assert
- Assert.NotNull(capturedOptions);
- Assert.False(capturedOptions.AllowBackgroundResponses);
- Assert.Null(capturedOptions.AdditionalProperties);
- }
-
- ///
- /// Verifies that when messageSendParams.Metadata has values, the options.AdditionalProperties contains the converted values.
- ///
- [Fact]
- public async Task MapA2A_WhenMetadataHasValues_PassesOptionsWithAdditionalPropertiesToRunAsync()
- {
- // 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)
- }
- });
-
- // 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"));
- }
-
- ///
- /// Verifies that when messageSendParams.Metadata is an empty dictionary, the options passed to RunAsync have
- /// AllowBackgroundResponses enabled and no AdditionalProperties.
- ///
- [Fact]
- public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
- {
- // 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 = []
- });
-
- // Assert
- Assert.NotNull(capturedOptions);
- Assert.False(capturedOptions.AllowBackgroundResponses);
- Assert.Null(capturedOptions.AdditionalProperties);
- }
-
- ///
- /// Verifies that when the agent response has AdditionalProperties, the returned AgentMessage.Metadata contains the converted values.
- ///
- [Fact]
- public async Task MapA2A_WhenResponseHasAdditionalProperties_ReturnsAgentMessageWithMetadataAsync()
- {
- // Arrange
- AdditionalPropertiesDictionary additionalProps = new()
- {
- ["responseKey1"] = "responseValue1",
- ["responseKey2"] = 123
- };
- AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
- {
- AdditionalProperties = additionalProps
- };
- 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
- 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());
- }
-
- ///