From 66e02c10e325b087d62dca0a3575d7f02bb530e7 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:53:00 +0100 Subject: [PATCH] .NET: [Breaking] Migrate A2A agent and hosting to A2A SDK v1 (#5423) * update a2a agent to the latest a2a sdk (#5257) * Move A2A samples from 04-hosting to 02-agents (#5267) Move the A2A sample projects (A2AAgent_AsFunctionTools and A2AAgent_PollingForTaskCompletion) from samples/04-hosting/A2A/ to samples/02-agents/A2A/ to better align with the sample directory structure. Update solution file and samples README accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Fix stream reconnection for A2AAgent (#5275) * Add SSE stream reconnection support to A2AAgent Implement automatic reconnection for SSE streams that disconnect mid-task, using the Last-Event-ID header to resume from where the stream left off. Changes: - Add InvokeStreamingWithReconnectAsync method to A2AAgent with configurable max retries and delay between attempts - Add new log messages for reconnection events - Add A2AAgent_StreamReconnection sample demonstrating the feature - Update existing polling sample to use simplified SendMessageAsync API - Add unit tests for stream reconnection logic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address comments * Address PR review feedback - Dispose SSE enumerator before GetTaskAsync fallback to release HTTP connection - Wrap StreamWriter in using blocks with leaveOpen:true and explicit UTF-8 encoding - Print update.Text instead of update object in stream reconnection sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Use IA2AClientFactory to create A2AClient (#5277) * Refactor A2A extensions to use IA2AClientFactory and add ProtocolSelection sample - Update A2AAgentCardExtensions to accept IA2AClientFactory instead of A2AClientOptions - Update A2ACardResolverExtensions to accept IA2AClientFactory - Update A2AClientExtensions to accept IA2AClientFactory - Update A2AAgent to use IA2AClientFactory for client creation - Add A2AAgent_ProtocolSelection sample demonstrating protocol selection - Add comprehensive unit tests for all changes - Update README files with new sample reference Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Reorder params: options before loggerFactory in A2A extensions Move A2AClientOptions parameter before ILoggerFactory in AsAIAgent and GetAIAgentAsync extension methods to follow the repo convention of keeping LoggerFactory and CancellationToken as the last parameters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Migrate A2A hosting to A2A SDK v1 (#5363) * .NET: Migrate A2A hosting to A2A SDK v1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * remove unused agent card --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Split A2A endpoint mapping into protocol-specific methods (#5413) * .NET: Refactor A2A hosting registration into A2AServerServiceCollectionExtensions - Rename A2AHostingOptions to A2AServerRegistrationOptions - Move server registration logic from A2AEndpointRouteBuilderExtensions and AIAgentExtensions into new A2AServerServiceCollectionExtensions - Remove A2AProtocolBinding and AIAgentExtensions (consolidated) - Update samples and tests to use the new registration API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address copilot comments --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove unnecessary using directive in AgentWebChat.AgentHost Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * restore AsyncEnumerable package version * address copilot initial feedback * address automated code review and formatting issues * fix formatting issues * Add DI wiring verification tests for AddA2AServer Add three tests to A2AServerServiceCollectionExtensionsTests that verify custom keyed services are actually wired through to the A2AServer, not just that the server resolves non-null: - Custom IAgentHandler: verifies the keyed handler is invoked when processing a SendMessageRequest instead of the default A2AAgentHandler. - Custom AgentSessionStore (no handler): verifies the keyed session store's GetSessionAsync is called during request processing when no custom handler is registered. - Default stores end-to-end: verifies the InMemoryAgentSessionStore and InMemoryTaskStore defaults successfully process a request. Uses a new CreateAgentMockForRequests helper that includes SerializeSessionCoreAsync setup needed by InMemoryAgentSessionStore. All tests call A2AServer.SendMessageAsync directly (no HTTP layer needed) and use CancellationToken timeouts to guard against hangs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/Directory.Packages.props | 10 +- dotnet/agent-framework-dotnet.slnx | 12 +- .../A2AAgent_AsFunctionTools.csproj | 0 .../A2A/A2AAgent_AsFunctionTools/Program.cs | 0 .../A2A/A2AAgent_AsFunctionTools/README.md | 0 .../A2AAgent_PollingForTaskCompletion.csproj | 3 +- .../Program.cs | 6 +- .../README.md | 0 .../A2AAgent_ProtocolSelection.csproj | 19 + .../A2A/A2AAgent_ProtocolSelection/Program.cs | 36 + .../A2A/A2AAgent_ProtocolSelection/README.md | 27 + .../A2AAgent_StreamReconnection.csproj | 23 + .../A2AAgent_StreamReconnection/Program.cs | 55 + .../A2A/A2AAgent_StreamReconnection/README.md | 29 + .../{04-hosting => 02-agents}/A2A/README.md | 4 +- dotnet/samples/02-agents/README.md | 1 + .../A2AClientServer/A2AClient/Program.cs | 10 +- .../A2AServer/HostAgentFactory.cs | 46 +- .../A2AClientServer/A2AServer/Program.cs | 33 +- .../AgentWebChat.AgentHost/Program.cs | 18 +- .../AgentWebChat.Web/A2AAgentClient.cs | 12 +- dotnet/samples/README.md | 2 +- .../src/Microsoft.Agents.AI.A2A/A2AAgent.cs | 281 +++-- .../A2AAgentLogMessages.cs | 13 + .../A2AContinuationToken.cs | 2 +- .../Extensions/A2AAgentCardExtensions.cs | 10 +- .../Extensions/A2ACardResolverExtensions.cs | 8 +- .../Extensions/A2AClientExtensions.cs | 6 +- .../Extensions/ChatMessageExtensions.cs | 6 +- .../Microsoft.Agents.AI.A2A.csproj | 1 + .../A2AEndpointRouteBuilderExtensions.cs | 138 +++ .../EndpointRouteBuilderExtensions.cs | 385 ------- ...ft.Agents.AI.Hosting.A2A.AspNetCore.csproj | 9 +- .../A2AAgentHandler.cs | 192 ++++ .../A2ARunDecisionContext.cs | 8 +- .../A2AServerRegistrationOptions.cs | 30 + .../A2AServerServiceCollectionExtensions.cs | 160 +++ .../AIAgentExtensions.cs | 309 ------ .../AgentRunMode.cs | 13 +- .../Converters/MessageConverter.cs | 12 +- .../A2AAgentTests.cs | 870 ++++++++++++---- .../A2AContinuationTokenTests.cs | 12 + .../Extensions/A2AAIContentExtensionsTests.cs | 24 +- .../Extensions/A2AAgentCardExtensionsTests.cs | 120 ++- .../Extensions/A2AAgentTaskExtensionsTests.cs | 20 +- .../Extensions/A2AArtifactExtensionsTests.cs | 12 +- .../A2ACardResolverExtensionsTests.cs | 58 +- .../Extensions/A2AClientExtensionsTests.cs | 36 + .../Extensions/ChatMessageExtensionsTests.cs | 30 +- .../Microsoft.Agents.AI.A2A.UnitTests.csproj | 4 + .../A2AAgentHandlerTests.cs | 966 ++++++++++++++++++ .../A2AEndpointRouteBuilderExtensionsTests.cs | 559 ++++++++++ .../A2AIntegrationTests.cs | 89 -- ...AServerServiceCollectionExtensionsTests.cs | 459 +++++++++ .../AIAgentExtensionsTests.cs | 866 ---------------- .../AgentRunModeTests.cs | 163 +++ .../Converters/MessageConverterTests.cs | 99 +- .../EndpointRouteA2ABuilderExtensionsTests.cs | 479 --------- 58 files changed, 4201 insertions(+), 2594 deletions(-) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj (100%) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_AsFunctionTools/Program.cs (100%) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_AsFunctionTools/README.md (100%) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj (86%) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_PollingForTaskCompletion/Program.cs (83%) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_PollingForTaskCompletion/README.md (100%) create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/Program.cs create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/README.md create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/Program.cs create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/README.md rename dotnet/samples/{04-hosting => 02-agents}/A2A/README.md (77%) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerRegistrationOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AIntegrationTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs 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 @@ AIAgent agent = agentCard.AsAIAgent(); 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 @@ public static class Program } 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 @@ internal static class HostAgentFactory 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 @@ internal static class HostAgentFactory 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 @@ internal static class HostAgentFactory 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 @@ for (var i = 0; i < args.Length; i++) 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 @@ IConfigurationRoot configuration = new ConfigurationBuilder() 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 @@ if (!string.IsNullOrEmpty(endpoint) && !string.IsNullOrEmpty(agentName)) { (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 @@ else if (!string.IsNullOrEmpty(apiKey)) 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 @@ else if (!string.IsNullOrEmpty(apiKey)) 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 @@ else if (!string.IsNullOrEmpty(apiKey)) Shipment number: SHPMT-SAP-001 Item: TSHIRT-RED-L Quantity: 900 - """), + """, agentUrls), _ => throw new ArgumentException($"Unsupported agent type: {agentType}"), }; } @@ -104,10 +101,12 @@ else 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 @@ builder.Services.AddKeyedSingleton("my-di-matchingname-agent", (sp, nam 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 @@ app.UseSwaggerUI(options => options.SwaggerEndpoint("/openapi/v1.json", "Agents // 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 @@ internal sealed class A2AAgentClient : AgentClientBase { // 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 @@ internal sealed class A2AAgentClient : AgentClientBase }); } } - 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 @@ public sealed class A2AAgent : AIAgent this._logger.LogA2AAgentInvokingAgent(nameof(RunAsync), this.Id, this.Name); - A2AResponse? a2aResponse = null; - if (GetContinuationToken(messages, options) is { } token) { - a2aResponse = await this._a2aClient.GetTaskAsync(token.TaskId, cancellationToken).ConfigureAwait(false); - } - else - { - MessageSendParams sendParams = new() - { - Message = CreateA2AMessage(typedSession, messages), - Metadata = options?.AdditionalProperties?.ToA2AMetadata() - }; + AgentTask agentTask = await this._a2aClient.GetTaskAsync(new GetTaskRequest { Id = token.TaskId }, cancellationToken).ConfigureAwait(false); - a2aResponse = await this._a2aClient.SendMessageAsync(sendParams, cancellationToken).ConfigureAwait(false); + this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name); + + UpdateSession(typedSession, agentTask.ContextId, agentTask.Id); + + return this.ConvertToAgentResponse(agentTask); } + SendMessageRequest sendParams = new() + { + Message = CreateA2AMessage(typedSession, messages), + Metadata = options?.AdditionalProperties?.ToA2AMetadata(), + Configuration = new SendMessageConfiguration { ReturnImmediately = options?.AllowBackgroundResponses is true } + }; + + SendMessageResponse a2aResponse = await this._a2aClient.SendMessageAsync(sendParams, cancellationToken).ConfigureAwait(false); + this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name); - if (a2aResponse is AgentMessage message) + if (a2aResponse.PayloadCase == SendMessageResponseCase.Message) { + var message = a2aResponse.Message!; + UpdateSession(typedSession, message.ContextId); - return new AgentResponse - { - AgentId = this.Id, - ResponseId = message.MessageId, - FinishReason = ChatFinishReason.Stop, - RawRepresentation = message, - Messages = [message.ToChatMessage()], - AdditionalProperties = message.Metadata?.ToAdditionalProperties(), - }; + return this.ConvertToAgentResponse(message); } - if (a2aResponse is AgentTask agentTask) + if (a2aResponse.PayloadCase == SendMessageResponseCase.Task) { + var agentTask = a2aResponse.Task!; + UpdateSession(typedSession, agentTask.ContextId, agentTask.Id); - var response = new AgentResponse - { - AgentId = this.Id, - ResponseId = agentTask.Id, - FinishReason = MapTaskStateToFinishReason(agentTask.Status.State), - RawRepresentation = agentTask, - Messages = agentTask.ToChatMessages() ?? [], - ContinuationToken = CreateContinuationToken(agentTask.Id, agentTask.Status.State), - AdditionalProperties = agentTask.Metadata?.ToAdditionalProperties(), - }; - - if (agentTask.ToChatMessages() is { Count: > 0 } taskMessages) - { - response.Messages = taskMessages; - } - - 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 @@ public sealed class A2AAgent : AIAgent 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; + case StreamResponseCase.Message: + var message = streamResponse.Message!; + contextId = message.ContextId; + yield return this.ConvertToAgentResponseUpdate(message); + break; - yield return this.ConvertToAgentResponseUpdate(message); - } - else if (sseEvent.Data is AgentTask task) - { - contextId = task.ContextId; - taskId = task.Id; + case StreamResponseCase.Task: + var task = streamResponse.Task!; + contextId = task.ContextId; + taskId = task.Id; + yield return this.ConvertToAgentResponseUpdate(task); + break; - yield return this.ConvertToAgentResponseUpdate(task); - } - else if (sseEvent.Data is TaskUpdateEvent taskUpdateEvent) - { - contextId = taskUpdateEvent.ContextId; - taskId = taskUpdateEvent.TaskId; + case StreamResponseCase.StatusUpdate: + var statusUpdate = streamResponse.StatusUpdate!; + contextId = statusUpdate.ContextId; + taskId = statusUpdate.TaskId; + yield return this.ConvertToAgentResponseUpdate(statusUpdate); + break; - 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.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 @@ public sealed class A2AAgent : AIAgent /// 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 @@ public sealed class A2AAgent : AIAgent 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 @@ public sealed class A2AAgent : AIAgent 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 @@ public sealed class A2AAgent : AIAgent 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 @@ public sealed class A2AAgent : AIAgent 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) + private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskArtifactUpdateEvent artifactUpdateEvent) + { + return new AgentResponseUpdate { - responseUpdate.Contents = artifactUpdateEvent.Artifact.ToAIContents(); - responseUpdate.RawRepresentation = artifactUpdateEvent; - } - else if (taskUpdateEvent is TaskStatusUpdateEvent statusUpdateEvent) - { - 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 @@ internal static partial class A2AAgentLogMessages 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 class A2AContinuationToken : ResponseContinuationToken { 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 @@ using Microsoft.Extensions.Logging; 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 class ChatMessageExtensions } } - 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 @@ public sealed class AgentRunMode : IEquatable } /// - /// 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 @@ public sealed class AgentRunMode : IEquatable } // 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 @@ internal static class MessageConverter 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.IO; 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 sealed class A2AAgentTests : IDisposable // 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable } // 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 sealed class A2AAgentTests : IDisposable 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 @@ public sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 @@ public sealed class A2AAgentTests : IDisposable }); } + [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 sealed class A2AAgentTests : IDisposable } // 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 @@ public sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable } [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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable 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 sealed class A2AAgentTests : IDisposable } // 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 sealed class A2AAgentTests : IDisposable } // 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,17 +1411,31 @@ public sealed class A2AAgentTests : IDisposable #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(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.NotNull(result); - Assert.Same(this._a2aClient, result); + Assert.Null(result); } /// @@ -1129,10 +1512,10 @@ public sealed class A2AAgentTests : IDisposable /// 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 @@ public sealed class A2AAgentTests : IDisposable public void Dispose() { + this._a2aClient.Dispose(); this._handler.Dispose(); this._httpClient.Dispose(); } @@ -1269,13 +1653,34 @@ public sealed class A2AAgentTests : IDisposable { public JsonRpcRequest? CapturedJsonRpcRequest { get; set; } - public MessageSendParams? CapturedMessageSendParams { get; set; } + public List CapturedJsonRpcRequests { get; } = []; - public TaskIdParams? CapturedTaskIdParams { get; set; } + public SendMessageRequest? CapturedSendMessageRequest { get; set; } - public A2AEvent? ResponseToReturn { get; set; } + public GetTaskRequest? CapturedGetTaskRequest { get; set; } - public A2AEvent? StreamingResponseToReturn { get; set; } + public SendMessageResponse? ResponseToReturn { get; set; } + + public AgentTask? AgentTaskToReturn { get; set; } + + public StreamResponse? StreamingResponseToReturn { get; set; } + + /// + /// When set, streaming requests for SubscribeToTask will return a JSON-RPC error + /// with this error code. Used to simulate UnsupportedOperation errors. + /// + public A2AErrorCode? StreamingErrorCodeToReturn { get; set; } + + /// + /// Error message to include when is set. + /// + public string StreamingErrorMessage { get; set; } = "Task is in a terminal state and cannot be subscribed to."; + + /// + /// When set, GetTask requests will return a JSON-RPC error with this error code. + /// Used to simulate failures in the GetTaskAsync fallback path. + /// + public A2AErrorCode? GetTaskErrorCodeToReturn { get; set; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { @@ -1286,46 +1691,121 @@ public sealed class A2AAgentTests : IDisposable this.CapturedJsonRpcRequest = JsonSerializer.Deserialize(content); - try + if (this.CapturedJsonRpcRequest is not null) { - this.CapturedMessageSendParams = this.CapturedJsonRpcRequest?.Params?.Deserialize(); + this.CapturedJsonRpcRequests.Add(this.CapturedJsonRpcRequest); } - catch { /* Ignore deserialization errors for non-MessageSendParams requests */ } try { - this.CapturedTaskIdParams = this.CapturedJsonRpcRequest?.Params?.Deserialize(); + this.CapturedSendMessageRequest = this.CapturedJsonRpcRequest?.Params?.Deserialize(A2AJsonUtilities.DefaultOptions); } - catch { /* Ignore deserialization errors for non-TaskIdParams requests */ } + catch { /* Ignore deserialization errors for non-SendMessageRequest requests */ } - // Return the pre-configured non-streaming response - if (this.ResponseToReturn is not null) + try { - var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("response-id", this.ResponseToReturn); + 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") + }; + } + + // Return the pre-configured non-streaming response + if (this.ResponseToReturn is not null) + { + 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 a streaming JSON-RPC error (e.g., UnsupportedOperation for SubscribeToTask) + else if (this.StreamingErrorCodeToReturn is not null + && this.CapturedJsonRpcRequest?.Method is "SubscribeToTask") + { + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Error = new JsonRpcError + { + Code = (int)this.StreamingErrorCodeToReturn.Value, + Message = this.StreamingErrorMessage + } + }; + + var stream = new MemoryStream(); + using (var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true)) + { + await writer.WriteAsync($"data: {JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions)}\n\n"); +#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods; overload doesn't exist downlevel + await writer.FlushAsync(); +#pragma warning restore CA2016 + } + + stream.Position = 0; + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(stream) + { + Headers = { { "Content-Type", "text/event-stream" } } + } + }; + } // Return the pre-configured streaming response else if (this.StreamingResponseToReturn is not null) { - var stream = new MemoryStream(); + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Result = JsonSerializer.SerializeToNode(this.StreamingResponseToReturn, A2AJsonUtilities.DefaultOptions) + }; - await SseFormatter.WriteAsync( - new SseItem[] - { - new(JsonRpcResponse.CreateJsonRpcResponse("response-id", this.StreamingResponseToReturn!)) - }.ToAsyncEnumerable(), - stream, - (item, writer) => - { - using Utf8JsonWriter json = new(writer, new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); - JsonSerializer.Serialize(json, item.Data); - }, - cancellationToken - ); + var 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 @@ public sealed class A2AAgentTests : IDisposable } 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 sealed class A2AContinuationTokenTests 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 sealed class A2AAIContentExtensionsTests 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 sealed class A2AAIContentExtensionsTests 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 sealed class 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 sealed class A2AAgentCardExtensionsTests 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 sealed class A2AAgentCardExtensionsTests 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 @@ public sealed class A2AAgentCardExtensionsTests 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 sealed class A2AAgentTaskExtensionsTests { Id = "task1", Artifacts = [], - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act @@ -58,7 +58,7 @@ public sealed class A2AAgentTaskExtensionsTests { Id = "task1", Artifacts = null, - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act @@ -76,7 +76,7 @@ public sealed class A2AAgentTaskExtensionsTests { Id = "task1", Artifacts = [], - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act @@ -94,7 +94,7 @@ public sealed class A2AAgentTaskExtensionsTests { Id = "task1", Artifacts = null, - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act @@ -110,14 +110,14 @@ public sealed class A2AAgentTaskExtensionsTests // 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 sealed class A2AAgentTaskExtensionsTests // 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 sealed class A2AAgentTaskExtensionsTests { 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 sealed class A2AArtifactExtensionsTests 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 sealed class A2AArtifactExtensionsTests 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 sealed class A2ACardResolverExtensionsTests : IDisposable { 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 sealed class A2ACardResolverExtensionsTests : IDisposable // 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 sealed class A2ACardResolverExtensionsTests : IDisposable 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 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable 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 sealed class A2AClientExtensionsTests 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 sealed class ChatMessageExtensionsTests 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 sealed class ChatMessageExtensionsTests 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()); - } - - /// - /// Verifies that when the agent response has null AdditionalProperties, the returned AgentMessage.Metadata is null. - /// - [Fact] - public async Task MapA2A_WhenResponseHasNullAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")]) - { - AdditionalProperties = null - }; - 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.Null(agentMessage.Metadata); - } - - /// - /// Verifies that when the agent response has empty AdditionalProperties, the returned AgentMessage.Metadata is null. - /// - [Fact] - public async Task MapA2A_WhenResponseHasEmptyAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")]) - { - AdditionalProperties = [] - }; - 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.Null(agentMessage.Metadata); - } - - /// - /// Verifies that when runMode is Message, the result is always an AgentMessage even when - /// the agent would otherwise support background responses. - /// - [Fact] - public async Task MapA2A_MessageMode_AlwaysReturnsAgentMessageAsync() - { - // Arrange - AgentRunOptions? capturedOptions = null; - ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options) - .Object.MapA2A(runMode: AgentRunMode.DisallowBackground); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - - // Assert - Assert.IsType(a2aResponse); - Assert.NotNull(capturedOptions); - Assert.False(capturedOptions.AllowBackgroundResponses); - } - - /// - /// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken), - /// the result is an AgentMessage because the response type is determined solely by ContinuationToken presence. - /// - [Fact] - public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageAsync() - { - // Arrange - AgentRunOptions? capturedOptions = null; - ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options) - .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - - // Assert - Assert.IsType(a2aResponse); - Assert.NotNull(capturedOptions); - Assert.True(capturedOptions.AllowBackgroundResponses); - } - - /// - /// Verifies that a custom Dynamic delegate returning false produces an AgentMessage - /// even when the agent completes immediately (no ContinuationToken). - /// - [Fact] - public async Task MapA2A_DynamicMode_WithFalseCallback_ReturnsAgentMessageAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Quick reply")]); - ITaskManager taskManager = CreateAgentMockWithResponse(response) - .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false))); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - - // Assert - Assert.IsType(a2aResponse); - } - -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - - /// - /// Verifies that when the agent returns a ContinuationToken, an AgentTask in Working state is returned. - /// - [Fact] - public async Task MapA2A_WhenResponseHasContinuationToken_ReturnsAgentTaskInWorkingStateAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - - // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.Equal(TaskState.Working, agentTask.Status.State); - } - - /// - /// Verifies that when the agent returns a ContinuationToken, the returned task includes - /// intermediate messages from the initial response in its status message. - /// - [Fact] - public async Task MapA2A_WhenResponseHasContinuationToken_TaskStatusHasIntermediateMessageAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - - // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.NotNull(agentTask.Status.Message); - TextPart textPart = Assert.IsType(Assert.Single(agentTask.Status.Message.Parts)); - Assert.Equal("Starting work...", textPart.Text); - } - - /// - /// Verifies that when the agent returns a ContinuationToken, the continuation token - /// is serialized into the AgentTask.Metadata for persistence. - /// - [Fact] - public async Task MapA2A_WhenResponseHasContinuationToken_StoresTokenInTaskMetadataAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - - // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.NotNull(agentTask.Metadata); - Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); - } - - /// - /// Verifies that when a task is created (Working or Completed), the original user message - /// is added to the task history, matching the A2A SDK's behavior when it creates tasks internally. - /// - [Fact] - public async Task MapA2A_WhenTaskIsCreated_OriginalMessageIsInHistoryAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - AgentMessage originalMessage = new() { MessageId = "user-msg-1", Role = MessageRole.User, Parts = [new TextPart { Text = "Do something" }] }; - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = originalMessage - }); - - // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.NotNull(agentTask.History); - Assert.Contains(agentTask.History, m => m.MessageId == "user-msg-1" && m.Role == MessageRole.User); - } - - /// - /// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken), - /// the returned AgentMessage preserves the original context ID. - /// - [Fact] - public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageWithContextIdAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]); - ITaskManager taskManager = CreateAgentMockWithResponse(response) - .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported); - AgentMessage originalMessage = new() { MessageId = "user-msg-2", ContextId = "ctx-123", Role = MessageRole.User, Parts = [new TextPart { Text = "Quick task" }] }; - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = originalMessage - }); - - // Assert - AgentMessage agentMessage = Assert.IsType(a2aResponse); - Assert.Equal("ctx-123", agentMessage.ContextId); - } - - /// - /// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token - /// and the agent returns a completed response (null ContinuationToken), the task is updated to Completed. - /// - [Fact] - public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationCompletes_TaskIsCompletedAsync() - { - // Arrange - int callCount = 0; - Mock agentMock = CreateAgentMockWithSequentialResponses( - // First call: return response with ContinuationToken (long-running) - new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) - { - ContinuationToken = CreateTestContinuationToken() - }, - // Second call (via OnTaskUpdated): return completed response - new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]), - ref callCount); - ITaskManager taskManager = agentMock.Object.MapA2A(); - - // Act — trigger OnMessageReceived to create the task - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.Equal(TaskState.Working, agentTask.Status.State); - - // Act — invoke OnTaskUpdated to check on the background operation - await InvokeOnTaskUpdatedAsync(taskManager, agentTask); - - // Assert — task should now be completed - AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(updatedTask); - Assert.Equal(TaskState.Completed, updatedTask.Status.State); - Assert.NotNull(updatedTask.Artifacts); - Artifact artifact = Assert.Single(updatedTask.Artifacts); - TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts)); - Assert.Equal("Done!", textPart.Text); - } - - /// - /// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token - /// and the agent returns another ContinuationToken, the task stays in Working state. - /// - [Fact] - public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationStillWorking_TaskRemainsWorkingAsync() - { - // Arrange - int callCount = 0; - Mock agentMock = CreateAgentMockWithSequentialResponses( - // First call: return response with ContinuationToken - new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) - { - ContinuationToken = CreateTestContinuationToken() - }, - // Second call (via OnTaskUpdated): still working, return another token - new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")]) - { - ContinuationToken = CreateTestContinuationToken() - }, - ref callCount); - ITaskManager taskManager = agentMock.Object.MapA2A(); - - // Act — trigger OnMessageReceived to create the task - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - - // Act — invoke OnTaskUpdated; agent still working - await InvokeOnTaskUpdatedAsync(taskManager, agentTask); - - // Assert — task should still be in Working state - AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(updatedTask); - Assert.Equal(TaskState.Working, updatedTask.Status.State); - } - - /// - /// Verifies the full lifecycle: agent starts background work, first poll returns still working, - /// second poll returns completed. - /// - [Fact] - public async Task MapA2A_OnTaskUpdated_MultiplePolls_EventuallyCompletesAsync() - { - // Arrange - int callCount = 0; - Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation => - { - return invocation switch - { - // First call: start background work - 1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) - { - ContinuationToken = CreateTestContinuationToken() - }, - // Second call: still working - 2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")]) - { - ContinuationToken = CreateTestContinuationToken() - }, - // Third call: done - _ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "All done!")]) - }; - }); - ITaskManager taskManager = agentMock.Object.MapA2A(); - - // Act — create the task - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Do work" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.Equal(TaskState.Working, agentTask.Status.State); - - // Act — first poll: still working - AgentTask? currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(currentTask); - await InvokeOnTaskUpdatedAsync(taskManager, currentTask); - currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(currentTask); - Assert.Equal(TaskState.Working, currentTask.Status.State); - - // Act — second poll: completed - await InvokeOnTaskUpdatedAsync(taskManager, currentTask); - currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(currentTask); - Assert.Equal(TaskState.Completed, currentTask.Status.State); - - // Assert — final output as artifact - Assert.NotNull(currentTask.Artifacts); - Artifact artifact = Assert.Single(currentTask.Artifacts); - TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts)); - Assert.Equal("All done!", textPart.Text); - } - - /// - /// Verifies that when the agent throws during a background operation poll, - /// the task is updated to Failed state. - /// - [Fact] - public async Task MapA2A_OnTaskUpdated_WhenAgentThrows_TaskIsFailedAsync() - { - // Arrange - int callCount = 0; - Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation => - { - if (invocation == 1) - { - return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - } - - throw new InvalidOperationException("Agent failed"); - }); - ITaskManager taskManager = agentMock.Object.MapA2A(); - - // Act — create the task - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - - // Act — poll the task; agent throws - await Assert.ThrowsAsync(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask)); - - // Assert — task should be Failed - AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(updatedTask); - Assert.Equal(TaskState.Failed, updatedTask.Status.State); - } - - /// - /// Verifies that in Task mode with a ContinuationToken, the result is an AgentTask in Working state. - /// - [Fact] - public async Task MapA2A_TaskMode_WhenContinuationToken_ReturnsWorkingAgentTaskAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Working on it...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response) - .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - - // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.Equal(TaskState.Working, agentTask.Status.State); - Assert.NotNull(agentTask.Metadata); - Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); - } - - /// - /// Verifies that when the agent returns a ContinuationToken with no progress messages, - /// the task transitions to Working state with a null status message. - /// - [Fact] - public async Task MapA2A_WhenContinuationTokenWithNoMessages_TaskStatusHasNullMessageAsync() - { - // Arrange - AgentResponse response = new([]) - { - ContinuationToken = CreateTestContinuationToken() - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - - // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.Equal(TaskState.Working, agentTask.Status.State); - Assert.Null(agentTask.Status.Message); - } - - /// - /// Verifies that when OnTaskUpdated is invoked on a completed task with a follow-up message - /// and no continuation token in metadata, the task processes history and completes with a new artifact. - /// - [Fact] - public async Task MapA2A_OnTaskUpdated_WhenNoContinuationToken_ProcessesHistoryAndCompletesAsync() - { - // Arrange - int callCount = 0; - Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation => - { - return invocation switch - { - // First call: create a task with ContinuationToken - 1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) - { - ContinuationToken = CreateTestContinuationToken() - }, - // Second call (via OnTaskUpdated): complete the background operation - 2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]), - // Third call (follow-up via OnTaskUpdated): complete follow-up - _ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Follow-up done!")]) - }; - }); - ITaskManager taskManager = agentMock.Object.MapA2A(); - - // Act — create a working task (with continuation token) - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - - // Act — first OnTaskUpdated: completes the background operation - await InvokeOnTaskUpdatedAsync(taskManager, agentTask); - agentTask = (await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None))!; - Assert.Equal(TaskState.Completed, agentTask.Status.State); - - // Simulate a follow-up message by adding it to history and re-submitting via OnTaskUpdated - agentTask.History ??= []; - agentTask.History.Add(new AgentMessage { MessageId = "follow-up", Role = MessageRole.User, Parts = [new TextPart { Text = "Follow up" }] }); - - // Act — invoke OnTaskUpdated without a continuation token in metadata - await InvokeOnTaskUpdatedAsync(taskManager, agentTask); - - // Assert - AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(updatedTask); - Assert.Equal(TaskState.Completed, updatedTask.Status.State); - Assert.NotNull(updatedTask.Artifacts); - Assert.Equal(2, updatedTask.Artifacts.Count); - Artifact artifact = updatedTask.Artifacts[1]; - TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts)); - Assert.Equal("Follow-up done!", textPart.Text); - } - - /// - /// Verifies that when a task is cancelled, the continuation token is removed from metadata. - /// - [Fact] - public async Task MapA2A_OnTaskCancelled_RemovesContinuationTokenFromMetadataAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - - // Act — create a working task with a continuation token - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.NotNull(agentTask.Metadata); - Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); - - // Act — cancel the task - await taskManager.CancelTaskAsync(new TaskIdParams { Id = agentTask.Id }, CancellationToken.None); - - // Assert — continuation token should be removed from metadata - Assert.False(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); - } - - /// - /// Verifies that when the agent throws an OperationCanceledException during a poll, - /// it is re-thrown without marking the task as Failed. - /// - [Fact] - public async Task MapA2A_OnTaskUpdated_WhenOperationCancelled_DoesNotMarkFailedAsync() - { - // Arrange - int callCount = 0; - Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation => - { - if (invocation == 1) - { - return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - } - - throw new OperationCanceledException("Cancelled"); - }); - ITaskManager taskManager = agentMock.Object.MapA2A(); - - // Act — create the task - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - - // Act — poll the task; agent throws OperationCanceledException - await Assert.ThrowsAsync(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask)); - - // Assert — task should still be Working, not Failed - AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(updatedTask); - Assert.Equal(TaskState.Working, updatedTask.Status.State); - } - - /// - /// Verifies that when the incoming message has a ContextId, it is used for the task - /// rather than generating a new one. - /// - [Fact] - public async Task MapA2A_WhenMessageHasContextId_UsesProvidedContextIdAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage - { - MessageId = "test-id", - ContextId = "my-context-123", - Role = MessageRole.User, - Parts = [new TextPart { Text = "Hello" }] - } - }); - - // Assert - AgentMessage agentMessage = Assert.IsType(a2aResponse); - Assert.Equal("my-context-123", agentMessage.ContextId); - } - -#pragma warning restore MEAI001 - - 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 async Task InvokeOnMessageReceivedAsync(ITaskManager taskManager, MessageSendParams messageSendParams) - { - Func>? handler = taskManager.OnMessageReceived; - Assert.NotNull(handler); - return await handler.Invoke(messageSendParams, CancellationToken.None); - } - - private static async Task InvokeOnTaskUpdatedAsync(ITaskManager taskManager, AgentTask agentTask) - { - Func? handler = taskManager.OnTaskUpdated; - Assert.NotNull(handler); - await handler.Invoke(agentTask, CancellationToken.None); - } - -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - private static ResponseContinuationToken CreateTestContinuationToken() - { - return ResponseContinuationToken.FromBytes(new byte[] { 0x01, 0x02, 0x03 }); - } -#pragma warning restore MEAI001 - - private static Mock CreateAgentMockWithSequentialResponses( - AgentResponse firstResponse, - AgentResponse secondResponse, - ref int callCount) - { - return CreateAgentMockWithCallCount(ref callCount, invocation => - invocation == 1 ? firstResponse : secondResponse); - } - - private static Mock CreateAgentMockWithCallCount( - ref int callCount, - Func responseFactory) - { - // Use a StrongBox to allow the lambda to capture a mutable reference - 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 sealed class TestAgentSession : AgentSession; -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs new file mode 100644 index 0000000000..cbe1254b81 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class AgentRunModeTests +{ + /// + /// Verifies that AllowBackgroundWhen throws ArgumentNullException for null delegate. + /// + [Fact] + public void AllowBackgroundWhen_NullDelegate_ThrowsArgumentNullException() + { + // Arrange & Act & Assert + Assert.Throws(() => + AgentRunMode.AllowBackgroundWhen(null!)); + } + + /// + /// Verifies that DisallowBackground equals another DisallowBackground instance. + /// + [Fact] + public void Equals_DisallowBackground_AreEqual() + { + // Arrange + var mode1 = AgentRunMode.DisallowBackground; + var mode2 = AgentRunMode.DisallowBackground; + + // Act & Assert + Assert.True(mode1.Equals(mode2)); + Assert.True(mode1 == mode2); + Assert.False(mode1 != mode2); + Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode()); + } + + /// + /// Verifies that AllowBackgroundIfSupported equals another AllowBackgroundIfSupported instance. + /// + [Fact] + public void Equals_AllowBackgroundIfSupported_AreEqual() + { + // Arrange + var mode1 = AgentRunMode.AllowBackgroundIfSupported; + var mode2 = AgentRunMode.AllowBackgroundIfSupported; + + // Act & Assert + Assert.True(mode1.Equals(mode2)); + Assert.True(mode1 == mode2); + } + + /// + /// Verifies that DisallowBackground and AllowBackgroundIfSupported are not equal. + /// + [Fact] + public void Equals_DifferentModes_AreNotEqual() + { + // Arrange + var disallow = AgentRunMode.DisallowBackground; + var allow = AgentRunMode.AllowBackgroundIfSupported; + + // Act & Assert + Assert.False(disallow.Equals(allow)); + Assert.False(disallow == allow); + Assert.True(disallow != allow); + } + + /// + /// Verifies that Equals returns false for null. + /// + [Fact] + public void Equals_Null_ReturnsFalse() + { + // Arrange + var mode = AgentRunMode.DisallowBackground; + + // Act & Assert + Assert.False(mode.Equals(null)); + Assert.False(mode.Equals((object?)null)); + Assert.False(mode == null); + Assert.True(mode != null); + } + + /// + /// Verifies that two null AgentRunMode values are equal. + /// + [Fact] + public void Equals_BothNull_AreEqual() + { + // Arrange + AgentRunMode? mode1 = null; + AgentRunMode? mode2 = null; + + // Act & Assert + Assert.True(mode1 == mode2); + Assert.False(mode1 != mode2); + } + + /// + /// Verifies that ToString returns expected values. + /// + [Fact] + public void ToString_ReturnsExpectedValues() + { + // Act & Assert + Assert.Equal("message", AgentRunMode.DisallowBackground.ToString()); + Assert.Equal("task", AgentRunMode.AllowBackgroundIfSupported.ToString()); + Assert.Equal("dynamic", AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)).ToString()); + } + + /// + /// Verifies that Equals works correctly with object parameter. + /// + [Fact] + public void Equals_WithObjectParameter_WorksCorrectly() + { + // Arrange + var mode = AgentRunMode.DisallowBackground; + + // Act & Assert + Assert.True(mode.Equals((object)AgentRunMode.DisallowBackground)); + Assert.False(mode.Equals((object)AgentRunMode.AllowBackgroundIfSupported)); + Assert.False(mode.Equals("not a run mode")); + } + + /// + /// Verifies that two AllowBackgroundWhen instances with different delegates are not considered equal, + /// because equality includes delegate identity for dynamic modes. + /// + [Fact] + public void Equals_AllowBackgroundWhen_DifferentDelegates_AreNotEqual() + { + // Arrange + var mode1 = AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)); + var mode2 = AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false)); + + // Act & Assert + Assert.False(mode1.Equals(mode2)); + Assert.True(mode1 != mode2); + } + + /// + /// Verifies that two AllowBackgroundWhen instances with the same delegate are considered equal. + /// + [Fact] + public void Equals_AllowBackgroundWhen_SameDelegate_AreEqual() + { + // Arrange + static ValueTask CallbackAsync(A2ARunDecisionContext _, CancellationToken __) => ValueTask.FromResult(true); + var mode1 = AgentRunMode.AllowBackgroundWhen(CallbackAsync); + var mode2 = AgentRunMode.AllowBackgroundWhen(CallbackAsync); + + // Act & Assert + Assert.True(mode1.Equals(mode2)); + Assert.True(mode1 == mode2); + Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode()); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs index 69eaf3a535..e26189e9e5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; using System.Linq; using A2A; using Microsoft.Agents.AI.Hosting.A2A.Converters; @@ -10,66 +11,66 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests.Converters; public class MessageConverterTests { [Fact] - public void ToChatMessages_MessageSendParams_Null_ReturnsEmptyCollection() + public void ToChatMessages_SendMessageRequest_Null_ReturnsEmptyCollection() { - MessageSendParams? messageSendParams = null; + SendMessageRequest? sendMessageRequest = null; - var result = messageSendParams!.ToChatMessages(); + var result = sendMessageRequest!.ToChatMessages(); Assert.NotNull(result); Assert.Empty(result); } [Fact] - public void ToChatMessages_MessageSendParams_WithNullMessage_ReturnsEmptyCollection() + public void ToChatMessages_SendMessageRequest_WithNullMessage_ReturnsEmptyCollection() { - var messageSendParams = new MessageSendParams + var sendMessageRequest = new SendMessageRequest { Message = null! }; - var result = messageSendParams.ToChatMessages(); + var result = sendMessageRequest.ToChatMessages(); Assert.NotNull(result); Assert.Empty(result); } [Fact] - public void ToChatMessages_MessageSendParams_WithMessageWithoutParts_ReturnsEmptyCollection() + public void ToChatMessages_SendMessageRequest_WithMessageWithoutParts_ReturnsEmptyCollection() { - var messageSendParams = new MessageSendParams + var sendMessageRequest = new SendMessageRequest { - Message = new AgentMessage + Message = new Message { MessageId = "test-id", - Role = MessageRole.User, + Role = Role.User, Parts = null! } }; - var result = messageSendParams.ToChatMessages(); + var result = sendMessageRequest.ToChatMessages(); Assert.NotNull(result); Assert.Empty(result); } [Fact] - public void ToChatMessages_MessageSendParams_WithValidTextMessage_ReturnsCorrectChatMessage() + public void ToChatMessages_SendMessageRequest_WithValidTextMessage_ReturnsCorrectChatMessage() { - var messageSendParams = new MessageSendParams + var sendMessageRequest = new SendMessageRequest { - Message = new AgentMessage + Message = new Message { MessageId = "test-id", - Role = MessageRole.User, + Role = Role.User, Parts = [ - new TextPart { Text = "Hello, world!" } + new Part { Text = "Hello, world!" } ] } }; - var result = messageSendParams.ToChatMessages(); + var result = sendMessageRequest.ToChatMessages(); Assert.NotNull(result); Assert.Single(result); @@ -82,4 +83,68 @@ public class MessageConverterTests var textContent = Assert.IsType(chatMessage.Contents.First()); Assert.Equal("Hello, world!", textContent.Text); } + + [Fact] + public void ToParts_NullList_ReturnsEmptyList() + { + // Arrange + IList? messages = null; + + // Act + var result = messages!.ToParts(); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void ToParts_EmptyList_ReturnsEmptyList() + { + // Arrange + IList messages = []; + + // Act + var result = messages.ToParts(); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void ToParts_WithTextContent_ReturnsTextPart() + { + // Arrange + IList messages = + [ + new ChatMessage(ChatRole.Assistant, "Hello from the agent!") + ]; + + // Act + var result = messages.ToParts(); + + // Assert + Assert.Single(result); + Assert.Equal("Hello from the agent!", result[0].Text); + } + + [Fact] + public void ToParts_WithMultipleMessages_ReturnsAllParts() + { + // Arrange + IList messages = + [ + new ChatMessage(ChatRole.User, "First message"), + new ChatMessage(ChatRole.Assistant, "Second message") + ]; + + // Act + var result = messages.ToParts(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("First message", result[0].Text); + Assert.Equal("Second message", result[1].Text); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs deleted file mode 100644 index a848528888..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs +++ /dev/null @@ -1,479 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using A2A; -using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; - -namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; - -/// -/// Tests for MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions.MapA2A method. -/// -public sealed class EndpointRouteA2ABuilderExtensionsTests -{ - /// - /// Verifies that MapA2A throws ArgumentNullException for null endpoints. - /// - [Fact] - public void MapA2A_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.MapA2A(agentBuilder, "/a2a")); - - Assert.Equal("endpoints", exception.ParamName); - } - - /// - /// Verifies that MapA2A throws ArgumentNullException for null agentBuilder. - /// - [Fact] - public void MapA2A_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.MapA2A(agentBuilder, "/a2a")); - - Assert.Equal("agentBuilder", exception.ParamName); - } - - /// - /// Verifies that MapA2A with IHostedAgentBuilder correctly maps the agent with default task manager configuration. - /// - [Fact] - public void MapA2A_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"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a"); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A with IHostedAgentBuilder and custom task manager configuration succeeds. - /// - [Fact] - public void MapA2A_WithAgentBuilder_CustomTaskManagerConfiguration_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", taskManager => { }); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A with IHostedAgentBuilder and agent card succeeds. - /// - [Fact] - public void MapA2A_WithAgentBuilder_WithAgentCard_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; - - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", agentCard); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A with IHostedAgentBuilder, agent card, and custom task manager configuration succeeds. - /// - [Fact] - public void MapA2A_WithAgentBuilder_WithAgentCardAndCustomConfiguration_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; - - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", agentCard, taskManager => { }); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using string agent name. - /// - [Fact] - public void MapA2A_WithAgentName_NullEndpoints_ThrowsArgumentNullException() - { - // Arrange - AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; - - // Act & Assert - ArgumentNullException exception = Assert.Throws(() => - endpoints.MapA2A("agent", "/a2a")); - - Assert.Equal("endpoints", exception.ParamName); - } - - /// - /// Verifies that MapA2A with string agent name correctly maps the agent. - /// - [Fact] - public void MapA2A_WithAgentName_DefaultConfiguration_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a"); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A with string agent name and custom task manager configuration succeeds. - /// - [Fact] - public void MapA2A_WithAgentName_CustomTaskManagerConfiguration_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a", taskManager => { }); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A with string agent name and agent card succeeds. - /// - [Fact] - public void MapA2A_WithAgentName_WithAgentCard_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; - - // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a", agentCard); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A with string agent name, agent card, and custom task manager configuration succeeds. - /// - [Fact] - public void MapA2A_WithAgentName_WithAgentCardAndCustomConfiguration_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; - - // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a", agentCard, taskManager => { }); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using AIAgent. - /// - [Fact] - public void MapA2A_WithAIAgent_NullEndpoints_ThrowsArgumentNullException() - { - // Arrange - AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; - - // Act & Assert - ArgumentNullException exception = Assert.Throws(() => - endpoints.MapA2A((AIAgent)null!, "/a2a")); - - Assert.Equal("endpoints", exception.ParamName); - } - - /// - /// Verifies that MapA2A with AIAgent correctly maps the agent. - /// - [Fact] - public void MapA2A_WithAIAgent_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.AddLogging(); - using WebApplication app = builder.Build(); - AIAgent agent = app.Services.GetRequiredKeyedService("agent"); - - // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a"); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A with AIAgent and custom task manager configuration succeeds. - /// - [Fact] - public void MapA2A_WithAIAgent_CustomTaskManagerConfiguration_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - AIAgent agent = app.Services.GetRequiredKeyedService("agent"); - - // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a", taskManager => { }); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A with AIAgent and agent card succeeds. - /// - [Fact] - public void MapA2A_WithAIAgent_WithAgentCard_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - AIAgent agent = app.Services.GetRequiredKeyedService("agent"); - - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; - - // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a", agentCard); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A with AIAgent, agent card, and custom task manager configuration succeeds. - /// - [Fact] - public void MapA2A_WithAIAgent_WithAgentCardAndCustomConfiguration_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - AIAgent agent = app.Services.GetRequiredKeyedService("agent"); - - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; - - // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a", agentCard, taskManager => { }); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using ITaskManager. - /// - [Fact] - public void MapA2A_WithTaskManager_NullEndpoints_ThrowsArgumentNullException() - { - // Arrange - AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; - ITaskManager taskManager = null!; - - // Act & Assert - ArgumentNullException exception = Assert.Throws(() => - endpoints.MapA2A(taskManager, "/a2a")); - - Assert.Equal("endpoints", exception.ParamName); - } - - /// - /// Verifies that multiple agents can be mapped to different paths. - /// - [Fact] - public void MapA2A_MultipleAgents_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agent1Builder = builder.AddAIAgent("agent1", "Instructions1", chatClientServiceKey: "chat-client"); - IHostedAgentBuilder agent2Builder = builder.AddAIAgent("agent2", "Instructions2", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - // Act & Assert - Should not throw - app.MapA2A(agent1Builder, "/a2a/agent1"); - app.MapA2A(agent2Builder, "/a2a/agent2"); - Assert.NotNull(app); - } - - /// - /// Verifies that custom paths can be specified for A2A endpoints. - /// - [Fact] - public void MapA2A_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"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - // Act & Assert - Should not throw - app.MapA2A(agentBuilder, "/custom/a2a/path"); - Assert.NotNull(app); - } - - /// - /// Verifies that task manager configuration callback is invoked correctly. - /// - [Fact] - public void MapA2A_WithAgentBuilder_TaskManagerConfigurationCallbackInvoked() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - bool configureCallbackInvoked = false; - - // Act - app.MapA2A(agentBuilder, "/a2a", taskManager => - { - configureCallbackInvoked = true; - Assert.NotNull(taskManager); - }); - - // Assert - Assert.True(configureCallbackInvoked); - } - - /// - /// Verifies that agent card with all properties is accepted. - /// - [Fact] - public void MapA2A_WithAgentBuilder_FullAgentCard_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A comprehensive test agent" - }; - - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", agentCard); - Assert.NotNull(result); - } -}