diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 46e0d61924..8fedb11f7f 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -42,15 +42,15 @@ - + - + - + @@ -104,8 +104,8 @@ - - + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index b3a95ea1f6..f1ade4eedc 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -344,11 +344,13 @@ - - - - - + + + + + + + diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj b/dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj similarity index 100% rename from dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj rename to dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/Program.cs b/dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/Program.cs similarity index 100% rename from dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/Program.cs rename to dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/Program.cs diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/README.md b/dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/README.md similarity index 100% rename from dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/README.md rename to dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/README.md diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj b/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj similarity index 86% rename from dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj rename to dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj index 1bccc99d4f..d91b20e34b 100644 --- a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj +++ b/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj @@ -2,7 +2,7 @@ Exe - net10.0 + net10.0 enable enable @@ -13,7 +13,6 @@ - diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/Program.cs b/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/Program.cs similarity index 83% rename from dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/Program.cs rename to dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/Program.cs index e1731604a9..9410785c39 100644 --- a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/Program.cs +++ b/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/Program.cs @@ -18,8 +18,12 @@ AgentSession session = await agent.CreateSessionAsync(); +// AllowBackgroundResponses must be true so the server returns immediately with a continuation token +// instead of blocking until the task is complete. +AgentRunOptions options = new() { AllowBackgroundResponses = true }; + // Start the initial run with a long-running task. -AgentResponse response = await agent.RunAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session); +AgentResponse response = await agent.RunAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session, options: options); // Poll until the response is complete. while (response.ContinuationToken is { } token) diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/README.md b/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/README.md similarity index 100% rename from dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/README.md rename to dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/README.md diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj new file mode 100644 index 0000000000..d21ac952b3 --- /dev/null +++ b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/Program.cs b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/Program.cs new file mode 100644 index 0000000000..4d1612ee36 --- /dev/null +++ b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/Program.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to select the A2A protocol binding (HTTP+JSON vs JSON-RPC) when +// creating an AIAgent from an A2A agent card using A2AClientOptions.PreferredBindings. + +using A2A; +using Microsoft.Agents.AI; + +var a2aAgentHost = Environment.GetEnvironmentVariable("A2A_AGENT_HOST") ?? throw new InvalidOperationException("A2A_AGENT_HOST is not set."); + +// Initialize an A2ACardResolver to get an A2A agent card. +A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost)); + +// Get the agent card +AgentCard agentCard = await agentCardResolver.GetAgentCardAsync(); + +// Use A2AClientOptions to explicitly select the HTTP+JSON protocol binding. +// This tells the A2A client factory to prefer the HTTP+JSON interface when the agent card +// advertises multiple supported interfaces. +A2AClientOptions options = new() +{ + PreferredBindings = [ProtocolBindingNames.HttpJson] +}; + +// To prefer JSON-RPC instead, use: +// A2AClientOptions options = new() +// { +// PreferredBindings = [ProtocolBindingNames.JsonRpc] +// }; + +// Create an instance of the AIAgent for an existing A2A agent, using the specified protocol binding. +AIAgent agent = agentCard.AsAIAgent(options: options); + +// Invoke the agent and output the text result. +AgentResponse response = await agent.RunAsync("Tell me a joke about a pirate."); +Console.WriteLine(response); diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/README.md b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/README.md new file mode 100644 index 0000000000..b50a76240c --- /dev/null +++ b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/README.md @@ -0,0 +1,27 @@ +# A2A Agent Protocol Selection + +This sample demonstrates how to select the A2A protocol binding when creating an `AIAgent` from an A2A agent card. + +A2A agents can expose multiple interfaces with different protocol bindings (e.g., HTTP+JSON, JSON-RPC). By default, `AsAIAgent()` prefers HTTP+JSON with JSON-RPC as a fallback. This sample shows how to use `A2AClientOptions.PreferredBindings` to explicitly control which protocol binding is used. + +The sample: + +- Connects to an A2A agent server specified in the `A2A_AGENT_HOST` environment variable +- Configures `A2AClientOptions` to prefer the HTTP+JSON protocol binding +- Creates an `AIAgent` from the resolved agent card using the specified binding +- Sends a message to the agent and displays the response + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 10.0 SDK or later +- An A2A agent server running and accessible via HTTP + +**Note**: These samples need to be run against a valid A2A server. If no A2A server is available, they can be run against the echo-agent that can be spun up locally by following the guidelines at: https://github.com/a2aproject/a2a-dotnet/blob/main/samples/AgentServer/README.md + +Set the following environment variable: + +```powershell +$env:A2A_AGENT_HOST="http://localhost:5000" # Replace with your A2A agent server host +``` diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj new file mode 100644 index 0000000000..e75368ea99 --- /dev/null +++ b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/Program.cs b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/Program.cs new file mode 100644 index 0000000000..9a4a680c62 --- /dev/null +++ b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/Program.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens, +// allowing recovery from stream interruptions without losing progress. + +using A2A; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +var a2aAgentHost = Environment.GetEnvironmentVariable("A2A_AGENT_HOST") ?? throw new InvalidOperationException("A2A_AGENT_HOST is not set."); + +// Initialize an A2ACardResolver to get an A2A agent card. +A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost)); + +// Get the agent card +AgentCard agentCard = await agentCardResolver.GetAgentCardAsync(); + +// Create an instance of the AIAgent for an existing A2A agent specified by the agent card. +AIAgent agent = agentCard.AsAIAgent(); + +AgentSession session = await agent.CreateSessionAsync(); + +ResponseContinuationToken? continuationToken = null; + +await foreach (var update in agent.RunStreamingAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session)) +{ + // Saving the continuation token to be able to reconnect to the same response stream later. + // Note: Continuation tokens are only returned for long-running tasks. If the underlying A2A agent + // returns a message instead of a task, the continuation token will not be initialized. + // A2A agents do not support stream resumption from a specific point in the stream, + // but only reconnection to obtain the same response stream from the beginning. + // So, A2A agents will return an initialized continuation token in the first update + // representing the beginning of the stream, and it will be null in all subsequent updates. + if (update.ContinuationToken is { } token) + { + continuationToken = token; + } + + // Imitating stream interruption + break; +} + +// Reconnect to the same response stream using the continuation token obtained from the previous run. +// As a first update, the agent will return an update representing the current state of the response at the moment of calling +// RunStreamingAsync with the same continuation token, followed by other updates until the end of the stream is reached. +if (continuationToken is not null) +{ + await foreach (var update in agent.RunStreamingAsync(session, options: new() { ContinuationToken = continuationToken })) + { + if (!string.IsNullOrEmpty(update.Text)) + { + Console.WriteLine(update.Text); + } + } +} diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/README.md b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/README.md new file mode 100644 index 0000000000..ca5b0b66ad --- /dev/null +++ b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/README.md @@ -0,0 +1,29 @@ +# A2A Agent Stream Reconnection + +This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens, allowing recovery from stream interruptions without losing progress. + +The sample: + +- Connects to an A2A agent server specified in the `A2A_AGENT_HOST` environment variable +- Sends a request to the agent and begins streaming the response +- Captures a continuation token from the stream for later reconnection +- Simulates a stream interruption by breaking out of the streaming loop +- Reconnects to the same response stream using the captured continuation token +- Displays the response received after reconnection + +This pattern is useful when network interruptions or other failures may disrupt an ongoing streaming response, and you need to recover and continue processing. + +> **Note:** Continuation tokens are only available when the underlying A2A agent returns a task. If the agent returns a message instead, the continuation token will not be initialized and stream reconnection is not applicable. + +# Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 10.0 SDK or later +- An A2A agent server running and accessible via HTTP + +Set the following environment variable: + +```powershell +$env:A2A_AGENT_HOST="http://localhost:5000" # Replace with your A2A agent server host +``` diff --git a/dotnet/samples/04-hosting/A2A/README.md b/dotnet/samples/02-agents/A2A/README.md similarity index 77% rename from dotnet/samples/04-hosting/A2A/README.md rename to dotnet/samples/02-agents/A2A/README.md index 55539a8322..28f2c0a910 100644 --- a/dotnet/samples/04-hosting/A2A/README.md +++ b/dotnet/samples/02-agents/A2A/README.md @@ -3,7 +3,7 @@ These samples demonstrate how to work with Agent-to-Agent (A2A) specific features in the Agent Framework. For other samples that demonstrate how to use AIAgent instances, -see the [Getting Started With Agents](../../02-agents/Agents/README.md) samples. +see the [Getting Started With Agents](../Agents/README.md) samples. ## Prerequisites @@ -15,6 +15,8 @@ See the README.md for each sample for the prerequisites for that sample. |---|---| |[A2A Agent As Function Tools](./A2AAgent_AsFunctionTools/)|This sample demonstrates how to represent an A2A agent as a set of function tools, where each function tool corresponds to a skill of the A2A agent, and register these function tools with another AI agent so it can leverage the A2A agent's skills.| |[A2A Agent Polling For Task Completion](./A2AAgent_PollingForTaskCompletion/)|This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A agent.| +|[A2A Agent Stream Reconnection](./A2AAgent_StreamReconnection/)|This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens, allowing recovery from stream interruptions.| +|[A2A Agent Protocol Selection](./A2AAgent_ProtocolSelection/)|This sample demonstrates how to select the A2A protocol binding (HTTP+JSON vs JSON-RPC) when creating an AIAgent from an A2A agent card using A2AClientOptions.| ## Running the samples from the console diff --git a/dotnet/samples/02-agents/README.md b/dotnet/samples/02-agents/README.md index 5ff0db416d..69f649c9b4 100644 --- a/dotnet/samples/02-agents/README.md +++ b/dotnet/samples/02-agents/README.md @@ -19,3 +19,4 @@ The getting started samples demonstrate the fundamental concepts and functionali | [Declarative Agents](./DeclarativeAgents) | Loading and executing AI agents from YAML configuration files | | [AG-UI](./AGUI/README.md) | Getting started with AG-UI (Agent UI Protocol) servers and clients | | [Dev UI](./DevUI/README.md) | Interactive web interface for testing and debugging AI agents during development | +| [A2A Agents](./A2A/README.md) | Working with Agent-to-Agent (A2A) specific features | diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/Program.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/Program.cs index 3624acd981..2175e13e71 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/Program.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/Program.cs @@ -62,12 +62,10 @@ private static async Task HandleCommandsAsync(CancellationToken cancellationToke } var agentResponse = await hostAgent.Agent!.RunAsync(message, session, cancellationToken: cancellationToken); - foreach (var chatMessage in agentResponse.Messages) - { - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine($"\nAgent: {chatMessage.Text}"); - Console.ResetColor(); - } + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine($"\nAgent: {agentResponse.Text}"); + Console.ResetColor(); } } catch (Exception ex) diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs index e4661f3217..8260b8302c 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs @@ -14,7 +14,7 @@ namespace A2AServer; internal static class HostAgentFactory { - internal static async Task<(AIAgent, AgentCard)> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string agentName, IList? tools = null) + internal static async Task<(AIAgent, AgentCard)> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string agentName, string[] agentUrls, IList? tools = null) { // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid @@ -26,16 +26,16 @@ internal static class HostAgentFactory AgentCard agentCard = agentType.ToUpperInvariant() switch { - "INVOICE" => GetInvoiceAgentCard(), - "POLICY" => GetPolicyAgentCard(), - "LOGISTICS" => GetLogisticsAgentCard(), + "INVOICE" => GetInvoiceAgentCard(agentUrls), + "POLICY" => GetPolicyAgentCard(agentUrls), + "LOGISTICS" => GetLogisticsAgentCard(agentUrls), _ => throw new ArgumentException($"Unsupported agent type: {agentType}"), }; return new(agent, agentCard); } - internal static async Task<(AIAgent, AgentCard)> CreateChatCompletionHostAgentAsync(string agentType, string model, string apiKey, string name, string instructions, IList? tools = null) + internal static async Task<(AIAgent, AgentCard)> CreateChatCompletionHostAgentAsync(string agentType, string model, string apiKey, string name, string instructions, string[] agentUrls, IList? tools = null) { AIAgent agent = new OpenAIClient(apiKey) .GetChatClient(model) @@ -43,9 +43,9 @@ internal static class HostAgentFactory AgentCard agentCard = agentType.ToUpperInvariant() switch { - "INVOICE" => GetInvoiceAgentCard(), - "POLICY" => GetPolicyAgentCard(), - "LOGISTICS" => GetLogisticsAgentCard(), + "INVOICE" => GetInvoiceAgentCard(agentUrls), + "POLICY" => GetPolicyAgentCard(agentUrls), + "LOGISTICS" => GetLogisticsAgentCard(agentUrls), _ => throw new ArgumentException($"Unsupported agent type: {agentType}"), }; @@ -53,7 +53,7 @@ internal static class HostAgentFactory } #region private - private static AgentCard GetInvoiceAgentCard() + private static AgentCard GetInvoiceAgentCard(string[] agentUrls) { var capabilities = new AgentCapabilities() { @@ -82,10 +82,11 @@ private static AgentCard GetInvoiceAgentCard() DefaultOutputModes = ["text"], Capabilities = capabilities, Skills = [invoiceQuery], + SupportedInterfaces = CreateAgentInterfaces(agentUrls) }; } - private static AgentCard GetPolicyAgentCard() + private static AgentCard GetPolicyAgentCard(string[] agentUrls) { var capabilities = new AgentCapabilities() { @@ -114,10 +115,11 @@ private static AgentCard GetPolicyAgentCard() DefaultOutputModes = ["text"], Capabilities = capabilities, Skills = [policyQuery], + SupportedInterfaces = CreateAgentInterfaces(agentUrls) }; } - private static AgentCard GetLogisticsAgentCard() + private static AgentCard GetLogisticsAgentCard(string[] agentUrls) { var capabilities = new AgentCapabilities() { @@ -146,7 +148,29 @@ private static AgentCard GetLogisticsAgentCard() DefaultOutputModes = ["text"], Capabilities = capabilities, Skills = [logisticsQuery], + SupportedInterfaces = CreateAgentInterfaces(agentUrls) }; } + + private static List CreateAgentInterfaces(string[] agentUrls) + { + List agentInterfaces = []; + + agentInterfaces.AddRange(agentUrls.Select(url => new AgentInterface + { + Url = url, + ProtocolBinding = "JSONRPC", + ProtocolVersion = "1.0", + })); + + agentInterfaces.AddRange(agentUrls.Select(url => new AgentInterface + { + Url = url, + ProtocolBinding = "HTTP+JSON", + ProtocolVersion = "1.0", + })); + + return agentInterfaces; + } #endregion } diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs index 8dcb3d1a34..c12a1c9431 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs @@ -25,10 +25,6 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddHttpClient().AddLogging(); -var app = builder.Build(); - -var httpClient = app.Services.GetRequiredService().CreateClient(); -var logger = app.Logger; IConfigurationRoot configuration = new ConfigurationBuilder() .AddEnvironmentVariables() @@ -38,14 +34,15 @@ string? apiKey = configuration["OPENAI_API_KEY"]; string model = configuration["OPENAI_CHAT_MODEL_NAME"] ?? "gpt-5.4-mini"; string? endpoint = configuration["AZURE_AI_PROJECT_ENDPOINT"]; +string[] agentUrls = (builder.Configuration["urls"] ?? "http://localhost:5000").Split(';'); var invoiceQueryPlugin = new InvoiceQuery(); IList tools = - [ +[ AIFunctionFactory.Create(invoiceQueryPlugin.QueryInvoices), AIFunctionFactory.Create(invoiceQueryPlugin.QueryByTransactionId), AIFunctionFactory.Create(invoiceQueryPlugin.QueryByInvoiceId) - ]; +]; AIAgent hostA2AAgent; AgentCard hostA2AAgentCard; @@ -54,9 +51,9 @@ { (hostA2AAgent, hostA2AAgentCard) = agentType.ToUpperInvariant() switch { - "INVOICE" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, tools), - "POLICY" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName), - "LOGISTICS" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName), + "INVOICE" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls, tools), + "POLICY" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls), + "LOGISTICS" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls), _ => throw new ArgumentException($"Unsupported agent type: {agentType}"), }; } @@ -68,7 +65,7 @@ agentType, model, apiKey, "InvoiceAgent", """ You specialize in handling queries related to invoices. - """, tools), + """, agentUrls, tools), "POLICY" => await HostAgentFactory.CreateChatCompletionHostAgentAsync( agentType, model, apiKey, "PolicyAgent", """ @@ -84,7 +81,7 @@ You specialize in handling queries related to policies and customer communicatio resolution in SAP CRM and notify the customer via email within 2 business days, referencing the original invoice and the credit memo number. Use the 'Formal Credit Notification' email template." - """), + """, agentUrls), "LOGISTICS" => await HostAgentFactory.CreateChatCompletionHostAgentAsync( agentType, model, apiKey, "LogisticsAgent", """ @@ -95,7 +92,7 @@ You specialize in handling queries related to logistics. Shipment number: SHPMT-SAP-001 Item: TSHIRT-RED-L Quantity: 900 - """), + """, agentUrls), _ => throw new ArgumentException($"Unsupported agent type: {agentType}"), }; } @@ -104,10 +101,12 @@ You specialize in handling queries related to logistics. throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided"); } -var a2aTaskManager = app.MapA2A( - hostA2AAgent, - path: "/", - agentCard: hostA2AAgentCard, - taskManager => app.MapWellKnownAgentCard(taskManager, "/")); +builder.AddA2AServer(hostA2AAgent); + +var app = builder.Build(); +app.MapA2AHttpJson(hostA2AAgent, "/"); +app.MapA2AJsonRpc(hostA2AAgent, "/"); + +app.MapWellKnownAgentCard(hostA2AAgentCard); await app.RunAsync(); diff --git a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs index 15e7cbbd86..c18dbad3a4 100644 --- a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using A2A; using A2A.AspNetCore; using AgentWebChat.AgentHost; using AgentWebChat.AgentHost.Custom; @@ -146,6 +147,9 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te instructions: "you are a dependency inject agent. Tell me all about dependency injection."); }); +pirateAgentBuilder.AddA2AServer(); +knightsKnavesAgentBuilder.AddA2AServer(); + var app = builder.Build(); app.MapOpenApi(); @@ -154,17 +158,9 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te // Configure the HTTP request pipeline. app.UseExceptionHandler(); -// attach a2a with simple message communication -app.MapA2A(pirateAgentBuilder, path: "/a2a/pirate"); -app.MapA2A(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves", agentCard: new() -{ - Name = "Knights and Knaves", - Description = "An agent that helps you solve the knights and knaves puzzle.", - Version = "1.0", - - // Url can be not set, and SDK will help assign it. - // Url = "http://localhost:5390/a2a/knights-and-knaves" -}); +// Expose A2A servers over HTTP with JSON payloads +app.MapA2AHttpJson(pirateAgentBuilder, path: "/a2a/pirate"); +app.MapA2AHttpJson(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves"); app.MapDevUI(); diff --git a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs index f790ec0daa..d2c67d0ca5 100644 --- a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs +++ b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs @@ -43,20 +43,21 @@ public override async IAsyncEnumerable RunStreamingAsync( { // Convert all messages to A2A parts and create a single message var parts = messages.ToParts(); - var a2aMessage = new AgentMessage + var a2aMessage = new Message { MessageId = Guid.NewGuid().ToString("N"), ContextId = contextId, - Role = MessageRole.User, + Role = Role.User, Parts = parts }; - var messageSendParams = new MessageSendParams { Message = a2aMessage }; + var messageSendParams = new SendMessageRequest { Message = a2aMessage }; var a2aResponse = await a2aClient.SendMessageAsync(messageSendParams, cancellationToken); // Handle different response types - if (a2aResponse is AgentMessage message) + if (a2aResponse.PayloadCase == SendMessageResponseCase.Message) { + var message = a2aResponse.Message!; var responseMessage = message.ToChatMessage(); if (responseMessage is { Contents.Count: > 0 }) { @@ -67,9 +68,10 @@ public override async IAsyncEnumerable RunStreamingAsync( }); } } - else if (a2aResponse is AgentTask agentTask) + else if (a2aResponse.PayloadCase == SendMessageResponseCase.Task) { // Manually convert AgentTask artifacts to ChatMessages since the extension method is internal + var agentTask = a2aResponse.Task!; if (agentTask.Artifacts is not null) { foreach (var artifact in agentTask.Artifacts) diff --git a/dotnet/samples/README.md b/dotnet/samples/README.md index 577b8bccbd..063e5cfc3f 100644 --- a/dotnet/samples/README.md +++ b/dotnet/samples/README.md @@ -16,7 +16,7 @@ were local agents. These are supported using various `AIAgent` subclasses. | [`01-get-started/`](./01-get-started/) | Progressive tutorial: hello agent → hosting | | [`02-agents/`](./02-agents/) | Deep-dive by concept: tools, middleware, providers, orchestrations | | [`03-workflows/`](./03-workflows/) | Workflow patterns: sequential, concurrent, state, declarative | -| [`04-hosting/`](./04-hosting/) | Deployment: Azure Functions, Durable Tasks, A2A | +| [`04-hosting/`](./04-hosting/) | Deployment: Azure Functions, Durable Tasks | | [`05-end-to-end/`](./05-end-to-end/) | Full applications, evaluation, demos | ## Getting Started diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs index 9d98857e9b..9fd2e8ff47 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net.ServerSentEvents; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; @@ -28,7 +27,7 @@ public sealed class A2AAgent : AIAgent { private static readonly AIAgentMetadata s_agentMetadata = new("a2a"); - private readonly A2AClient _a2aClient; + private readonly IA2AClient _a2aClient; private readonly string? _id; private readonly string? _name; private readonly string? _description; @@ -42,7 +41,7 @@ public sealed class A2AAgent : AIAgent /// The the name of the agent. /// The description of the agent. /// Optional logger factory to use for logging. - public A2AAgent(A2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) + public A2AAgent(IA2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) { _ = Throw.IfNull(a2aClient); @@ -100,64 +99,47 @@ protected override async Task RunCoreAsync(IEnumerable 0 } taskMessages) - { - response.Messages = taskMessages; - } + UpdateSession(typedSession, agentTask.ContextId, agentTask.Id); - return response; + return this.ConvertToAgentResponse(agentTask); } - throw new NotSupportedException($"Only Message and AgentTask responses are supported from A2A agents. Received: {a2aResponse.GetType().FullName ?? "null"}"); + throw new NotSupportedException($"Only Message and AgentTask responses are supported from A2A agents. Received: {a2aResponse.PayloadCase}"); } /// @@ -169,59 +151,61 @@ protected override async IAsyncEnumerable RunCoreStreamingA this._logger.LogA2AAgentInvokingAgent(nameof(RunStreamingAsync), this.Id, this.Name); - ConfiguredCancelableAsyncEnumerable> a2aSseEvents; + ConfiguredCancelableAsyncEnumerable streamEvents; - if (options?.ContinuationToken is not null) + if (GetContinuationToken(messages, options) is { } token) { - // Task stream resumption is not well defined in the A2A v2.* specification, leaving it to the agent implementations. - // The v3.0 specification improves this by defining task stream reconnection that allows obtaining the same stream - // from the beginning, but it does not define stream resumption from a specific point in the stream. - // Therefore, the code should be updated once the A2A .NET library supports the A2A v3.0 specification, - // and AF has the necessary model to allow consumers to know whether they need to resume the stream and add new updates to - // the existing ones or reconnect the stream and obtain all updates again. - // For more details, see the following issue: https://github.com/microsoft/agent-framework/issues/1764 - throw new InvalidOperationException("Reconnecting to task streams using continuation tokens is not supported yet."); - // a2aSseEvents = this._a2aClient.SubscribeToTaskAsync(token.TaskId, cancellationToken).ConfigureAwait(false); + streamEvents = this.SubscribeToTaskWithFallbackAsync(token.TaskId, cancellationToken).ConfigureAwait(false); } - - MessageSendParams sendParams = new() + else { - Message = CreateA2AMessage(typedSession, messages), - Metadata = options?.AdditionalProperties?.ToA2AMetadata() - }; + SendMessageRequest sendParams = new() + { + Message = CreateA2AMessage(typedSession, messages), + Metadata = options?.AdditionalProperties?.ToA2AMetadata() + }; - a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(sendParams, cancellationToken).ConfigureAwait(false); + streamEvents = this._a2aClient.SendStreamingMessageAsync(sendParams, cancellationToken).ConfigureAwait(false); + } this._logger.LogAgentChatClientInvokedAgent(nameof(RunStreamingAsync), this.Id, this.Name); string? contextId = null; string? taskId = null; - await foreach (var sseEvent in a2aSseEvents) + await foreach (var streamResponse in streamEvents) { - if (sseEvent.Data is AgentMessage message) + switch (streamResponse.PayloadCase) { - contextId = message.ContextId; - - yield return this.ConvertToAgentResponseUpdate(message); - } - else if (sseEvent.Data is AgentTask task) - { - contextId = task.ContextId; - taskId = task.Id; - - yield return this.ConvertToAgentResponseUpdate(task); - } - else if (sseEvent.Data is TaskUpdateEvent taskUpdateEvent) - { - contextId = taskUpdateEvent.ContextId; - taskId = taskUpdateEvent.TaskId; - - yield return this.ConvertToAgentResponseUpdate(taskUpdateEvent); - } - else - { - throw new NotSupportedException($"Only message, task, task update events are supported from A2A agents. Received: {sseEvent.Data.GetType().FullName ?? "null"}"); + case StreamResponseCase.Message: + var message = streamResponse.Message!; + contextId = message.ContextId; + yield return this.ConvertToAgentResponseUpdate(message); + break; + + case StreamResponseCase.Task: + var task = streamResponse.Task!; + contextId = task.ContextId; + taskId = task.Id; + yield return this.ConvertToAgentResponseUpdate(task); + break; + + case StreamResponseCase.StatusUpdate: + var statusUpdate = streamResponse.StatusUpdate!; + contextId = statusUpdate.ContextId; + taskId = statusUpdate.TaskId; + yield return this.ConvertToAgentResponseUpdate(statusUpdate); + break; + + case StreamResponseCase.ArtifactUpdate: + var artifactUpdate = streamResponse.ArtifactUpdate!; + contextId = artifactUpdate.ContextId; + taskId = artifactUpdate.TaskId; + yield return this.ConvertToAgentResponseUpdate(artifactUpdate); + break; + + default: + throw new NotSupportedException($"Only message, task, task update events are supported from A2A agents. Received: {streamResponse.PayloadCase}"); } } @@ -240,7 +224,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA /// public override object? GetService(Type serviceType, object? serviceKey = null) => base.GetService(serviceType, serviceKey) - ?? (serviceType == typeof(A2AClient) ? this._a2aClient + ?? (serviceType == typeof(IA2AClient) ? this._a2aClient : serviceType == typeof(AIAgentMetadata) ? s_agentMetadata : null); @@ -264,6 +248,75 @@ private async ValueTask GetA2ASessionAsync(AgentSession? sessio return typedSession; } + /// + /// Subscribes to task updates, falling back to + /// when the task has already reached a terminal state and the server responds with + /// . + /// + /// + /// Per A2A spec §3.1.6, subscribing to a task in a terminal state (completed, failed, + /// canceled, or rejected) results in an UnsupportedOperationError. + /// See: . + /// + private async IAsyncEnumerable SubscribeToTaskWithFallbackAsync( + string taskId, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var subscribeStream = this._a2aClient.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = taskId }, cancellationToken); + + var enumerator = subscribeStream.GetAsyncEnumerator(cancellationToken); + + // yield return cannot appear inside a try block that has catch clauses, + // so we manually advance the enumerator within try/catch and yield outside it. + // The outer try/finally (no catch) is allowed to contain yield return in C#. + StreamResponse? fallbackResponse = null; + bool disposed = false; + + try + { + while (true) + { + bool hasNext; + try + { + hasNext = await enumerator.MoveNextAsync().ConfigureAwait(false); + } + catch (A2AException ex) when (ex.ErrorCode == A2AErrorCode.UnsupportedOperation) + { + this._logger.LogA2ASubscribeToTaskFallback(this.Id, this.Name, taskId, ex.Message); + + // Dispose the enumerator before the fallback call to release the HTTP/SSE connection. + await enumerator.DisposeAsync().ConfigureAwait(false); + disposed = true; + + AgentTask agentTask = await this._a2aClient.GetTaskAsync(new GetTaskRequest { Id = taskId }, cancellationToken).ConfigureAwait(false); + + fallbackResponse = new StreamResponse { Task = agentTask }; + break; + } + + if (!hasNext) + { + break; + } + + yield return enumerator.Current; + } + + if (fallbackResponse is not null) + { + yield return fallbackResponse; + } + } + finally + { + if (!disposed) + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } + } + } + private static void UpdateSession(A2AAgentSession? session, string? contextId, string? taskId = null) { if (session is null) @@ -284,7 +337,7 @@ private static void UpdateSession(A2AAgentSession? session, string? contextId, s session.TaskId = taskId; } - private static AgentMessage CreateA2AMessage(A2AAgentSession typedSession, IEnumerable messages) + private static Message CreateA2AMessage(A2AAgentSession typedSession, IEnumerable messages) { var a2aMessage = messages.ToA2AMessage(); @@ -324,7 +377,34 @@ private static AgentMessage CreateA2AMessage(A2AAgentSession typedSession, IEnum return null; } - private AgentResponseUpdate ConvertToAgentResponseUpdate(AgentMessage message) + private AgentResponse ConvertToAgentResponse(Message message) + { + return new AgentResponse + { + AgentId = this.Id, + ResponseId = message.MessageId, + FinishReason = ChatFinishReason.Stop, + RawRepresentation = message, + Messages = [message.ToChatMessage()], + AdditionalProperties = message.Metadata?.ToAdditionalProperties(), + }; + } + + private AgentResponse ConvertToAgentResponse(AgentTask task) + { + return new AgentResponse + { + AgentId = this.Id, + ResponseId = task.Id, + FinishReason = MapTaskStateToFinishReason(task.Status.State), + RawRepresentation = task, + Messages = task.ToChatMessages() ?? [], + ContinuationToken = CreateContinuationToken(task.Id, task.Status.State), + AdditionalProperties = task.Metadata?.ToAdditionalProperties(), + }; + } + + private AgentResponseUpdate ConvertToAgentResponseUpdate(Message message) { return new AgentResponseUpdate { @@ -349,32 +429,35 @@ private AgentResponseUpdate ConvertToAgentResponseUpdate(AgentTask task) RawRepresentation = task, Role = ChatRole.Assistant, Contents = task.ToAIContents(), + ContinuationToken = CreateContinuationToken(task.Id, task.Status.State), AdditionalProperties = task.Metadata?.ToAdditionalProperties(), }; } - private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskUpdateEvent taskUpdateEvent) + private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskStatusUpdateEvent statusUpdateEvent) { - AgentResponseUpdate responseUpdate = new() + return new AgentResponseUpdate { AgentId = this.Id, - ResponseId = taskUpdateEvent.TaskId, - RawRepresentation = taskUpdateEvent, + ResponseId = statusUpdateEvent.TaskId, + RawRepresentation = statusUpdateEvent, Role = ChatRole.Assistant, - AdditionalProperties = taskUpdateEvent.Metadata?.ToAdditionalProperties() ?? [], + FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State), + AdditionalProperties = statusUpdateEvent.Metadata?.ToAdditionalProperties() ?? [], }; + } - if (taskUpdateEvent is TaskArtifactUpdateEvent artifactUpdateEvent) - { - responseUpdate.Contents = artifactUpdateEvent.Artifact.ToAIContents(); - responseUpdate.RawRepresentation = artifactUpdateEvent; - } - else if (taskUpdateEvent is TaskStatusUpdateEvent statusUpdateEvent) + private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskArtifactUpdateEvent artifactUpdateEvent) + { + return new AgentResponseUpdate { - responseUpdate.FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State); - } - - return responseUpdate; + AgentId = this.Id, + ResponseId = artifactUpdateEvent.TaskId, + RawRepresentation = artifactUpdateEvent, + Role = ChatRole.Assistant, + Contents = artifactUpdateEvent.Artifact.ToAIContents(), + AdditionalProperties = artifactUpdateEvent.Metadata?.ToAdditionalProperties() ?? [], + }; } private static ChatFinishReason? MapTaskStateToFinishReason(TaskState state) diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentLogMessages.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentLogMessages.cs index 96d0ba0f9f..7d72013ba3 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentLogMessages.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentLogMessages.cs @@ -34,4 +34,17 @@ public static partial void LogAgentChatClientInvokedAgent( string methodName, string agentId, string? agentName); + + /// + /// Logs falling back to GetTaskAsync after SubscribeToTaskAsync failed with UnsupportedOperation. + /// + [LoggerMessage( + Level = LogLevel.Warning, + Message = "A2AAgent {AgentId}/{AgentName} SubscribeToTask for task '{TaskId}' failed with UnsupportedOperation: {ErrorMessage}. Falling back to GetTaskAsync.")] + public static partial void LogA2ASubscribeToTaskFallback( + this ILogger logger, + string agentId, + string? agentName, + string taskId, + string errorMessage); } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs index 1998d020b5..086505b2bc 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Net.Http; using Microsoft.Agents.AI; using Microsoft.Extensions.Logging; @@ -25,12 +24,15 @@ public static class A2AAgentCardExtensions /// /// The to use for the agent creation. /// The to use for HTTP requests. + /// + /// Optional controlling protocol binding preference. + /// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback. + /// /// The logger factory for enabling logging within the agent. /// An instance backed by the A2A agent. - public static AIAgent AsAIAgent(this AgentCard card, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null) + public static AIAgent AsAIAgent(this AgentCard card, HttpClient? httpClient = null, A2AClientOptions? options = null, ILoggerFactory? loggerFactory = null) { - // Create the A2A client using the agent URL from the card. - var a2aClient = new A2AClient(new Uri(card.Url), httpClient); + var a2aClient = A2AClientFactory.Create(card, httpClient, options); return a2aClient.AsAIAgent(name: card.Name, description: card.Description, loggerFactory: loggerFactory); } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs index 6a32822fea..49b6de1102 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs @@ -34,14 +34,18 @@ public static class A2ACardResolverExtensions /// /// The to use for the agent creation. /// The to use for HTTP requests. + /// + /// Optional controlling protocol binding preference. + /// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback. + /// /// The logger factory for enabling logging within the agent. /// The to monitor for cancellation requests. The default is . /// An instance backed by the A2A agent. - public static async Task GetAIAgentAsync(this A2ACardResolver resolver, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default) + public static async Task GetAIAgentAsync(this A2ACardResolver resolver, HttpClient? httpClient = null, A2AClientOptions? options = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default) { // Obtain the agent card from the resolver. var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false); - return agentCard.AsAIAgent(httpClient, loggerFactory); + return agentCard.AsAIAgent(httpClient, options, loggerFactory); } } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs index cd93ca0bac..c7386309d3 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs @@ -7,7 +7,7 @@ namespace A2A; /// -/// Provides extension methods for +/// Provides extension methods for /// to simplify the creation of A2A agents. /// /// @@ -29,12 +29,12 @@ public static class A2AClientExtensions /// Direct Configuration / Private Discovery /// discovery mechanism. /// - /// The to use for the agent. + /// The to use for the agent. /// The unique identifier for the agent. /// The the name of the agent. /// The description of the agent. /// Optional logger factory for enabling logging within the agent. /// An instance backed by the A2A agent. - public static AIAgent AsAIAgent(this A2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) => + public static AIAgent AsAIAgent(this IA2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) => new A2AAgent(client, id, name, description, loggerFactory); } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/ChatMessageExtensions.cs index b1f1bd643a..dd0749ecc9 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/ChatMessageExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/ChatMessageExtensions.cs @@ -11,7 +11,7 @@ namespace Microsoft.Extensions.AI; /// internal static class ChatMessageExtensions { - internal static AgentMessage ToA2AMessage(this IEnumerable messages) + internal static Message ToA2AMessage(this IEnumerable messages) { List allParts = []; @@ -23,10 +23,10 @@ internal static AgentMessage ToA2AMessage(this IEnumerable messages } } - return new AgentMessage + return new Message { MessageId = Guid.NewGuid().ToString("N"), - Role = MessageRole.User, + Role = Role.User, Parts = allParts, }; } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj b/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj index b1b9ba7671..4e92826f56 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj @@ -1,6 +1,7 @@ + $(TargetFrameworksCore) preview $(NoWarn);MEAI001 diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000000..a57ed07890 --- /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.ThrowIfNullOrEmpty(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.ThrowIfNullOrEmpty(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..fd4a6945f1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using A2A; +using Microsoft.Agents.AI.Hosting.A2A.Converters; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Hosting.A2A; + +/// +/// An implementation that bridges an to the +/// A2A (Agent2Agent) protocol. Handles message execution and cancellation by delegating to +/// the underlying agent and translating responses into A2A events. +/// +[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] +internal sealed class A2AAgentHandler : IAgentHandler +{ + private readonly AIHostAgent _hostAgent; + private readonly AgentRunMode _runMode; + + /// + /// Initializes a new instance of the class. + /// + /// The hosted agent that provides the execution logic. + /// Controls whether the agent runs in background mode. + public A2AAgentHandler( + AIHostAgent hostAgent, + AgentRunMode runMode) + { + this._hostAgent = hostAgent; + this._runMode = runMode; + } + + /// + public Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + if (context.IsContinuation) + { + return this.HandleTaskUpdateAsync(context, eventQueue, cancellationToken); + } + + return this.HandleNewMessageAsync(context, eventQueue, cancellationToken); + } + + /// + public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId); + await taskUpdater.CancelAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + var contextId = context.ContextId ?? Guid.NewGuid().ToString("N"); + var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); + + // AIAgent does not support resuming from arbitrary prior tasks. + // Throw explicitly so the client gets a clear error rather than a response + // that silently ignores the referenced task context. + if (context.Message?.ReferenceTaskIds is { Count: > 0 }) + { + throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context."); + } + + List chatMessages = context.Message is not null ? [context.Message.ToChatMessage()] : []; + + // Decide whether to run in background based on user preferences and agent capabilities + var decisionContext = new A2ARunDecisionContext(context); + var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false); + + var options = context.Metadata is not { Count: > 0 } + ? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses } + : new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() }; + + var response = await this._hostAgent.RunAsync( + chatMessages, + session: session, + options: options, + cancellationToken: cancellationToken).ConfigureAwait(false); + + await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); + + if (response.ContinuationToken is null) + { + // Return a lightweight message response (no task lifecycle needed). + var message = CreateMessageFromResponse(contextId, response); + await eventQueue.EnqueueMessageAsync(message, cancellationToken).ConfigureAwait(false); + } + else + { + // Long-running operation: emit task lifecycle events. + var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId); + await taskUpdater.SubmitAsync(cancellationToken).ConfigureAwait(false); + + Message? progressMessage = response.Messages.Count > 0 + ? CreateMessageFromResponse(contextId, response) + : null; + + await taskUpdater.StartWorkAsync(progressMessage, cancellationToken).ConfigureAwait(false); + } + } + + private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + var contextId = context.ContextId ?? Guid.NewGuid().ToString("N"); + var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); + + List chatMessages = ExtractChatMessagesFromTaskHistory(context.Task); + + var decisionContext = new A2ARunDecisionContext(context); + var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false); + + var options = context.Metadata is not { Count: > 0 } + ? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses } + : new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() }; + + AgentResponse response; + try + { + response = await this._hostAgent.RunAsync( + chatMessages, + session: session, + options: options, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + var failUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId); + await failUpdater.FailAsync(message: null, cancellationToken).ConfigureAwait(false); + throw; + } + + await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); + + if (response.ContinuationToken is null) + { + // Complete the task with an artifact containing the response. + var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId); + await taskUpdater.AddArtifactAsync(response.Messages.ToParts(), cancellationToken: cancellationToken).ConfigureAwait(false); + await taskUpdater.CompleteAsync(message: null, cancellationToken).ConfigureAwait(false); + } + else + { + // Still working: emit progress status. + var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId); + + Message? progressMessage = response.Messages.Count > 0 + ? CreateMessageFromResponse(contextId, response) + : null; + + await taskUpdater.StartWorkAsync(progressMessage, cancellationToken).ConfigureAwait(false); + } + } + + private static Message CreateMessageFromResponse(string contextId, AgentResponse response) => + new() + { + MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"), + ContextId = contextId, + Role = Role.Agent, + Parts = response.Messages.ToParts(), + Metadata = response.AdditionalProperties?.ToA2AMetadata() + }; + + private static List ExtractChatMessagesFromTaskHistory(AgentTask? agentTask) + { + if (agentTask?.History is not { Count: > 0 }) + { + return []; + } + + var chatMessages = new List(agentTask.History.Count); + foreach (var message in agentTask.History) + { + chatMessages.Add(message.ToChatMessage()); + } + + return chatMessages; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/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..9dbb95b989 --- /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.ThrowIfNullOrEmpty(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..094a5156c0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs @@ -28,7 +28,7 @@ private AgentRunMode(string value, Func - /// Dissallows the background responses from the agent. Is equivalent to configuring as false. + /// Disallows the background responses from the agent. Is equivalent to configuring as false. /// In the A2A protocol terminology will make responses be returned as AgentMessage. /// public static AgentRunMode DisallowBackground => new(MessageValue); @@ -79,7 +79,7 @@ internal ValueTask ShouldRunInBackgroundAsync(A2ARunDecisionContext contex } // No delegate provided — fall back to "message" behavior. - return ValueTask.FromResult(true); + return ValueTask.FromResult(false); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs index 5d2381a235..b2f57fc09e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs @@ -31,21 +31,21 @@ public static List ToParts(this IList chatMessages) return parts; } /// - /// Converts A2A MessageSendParams to a collection of Microsoft.Extensions.AI ChatMessage objects. + /// Converts A2A SendMessageRequest to a collection of Microsoft.Extensions.AI ChatMessage objects. /// - /// The A2A message send parameters to convert. + /// The A2A send message request to convert. /// A read-only collection of ChatMessage objects. - public static List ToChatMessages(this MessageSendParams messageSendParams) + public static List ToChatMessages(this SendMessageRequest sendMessageRequest) { - if (messageSendParams is null) + if (sendMessageRequest is null) { return []; } var result = new List(); - if (messageSendParams.Message?.Parts is not null) + if (sendMessageRequest.Message?.Parts is not null) { - result.Add(messageSendParams.Message.ToChatMessage()); + result.Add(sendMessageRequest.Message.ToChatMessage()); } return result; diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs index 514922dd26..320b5b030c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs @@ -6,9 +6,7 @@ using System.Linq; using System.Net; using System.Net.Http; -using System.Net.ServerSentEvents; using System.Text; -using System.Text.Encodings.Web; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -57,6 +55,21 @@ public void Constructor_WithNullA2AClient_ThrowsArgumentNullException() => // Act & Assert Assert.Throws(() => new A2AAgent(null!)); + [Fact] + public void Constructor_WithIA2AClient_InitializesCorrectly() + { + // Arrange + IA2AClient ia2aClient = this._a2aClient; + + // Act + var agent = new A2AAgent(ia2aClient, "ia2a-id", "IA2A Agent", "An agent from IA2AClient"); + + // Assert + Assert.Equal("ia2a-id", agent.Id); + Assert.Equal("IA2A Agent", agent.Name); + Assert.Equal("An agent from IA2AClient", agent.Description); + } + [Fact] public void Constructor_WithDefaultParameters_UsesBaseProperties() { @@ -89,14 +102,17 @@ public async Task RunAsync_AllowsNonUserRoleMessagesAsync() public async Task RunAsync_WithValidUserMessage_RunsSuccessfullyAsync() { // Arrange - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = - [ - new TextPart { Text = "Hello! How can I help you today?" } - ] + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = + [ + new Part { Text = "Hello! How can I help you today?" } + ] + } }; var inputMessages = new List @@ -108,11 +124,11 @@ public async Task RunAsync_WithValidUserMessage_RunsSuccessfullyAsync() var result = await this._agent.RunAsync(inputMessages); // Assert input message sent to A2AClient - var inputMessage = this._handler.CapturedMessageSendParams?.Message; + var inputMessage = this._handler.CapturedSendMessageRequest?.Message; Assert.NotNull(inputMessage); Assert.Single(inputMessage.Parts); - Assert.Equal(MessageRole.User, inputMessage.Role); - Assert.Equal("Hello, world!", ((TextPart)inputMessage.Parts[0]).Text); + Assert.Equal(Role.User, inputMessage.Role); + Assert.Equal("Hello, world!", inputMessage.Parts[0].Text); // Assert response from A2AClient is converted correctly Assert.NotNull(result); @@ -120,8 +136,8 @@ public async Task RunAsync_WithValidUserMessage_RunsSuccessfullyAsync() Assert.Equal("response-123", result.ResponseId); Assert.NotNull(result.RawRepresentation); - Assert.IsType(result.RawRepresentation); - Assert.Equal("response-123", ((AgentMessage)result.RawRepresentation).MessageId); + Assert.IsType(result.RawRepresentation); + Assert.Equal("response-123", ((Message)result.RawRepresentation).MessageId); Assert.Single(result.Messages); Assert.Equal(ChatRole.Assistant, result.Messages[0].Role); @@ -133,15 +149,18 @@ public async Task RunAsync_WithValidUserMessage_RunsSuccessfullyAsync() public async Task RunAsync_WithNewSession_UpdatesSessionConversationIdAsync() { // Arrange - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = - [ - new TextPart { Text = "Response" } - ], - ContextId = "new-context-id" + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = + [ + new Part { Text = "Response" } + ], + ContextId = "new-context-id" + } }; var inputMessages = new List @@ -177,7 +196,7 @@ public async Task RunAsync_WithExistingSession_SetConversationIdToMessageAsync() await this._agent.RunAsync(inputMessages, session); // Assert - var message = this._handler.CapturedMessageSendParams?.Message; + var message = this._handler.CapturedSendMessageRequest?.Message; Assert.NotNull(message); Assert.Equal("existing-context-id", message.ContextId); } @@ -191,15 +210,18 @@ public async Task RunAsync_WithSessionHavingDifferentContextId_ThrowsInvalidOper new(ChatRole.User, "Test message") }; - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = - [ - new TextPart { Text = "Response" } - ], - ContextId = "different-context" + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = + [ + new Part { Text = "Response" } + ], + ContextId = "different-context" + } }; var session = await this._agent.CreateSessionAsync(); @@ -219,12 +241,15 @@ public async Task RunStreamingAsync_WithValidUserMessage_YieldsAgentResponseUpda new(ChatRole.User, "Hello, streaming!") }; - this._handler.StreamingResponseToReturn = new AgentMessage() + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = "stream-1", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Hello" }], - ContextId = "stream-context" + Message = new Message + { + MessageId = "stream-1", + Role = Role.Agent, + Parts = [new Part { Text = "Hello" }], + ContextId = "stream-context" + } }; // Act @@ -238,11 +263,11 @@ public async Task RunStreamingAsync_WithValidUserMessage_YieldsAgentResponseUpda Assert.Single(updates); // Assert input message sent to A2AClient - var inputMessage = this._handler.CapturedMessageSendParams?.Message; + var inputMessage = this._handler.CapturedSendMessageRequest?.Message; Assert.NotNull(inputMessage); Assert.Single(inputMessage.Parts); - Assert.Equal(MessageRole.User, inputMessage.Role); - Assert.Equal("Hello, streaming!", ((TextPart)inputMessage.Parts[0]).Text); + Assert.Equal(Role.User, inputMessage.Role); + Assert.Equal("Hello, streaming!", inputMessage.Parts[0].Text); // Assert response from A2AClient is converted correctly Assert.Equal(ChatRole.Assistant, updates[0].Role); @@ -251,8 +276,8 @@ public async Task RunStreamingAsync_WithValidUserMessage_YieldsAgentResponseUpda Assert.Equal(this._agent.Id, updates[0].AgentId); Assert.Equal("stream-1", updates[0].ResponseId); Assert.Equal(ChatFinishReason.Stop, updates[0].FinishReason); - Assert.IsType(updates[0].RawRepresentation); - Assert.Equal("stream-1", ((AgentMessage)updates[0].RawRepresentation!).MessageId); + Assert.IsType(updates[0].RawRepresentation); + Assert.Equal("stream-1", ((Message)updates[0].RawRepresentation!).MessageId); } [Fact] @@ -264,12 +289,15 @@ public async Task RunStreamingAsync_WithSession_UpdatesSessionConversationIdAsyn new(ChatRole.User, "Test streaming") }; - this._handler.StreamingResponseToReturn = new AgentMessage() + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = "stream-1", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }], - ContextId = "new-stream-context" + Message = new Message + { + MessageId = "stream-1", + Role = Role.Agent, + Parts = [new Part { Text = "Response" }], + ContextId = "new-stream-context" + } }; var session = await this._agent.CreateSessionAsync(); @@ -294,7 +322,7 @@ public async Task RunStreamingAsync_WithExistingSession_SetConversationIdToMessa new(ChatRole.User, "Test streaming") }; - this._handler.StreamingResponseToReturn = new AgentMessage(); + this._handler.StreamingResponseToReturn = new StreamResponse { Message = new Message() }; var session = await this._agent.CreateSessionAsync(); var a2aSession = (A2AAgentSession)session; @@ -307,7 +335,7 @@ public async Task RunStreamingAsync_WithExistingSession_SetConversationIdToMessa } // Assert - var message = this._handler.CapturedMessageSendParams?.Message; + var message = this._handler.CapturedSendMessageRequest?.Message; Assert.NotNull(message); Assert.Equal("existing-context-id", message.ContextId); } @@ -325,12 +353,15 @@ public async Task RunStreamingAsync_WithSessionHavingDifferentContextId_ThrowsIn new(ChatRole.User, "Test streaming") }; - this._handler.StreamingResponseToReturn = new AgentMessage() + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = "stream-1", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }], - ContextId = "different-context" + Message = new Message + { + MessageId = "stream-1", + Role = Role.Agent, + Parts = [new Part { Text = "Response" }], + ContextId = "different-context" + } }; // Act @@ -346,12 +377,15 @@ await Assert.ThrowsAsync(async () => public async Task RunStreamingAsync_AllowsNonUserRoleMessagesAsync() { // Arrange - this._handler.StreamingResponseToReturn = new AgentMessage() + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = "stream-1", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }], - ContextId = "new-stream-context" + Message = new Message + { + MessageId = "stream-1", + Role = Role.Agent, + Parts = [new Part { Text = "Response" }], + ContextId = "new-stream-context" + } }; var inputMessages = new List @@ -385,13 +419,13 @@ public async Task RunAsync_WithHostedFileContent_ConvertsToFilePartAsync() await this._agent.RunAsync(inputMessages); // Assert - var message = this._handler.CapturedMessageSendParams?.Message; + var message = this._handler.CapturedSendMessageRequest?.Message; Assert.NotNull(message); Assert.Equal(2, message.Parts.Count); - Assert.IsType(message.Parts[0]); - Assert.Equal("Check this file:", ((TextPart)message.Parts[0]).Text); - Assert.IsType(message.Parts[1]); - Assert.Equal("https://example.com/file.pdf", ((FilePart)message.Parts[1]).File.Uri?.ToString()); + Assert.Equal(PartContentCase.Text, message.Parts[0].ContentCase); + Assert.Equal("Check this file:", message.Parts[0].Text); + Assert.Equal(PartContentCase.Url, message.Parts[1].ContentCase); + Assert.Equal("https://example.com/file.pdf", message.Parts[1].Url); } [Fact] @@ -413,10 +447,11 @@ public async Task RunAsync_WithContinuationTokenAndMessages_ThrowsInvalidOperati public async Task RunAsync_WithContinuationToken_CallsGetTaskAsyncAsync() { // Arrange - this._handler.ResponseToReturn = new AgentTask + this._handler.AgentTaskToReturn = new AgentTask { Id = "task-123", - ContextId = "context-123" + ContextId = "context-123", + Status = new() { State = TaskState.Submitted } }; var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken("task-123") }; @@ -425,19 +460,22 @@ public async Task RunAsync_WithContinuationToken_CallsGetTaskAsyncAsync() await this._agent.RunAsync([], options: options); // Assert - Assert.Equal("tasks/get", this._handler.CapturedJsonRpcRequest?.Method); - Assert.Equal("task-123", this._handler.CapturedTaskIdParams?.Id); + Assert.Equal("GetTask", this._handler.CapturedJsonRpcRequest?.Method); + Assert.Equal("task-123", this._handler.CapturedGetTaskRequest?.Id); } [Fact] public async Task RunAsync_WithTaskInSessionAndMessage_AddTaskAsReferencesToMessageAsync() { // Arrange - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response to task" }] + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Response to task" }] + } }; var session = (A2AAgentSession)await this._agent.CreateSessionAsync(); @@ -449,7 +487,7 @@ public async Task RunAsync_WithTaskInSessionAndMessage_AddTaskAsReferencesToMess await this._agent.RunAsync(inputMessage, session); // Assert - var message = this._handler.CapturedMessageSendParams?.Message; + var message = this._handler.CapturedSendMessageRequest?.Message; Assert.Null(message?.TaskId); Assert.NotNull(message?.ReferenceTaskIds); Assert.Contains("task-123", message.ReferenceTaskIds); @@ -459,11 +497,14 @@ public async Task RunAsync_WithTaskInSessionAndMessage_AddTaskAsReferencesToMess public async Task RunAsync_WithAgentTask_UpdatesSessionTaskIdAsync() { // Arrange - this._handler.ResponseToReturn = new AgentTask + this._handler.ResponseToReturn = new SendMessageResponse { - Id = "task-456", - ContextId = "context-789", - Status = new() { State = TaskState.Submitted } + Task = new AgentTask + { + Id = "task-456", + ContextId = "context-789", + Status = new() { State = TaskState.Submitted } + } }; var session = await this._agent.CreateSessionAsync(); @@ -480,16 +521,19 @@ public async Task RunAsync_WithAgentTask_UpdatesSessionTaskIdAsync() public async Task RunAsync_WithAgentTaskResponse_ReturnsTaskResponseCorrectlyAsync() { // Arrange - this._handler.ResponseToReturn = new AgentTask + this._handler.ResponseToReturn = new SendMessageResponse { - Id = "task-789", - ContextId = "context-456", - Status = new() { State = TaskState.Submitted }, - Metadata = new Dictionary + Task = new AgentTask + { + Id = "task-789", + ContextId = "context-456", + Status = new() { State = TaskState.Submitted }, + Metadata = new Dictionary { { "key1", JsonSerializer.SerializeToElement("value1") }, { "count", JsonSerializer.SerializeToElement(42) } } + } }; var session = await this._agent.CreateSessionAsync(); @@ -532,11 +576,14 @@ public async Task RunAsync_WithAgentTaskResponse_ReturnsTaskResponseCorrectlyAsy public async Task RunAsync_WithVariousTaskStates_ReturnsCorrectTokenAsync(TaskState taskState) { // Arrange - this._handler.ResponseToReturn = new AgentTask + this._handler.ResponseToReturn = new SendMessageResponse { - Id = "task-123", - ContextId = "context-123", - Status = new() { State = taskState } + Task = new AgentTask + { + Id = "task-123", + ContextId = "context-123", + Status = new() { State = taskState } + } }; // Act @@ -583,15 +630,175 @@ await Assert.ThrowsAsync(async () => }); } + [Fact] + public async Task RunStreamingAsync_WithContinuationToken_UsesSubscribeToTaskMethodAsync() + { + // Arrange + this._handler.StreamingResponseToReturn = new StreamResponse + { + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Continuation response" }] + } + }; + + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken("task-456") }; + + // Act + await foreach (var _ in this._agent.RunStreamingAsync([], null, options)) + { + // Just iterate through to trigger the logic + } + + // Assert - verify SubscribeToTask was called (not SendStreamingMessage) + Assert.Single(this._handler.CapturedJsonRpcRequests); + Assert.Equal("SubscribeToTask", this._handler.CapturedJsonRpcRequests[0].Method); + } + + [Fact] + public async Task RunStreamingAsync_WithContinuationToken_PassesCorrectTaskIdAsync() + { + // Arrange + this._handler.StreamingResponseToReturn = new StreamResponse + { + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Continuation response" }] + } + }; + + const string ExpectedTaskId = "my-task-789"; + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken(ExpectedTaskId) }; + + // Act + await foreach (var _ in this._agent.RunStreamingAsync([], null, options)) + { + // Just iterate through to trigger the logic + } + + // Assert - verify the task ID was passed correctly + Assert.NotEmpty(this._handler.CapturedJsonRpcRequests); + var subscribeRequest = this._handler.CapturedJsonRpcRequests[0]; + var subscribeParams = subscribeRequest.Params?.Deserialize(A2AJsonUtilities.DefaultOptions); + Assert.NotNull(subscribeParams); + Assert.Equal(ExpectedTaskId, subscribeParams.Id); + } + + [Fact] + public async Task RunStreamingAsync_WithContinuationToken_WhenSubscribeFailsWithUnsupportedOperation_FallsBackToGetTaskAsync() + { + // Arrange + const string TaskId = "completed-task-123"; + const string ContextId = "ctx-completed"; + + this._handler.StreamingErrorCodeToReturn = A2AErrorCode.UnsupportedOperation; + this._handler.AgentTaskToReturn = new AgentTask + { + Id = TaskId, + ContextId = ContextId, + Status = new() { State = TaskState.Completed }, + Artifacts = + [ + new() { ArtifactId = "art-1", Parts = [new Part { Text = "Final result" }] } + ] + }; + + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken(TaskId) }; + + // Act + var updates = new List(); + await foreach (var update in this._agent.RunStreamingAsync([], null, options)) + { + updates.Add(update); + } + + // Assert - should yield one update from GetTaskAsync fallback + Assert.Single(updates); + var update0 = updates[0]; + Assert.Equal(TaskId, update0.ResponseId); + Assert.Equal(ChatFinishReason.Stop, update0.FinishReason); + Assert.IsType(update0.RawRepresentation); + Assert.Equal(TaskId, ((AgentTask)update0.RawRepresentation!).Id); + + // Assert - both SubscribeToTask and GetTask were called + Assert.Equal(2, this._handler.CapturedJsonRpcRequests.Count); + Assert.Equal("SubscribeToTask", this._handler.CapturedJsonRpcRequests[0].Method); + Assert.Equal("GetTask", this._handler.CapturedJsonRpcRequests[1].Method); + } + + [Fact] + public async Task RunStreamingAsync_WithContinuationToken_WhenSubscribeFailsWithUnsupportedOperation_UpdatesSessionAsync() + { + // Arrange + const string TaskId = "completed-task-456"; + const string ContextId = "ctx-completed-456"; + + this._handler.StreamingErrorCodeToReturn = A2AErrorCode.UnsupportedOperation; + this._handler.AgentTaskToReturn = new AgentTask + { + Id = TaskId, + ContextId = ContextId, + Status = new() { State = TaskState.Completed } + }; + + var session = await this._agent.CreateSessionAsync(); + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken(TaskId) }; + + // Act + await foreach (var _ in this._agent.RunStreamingAsync([], session, options)) + { + // Just iterate through to trigger the logic + } + + // Assert - session should be updated with the task state from GetTaskAsync + var a2aSession = (A2AAgentSession)session; + Assert.Equal(ContextId, a2aSession.ContextId); + Assert.Equal(TaskId, a2aSession.TaskId); + } + + [Fact] + public async Task RunStreamingAsync_WithContinuationToken_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 +811,7 @@ public async Task RunStreamingAsync_WithTaskInSessionAndMessage_AddTaskAsReferen } // Assert - var message = this._handler.CapturedMessageSendParams?.Message; + var message = this._handler.CapturedSendMessageRequest?.Message; Assert.Null(message?.TaskId); Assert.NotNull(message?.ReferenceTaskIds); Assert.Contains("task-123", message.ReferenceTaskIds); @@ -614,11 +821,14 @@ public async Task RunStreamingAsync_WithTaskInSessionAndMessage_AddTaskAsReferen public async Task RunStreamingAsync_WithAgentTask_UpdatesSessionTaskIdAsync() { // Arrange - this._handler.StreamingResponseToReturn = new AgentTask + this._handler.StreamingResponseToReturn = new StreamResponse { - Id = "task-456", - ContextId = "context-789", - Status = new() { State = TaskState.Submitted } + Task = new AgentTask + { + Id = "task-456", + ContextId = "context-789", + Status = new() { State = TaskState.Submitted } + } }; var session = await this._agent.CreateSessionAsync(); @@ -642,15 +852,18 @@ public async Task RunStreamingAsync_WithAgentMessage_YieldsResponseUpdateAsync() const string ContextId = "ctx-456"; const string MessageText = "Hello from agent!"; - this._handler.StreamingResponseToReturn = new AgentMessage + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = MessageId, - Role = MessageRole.Agent, - ContextId = ContextId, - Parts = - [ - new TextPart { Text = MessageText } - ] + Message = new Message + { + MessageId = MessageId, + Role = Role.Agent, + ContextId = ContextId, + Parts = + [ + new Part { Text = MessageText } + ] + } }; // Act @@ -670,8 +883,8 @@ public async Task RunStreamingAsync_WithAgentMessage_YieldsResponseUpdateAsync() Assert.Equal(this._agent.Id, update0.AgentId); Assert.Equal(MessageText, update0.Text); Assert.Equal(ChatFinishReason.Stop, update0.FinishReason); - Assert.IsType(update0.RawRepresentation); - Assert.Equal(MessageId, ((AgentMessage)update0.RawRepresentation!).MessageId); + Assert.IsType(update0.RawRepresentation); + Assert.Equal(MessageId, ((Message)update0.RawRepresentation!).MessageId); } [Fact] @@ -681,18 +894,21 @@ public async Task RunStreamingAsync_WithAgentTask_YieldsResponseUpdateAsync() const string TaskId = "task-789"; const string ContextId = "ctx-012"; - this._handler.StreamingResponseToReturn = new AgentTask + this._handler.StreamingResponseToReturn = new StreamResponse { - Id = TaskId, - ContextId = ContextId, - Status = new() { State = TaskState.Submitted }, - Artifacts = [ + Task = new AgentTask + { + Id = TaskId, + ContextId = ContextId, + Status = new() { State = TaskState.Submitted }, + Artifacts = [ new() { ArtifactId = "art-123", - Parts = [new TextPart { Text = "Task artifact content" }] + Parts = [new Part { Text = "Task artifact content" }] } ] + } }; var session = await this._agent.CreateSessionAsync(); @@ -728,11 +944,14 @@ public async Task RunStreamingAsync_WithTaskStatusUpdateEvent_YieldsResponseUpda const string TaskId = "task-status-123"; const string ContextId = "ctx-status-456"; - this._handler.StreamingResponseToReturn = new TaskStatusUpdateEvent + this._handler.StreamingResponseToReturn = new StreamResponse { - TaskId = TaskId, - ContextId = ContextId, - Status = new() { State = TaskState.Working } + StatusUpdate = new TaskStatusUpdateEvent + { + TaskId = TaskId, + ContextId = ContextId, + Status = new() { State = TaskState.Working } + } }; var session = await this._agent.CreateSessionAsync(); @@ -768,14 +987,17 @@ public async Task RunStreamingAsync_WithTaskArtifactUpdateEvent_YieldsResponseUp const string ContextId = "ctx-artifact-456"; const string ArtifactContent = "Task artifact data"; - this._handler.StreamingResponseToReturn = new TaskArtifactUpdateEvent + this._handler.StreamingResponseToReturn = new StreamResponse { - TaskId = TaskId, - ContextId = ContextId, - Artifact = new() + ArtifactUpdate = new TaskArtifactUpdateEvent { - ArtifactId = "artifact-789", - Parts = [new TextPart { Text = ArtifactContent }] + TaskId = TaskId, + ContextId = ContextId, + Artifact = new() + { + ArtifactId = "artifact-789", + Parts = [new Part { Text = ArtifactContent }] + } } }; @@ -848,15 +1070,18 @@ await Assert.ThrowsAsync(async () => public async Task RunAsync_WithAgentMessageResponseMetadata_ReturnsMetadataAsAdditionalPropertiesAsync() { // Arrange - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response with metadata" }], - Metadata = new Dictionary + Message = new Message { - { "responseKey1", JsonSerializer.SerializeToElement("responseValue1") }, - { "responseCount", JsonSerializer.SerializeToElement(99) } + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Response with metadata" }], + Metadata = new Dictionary + { + { "responseKey1", JsonSerializer.SerializeToElement("responseValue1") }, + { "responseCount", JsonSerializer.SerializeToElement(99) } + } } }; @@ -877,14 +1102,17 @@ public async Task RunAsync_WithAgentMessageResponseMetadata_ReturnsMetadataAsAdd } [Fact] - public async Task RunAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMessageSendParamsAsync() + public async Task RunAsync_WithAdditionalProperties_PropagatesThemAsMetadataToSendMessageRequestAsync() { // Arrange - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }] + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Response" }] + } }; var inputMessages = new List @@ -906,22 +1134,25 @@ public async Task RunAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMe await this._agent.RunAsync(inputMessages, null, options); // Assert - Assert.NotNull(this._handler.CapturedMessageSendParams); - Assert.NotNull(this._handler.CapturedMessageSendParams.Metadata); - Assert.Equal("value1", this._handler.CapturedMessageSendParams.Metadata["key1"].GetString()); - Assert.Equal(42, this._handler.CapturedMessageSendParams.Metadata["key2"].GetInt32()); - Assert.True(this._handler.CapturedMessageSendParams.Metadata["key3"].GetBoolean()); + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.NotNull(this._handler.CapturedSendMessageRequest.Metadata); + Assert.Equal("value1", this._handler.CapturedSendMessageRequest.Metadata["key1"].GetString()); + Assert.Equal(42, this._handler.CapturedSendMessageRequest.Metadata["key2"].GetInt32()); + Assert.True(this._handler.CapturedSendMessageRequest.Metadata["key3"].GetBoolean()); } [Fact] public async Task RunAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync() { // Arrange - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }] + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Response" }] + } }; var inputMessages = new List @@ -938,19 +1169,22 @@ public async Task RunAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync( await this._agent.RunAsync(inputMessages, null, options); // Assert - Assert.NotNull(this._handler.CapturedMessageSendParams); - Assert.Null(this._handler.CapturedMessageSendParams.Metadata); + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.Null(this._handler.CapturedSendMessageRequest.Metadata); } [Fact] - public async Task RunStreamingAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMessageSendParamsAsync() + public async Task RunStreamingAsync_WithAdditionalProperties_PropagatesThemAsMetadataToSendMessageRequestAsync() { // Arrange - this._handler.StreamingResponseToReturn = new AgentMessage + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = "stream-123", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Streaming response" }] + Message = new Message + { + MessageId = "stream-123", + Role = Role.Agent, + Parts = [new Part { Text = "Streaming response" }] + } }; var inputMessages = new List @@ -974,22 +1208,25 @@ public async Task RunStreamingAsync_WithAdditionalProperties_PropagatesThemAsMet } // Assert - Assert.NotNull(this._handler.CapturedMessageSendParams); - Assert.NotNull(this._handler.CapturedMessageSendParams.Metadata); - Assert.Equal("streamValue1", this._handler.CapturedMessageSendParams.Metadata["streamKey1"].GetString()); - Assert.Equal(100, this._handler.CapturedMessageSendParams.Metadata["streamKey2"].GetInt32()); - Assert.False(this._handler.CapturedMessageSendParams.Metadata["streamKey3"].GetBoolean()); + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.NotNull(this._handler.CapturedSendMessageRequest.Metadata); + Assert.Equal("streamValue1", this._handler.CapturedSendMessageRequest.Metadata["streamKey1"].GetString()); + Assert.Equal(100, this._handler.CapturedSendMessageRequest.Metadata["streamKey2"].GetInt32()); + Assert.False(this._handler.CapturedSendMessageRequest.Metadata["streamKey3"].GetBoolean()); } [Fact] public async Task RunStreamingAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync() { // Arrange - this._handler.StreamingResponseToReturn = new AgentMessage + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = "stream-123", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Streaming response" }] + Message = new Message + { + MessageId = "stream-123", + Role = Role.Agent, + Parts = [new Part { Text = "Streaming response" }] + } }; var inputMessages = new List @@ -1008,8 +1245,115 @@ public async Task RunStreamingAsync_WithNullAdditionalProperties_DoesNotSetMetad } // Assert - Assert.NotNull(this._handler.CapturedMessageSendParams); - Assert.Null(this._handler.CapturedMessageSendParams.Metadata); + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.Null(this._handler.CapturedSendMessageRequest.Metadata); + } + + [Fact] + public async Task RunAsync_WithDefaultOptions_SetsBlockingToTrueAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + // Act + await this._agent.RunAsync(inputMessages); + + // Assert + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.NotNull(this._handler.CapturedSendMessageRequest.Configuration); + Assert.False(this._handler.CapturedSendMessageRequest.Configuration.ReturnImmediately); + } + + [Fact] + public async Task RunAsync_WithAllowBackgroundResponsesTrue_SetsReturnImmediatelyToTrueAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + var session = await this._agent.CreateSessionAsync(); + var options = new AgentRunOptions { AllowBackgroundResponses = true }; + + // Act + await this._agent.RunAsync(inputMessages, session, options); + + // Assert + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.NotNull(this._handler.CapturedSendMessageRequest.Configuration); + Assert.True(this._handler.CapturedSendMessageRequest.Configuration.ReturnImmediately); + } + + [Fact] + public async Task RunAsync_WithAllowBackgroundResponsesFalse_SetsReturnImmediatelyToFalseAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + var options = new AgentRunOptions { AllowBackgroundResponses = false }; + + // Act + await this._agent.RunAsync(inputMessages, null, options); + + // Assert + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.NotNull(this._handler.CapturedSendMessageRequest.Configuration); + Assert.False(this._handler.CapturedSendMessageRequest.Configuration.ReturnImmediately); + } + + [Fact] + public async Task RunAsync_WithNullOptions_SetsReturnImmediatelyToFalseAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + // Act + await this._agent.RunAsync(inputMessages, null, null); + + // Assert + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.NotNull(this._handler.CapturedSendMessageRequest.Configuration); + Assert.False(this._handler.CapturedSendMessageRequest.Configuration.ReturnImmediately); + } + + [Fact] + public async Task RunStreamingAsync_SendMessageRequest_DoesNotSetReturnImmediatelyConfigurationAsync() + { + // Arrange + this._handler.StreamingResponseToReturn = new StreamResponse + { + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Streaming response" }] + } + }; + + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + // Act + await foreach (var _ in this._agent.RunStreamingAsync(inputMessages)) + { + // Just iterate through to trigger the logic + } + + // Assert + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.Null(this._handler.CapturedSendMessageRequest.Configuration); } [Fact] @@ -1042,19 +1386,33 @@ public async Task RunStreamingAsync_WithInvalidSessionType_ThrowsInvalidOperatio #region GetService Method Tests /// - /// Verify that GetService returns A2AClient when requested. + /// Verify that GetService returns IA2AClient when requested. /// [Fact] - public void GetService_RequestingA2AClient_ReturnsA2AClient() + public void GetService_RequestingIA2AClient_ReturnsA2AClient() { // Arrange & Act - var result = this._agent.GetService(typeof(A2AClient)); + var result = this._agent.GetService(typeof(IA2AClient)); // Assert Assert.NotNull(result); Assert.Same(this._a2aClient, result); } + /// + /// Verify that GetService returns null when requesting the concrete A2AClient type + /// since the agent now exposes IA2AClient instead. + /// + [Fact] + public void GetService_RequestingConcreteA2AClient_ReturnsNull() + { + // Arrange & Act + var result = this._agent.GetService(typeof(A2AClient)); + + // Assert + Assert.Null(result); + } + /// /// Verify that GetService returns AIAgentMetadata when requested. /// @@ -1129,10 +1487,10 @@ public void GetService_RequestingAIAgentType_ReturnsBaseImplementation() /// Verify that GetService calls base.GetService() first but continues to derived logic when base returns null. /// [Fact] - public void GetService_RequestingA2AClientWithServiceKey_CallsBaseFirstThenDerivedLogic() + public void GetService_RequestingIA2AClientWithServiceKey_CallsBaseFirstThenDerivedLogic() { - // Arrange & Act - Request A2AClient with a service key (base.GetService will return null due to serviceKey) - var result = this._agent.GetService(typeof(A2AClient), "some-key"); + // Arrange & Act - Request IA2AClient with a service key (base.GetService will return null due to serviceKey) + var result = this._agent.GetService(typeof(IA2AClient), "some-key"); // Assert Assert.NotNull(result); @@ -1256,6 +1614,7 @@ await Assert.ThrowsAnyAsync(async () => public void Dispose() { + this._a2aClient.Dispose(); this._handler.Dispose(); this._httpClient.Dispose(); } @@ -1269,13 +1628,34 @@ internal sealed class A2AClientHttpMessageHandlerStub : HttpMessageHandler { public JsonRpcRequest? CapturedJsonRpcRequest { get; set; } - public MessageSendParams? CapturedMessageSendParams { get; set; } + public List CapturedJsonRpcRequests { get; } = []; + + public SendMessageRequest? CapturedSendMessageRequest { get; set; } - public TaskIdParams? CapturedTaskIdParams { get; set; } + public GetTaskRequest? CapturedGetTaskRequest { get; set; } - public A2AEvent? ResponseToReturn { get; set; } + public SendMessageResponse? ResponseToReturn { get; set; } - public A2AEvent? StreamingResponseToReturn { get; set; } + public AgentTask? AgentTaskToReturn { get; set; } + + public StreamResponse? StreamingResponseToReturn { get; set; } + + /// + /// 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 +1666,121 @@ protected override async Task SendAsync(HttpRequestMessage this.CapturedJsonRpcRequest = JsonSerializer.Deserialize(content); + if (this.CapturedJsonRpcRequest is not null) + { + this.CapturedJsonRpcRequests.Add(this.CapturedJsonRpcRequest); + } + try { - this.CapturedMessageSendParams = this.CapturedJsonRpcRequest?.Params?.Deserialize(); + this.CapturedSendMessageRequest = this.CapturedJsonRpcRequest?.Params?.Deserialize(A2AJsonUtilities.DefaultOptions); } - catch { /* Ignore deserialization errors for non-MessageSendParams requests */ } + catch { /* Ignore deserialization errors for non-SendMessageRequest requests */ } try { - this.CapturedTaskIdParams = this.CapturedJsonRpcRequest?.Params?.Deserialize(); + this.CapturedGetTaskRequest = this.CapturedJsonRpcRequest?.Params?.Deserialize(A2AJsonUtilities.DefaultOptions); + } + catch { /* Ignore deserialization errors for non-GetTaskRequest requests */ } + + // Return a JSON-RPC error for GetTask when configured + if (this.GetTaskErrorCodeToReturn is not null && this.CapturedJsonRpcRequest?.Method == "GetTask") + { + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Error = new JsonRpcError + { + Code = (int)this.GetTaskErrorCodeToReturn.Value, + Message = "Simulated GetTask error." + } + }; + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json") + }; + } + + // Return the pre-configured AgentTask response (for tasks/get) + if (this.AgentTaskToReturn is not null && this.CapturedJsonRpcRequest?.Method == "GetTask") + { + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Result = JsonSerializer.SerializeToNode(this.AgentTaskToReturn, A2AJsonUtilities.DefaultOptions) + }; + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json") + }; } - catch { /* Ignore deserialization errors for non-TaskIdParams requests */ } // Return the pre-configured non-streaming response if (this.ResponseToReturn is not null) { - var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("response-id", this.ResponseToReturn); + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Result = JsonSerializer.SerializeToNode(this.ResponseToReturn, A2AJsonUtilities.DefaultOptions) + }; return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json") }; } - // Return the pre-configured streaming response - else if (this.StreamingResponseToReturn is not null) + // Return a streaming JSON-RPC error (e.g., UnsupportedOperation for SubscribeToTask) + else if (this.StreamingErrorCodeToReturn is not null + && this.CapturedJsonRpcRequest?.Method is "SubscribeToTask") { + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Error = new JsonRpcError + { + Code = (int)this.StreamingErrorCodeToReturn.Value, + Message = this.StreamingErrorMessage + } + }; + var stream = new MemoryStream(); + using (var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true)) + { + await writer.WriteAsync($"data: {JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions)}\n\n"); +#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods; overload doesn't exist downlevel + await writer.FlushAsync(); +#pragma warning restore CA2016 + } - await SseFormatter.WriteAsync( - new SseItem[] - { - new(JsonRpcResponse.CreateJsonRpcResponse("response-id", this.StreamingResponseToReturn!)) - }.ToAsyncEnumerable(), - stream, - (item, writer) => + stream.Position = 0; + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(stream) { - using Utf8JsonWriter json = new(writer, new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); - JsonSerializer.Serialize(json, item.Data); - }, - cancellationToken - ); + Headers = { { "Content-Type", "text/event-stream" } } + } + }; + } + // Return the pre-configured streaming response + else if (this.StreamingResponseToReturn is not null) + { + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Result = JsonSerializer.SerializeToNode(this.StreamingResponseToReturn, A2AJsonUtilities.DefaultOptions) + }; + + var stream = new MemoryStream(); + using (var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true)) + { + await writer.WriteAsync($"data: {JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions)}\n\n"); +#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods; overload doesn't exist downlevel + await writer.FlushAsync(); +#pragma warning restore CA2016 + } stream.Position = 0; @@ -1339,7 +1794,11 @@ await SseFormatter.WriteAsync( } else { - var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("response-id", new AgentMessage()); + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Result = JsonSerializer.SerializeToNode(new SendMessageResponse { Message = new Message() }, A2AJsonUtilities.DefaultOptions) + }; return new HttpResponseMessage(HttpStatusCode.OK) { diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAIContentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAIContentExtensionsTests.cs index 358bdfb152..c2e704833a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAIContentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAIContentExtensionsTests.cs @@ -42,14 +42,14 @@ public void ToA2AParts_WithMultipleContents_ReturnsListWithAllParts() Assert.NotNull(result); Assert.Equal(3, result.Count); - var firstTextPart = Assert.IsType(result[0]); - Assert.Equal("First text", firstTextPart.Text); + Assert.Equal(PartContentCase.Text, result[0].ContentCase); + Assert.Equal("First text", result[0].Text); - var filePart = Assert.IsType(result[1]); - Assert.Equal("https://example.com/file1.txt", filePart.File.Uri?.ToString()); + Assert.Equal(PartContentCase.Url, result[1].ContentCase); + Assert.Equal("https://example.com/file1.txt", result[1].Url); - var secondTextPart = Assert.IsType(result[2]); - Assert.Equal("Second text", secondTextPart.Text); + Assert.Equal(PartContentCase.Text, result[2].ContentCase); + Assert.Equal("Second text", result[2].Text); } [Fact] @@ -72,14 +72,14 @@ public void ToA2AParts_WithMixedSupportedAndUnsupportedContent_IgnoresUnsupporte Assert.NotNull(result); Assert.Equal(3, result.Count); - var firstTextPart = Assert.IsType(result[0]); - Assert.Equal("First text", firstTextPart.Text); + Assert.Equal(PartContentCase.Text, result[0].ContentCase); + Assert.Equal("First text", result[0].Text); - var filePart = Assert.IsType(result[1]); - Assert.Equal("https://example.com/file.txt", filePart.File.Uri?.ToString()); + Assert.Equal(PartContentCase.Url, result[1].ContentCase); + Assert.Equal("https://example.com/file.txt", result[1].Url); - var secondTextPart = Assert.IsType(result[2]); - Assert.Equal("Second text", secondTextPart.Text); + Assert.Equal(PartContentCase.Text, result[2].ContentCase); + Assert.Equal("Second text", result[2].Text); } // Mock class for testing unsupported scenarios diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs index f644109b38..603376b396 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Net.Http; using System.Text; @@ -26,7 +27,7 @@ public A2AAgentCardExtensionsTests() { Name = "Test Agent", Description = "A test agent for unit testing", - Url = "http://test-endpoint/agent" + SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }] }; } @@ -50,13 +51,13 @@ public async Task RunIAgentAsync_SendsRequestToTheUrlSpecifiedInAgentCardAsync() using var handler = new HttpMessageHandlerStub(); using var httpClient = new HttpClient(handler, false); - handler.ResponsesToReturn.Enqueue(new AgentMessage + handler.ResponsesToReturn.Enqueue(new Message { - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }], + Role = Role.Agent, + Parts = [Part.FromText("Response")], }); - var agent = this._agentCard.AsAIAgent(httpClient); + var agent = this._agentCard.AsAIAgent(httpClient: httpClient); // Act await agent.RunAsync("Test input"); @@ -66,6 +67,105 @@ public async Task RunIAgentAsync_SendsRequestToTheUrlSpecifiedInAgentCardAsync() Assert.Equal(new Uri("http://test-endpoint/agent"), handler.CapturedUris[0]); } + [Fact] + public async Task AsAIAgent_WithPreferredBindings_UsesMatchingInterfaceAsync() + { + // Arrange + var card = new AgentCard + { + Name = "Multi-Interface Agent", + Description = "An agent with multiple interfaces", + SupportedInterfaces = + [ + new AgentInterface { Url = "http://first/agent", ProtocolBinding = ProtocolBindingNames.HttpJson }, + new AgentInterface { Url = "http://second/agent", ProtocolBinding = ProtocolBindingNames.JsonRpc }, + ] + }; + + using var handler = new HttpMessageHandlerStub(); + using var httpClient = new HttpClient(handler, false); + + handler.ResponsesToReturn.Enqueue(new Message + { + Role = Role.Agent, + Parts = [Part.FromText("Response")], + }); + + var options = new A2AClientOptions + { + PreferredBindings = [ProtocolBindingNames.JsonRpc] + }; + + var agent = card.AsAIAgent(httpClient, options: options); + + // Act + await agent.RunAsync("Test input"); + + // Assert + Assert.Single(handler.CapturedUris); + Assert.Equal(new Uri("http://second/agent"), handler.CapturedUris[0]); + } + + [Fact] + public void AsAIAgent_WithNullOptions_UsesDefaultBindingPreference() + { + // Arrange + var card = new AgentCard + { + Name = "Default Options Agent", + Description = "Tests default A2AClientOptions behavior", + SupportedInterfaces = + [ + new AgentInterface { Url = "http://default/agent" }, + ] + }; + + // Act - null options should use defaults (HTTP+JSON first, JSON-RPC as fallback) + var agent = card.AsAIAgent(options: null); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + Assert.Equal("Default Options Agent", agent.Name); + } + + [Fact] + public void AsAIAgent_WithNoMatchingBinding_ThrowsException() + { + // Arrange + var card = new AgentCard + { + Name = "Unmatched Binding Agent", + Description = "Agent with unsupported binding only", + SupportedInterfaces = + [ + new AgentInterface { Url = "http://grpc/agent", ProtocolBinding = "GRPC" }, + ] + }; + + var options = new A2AClientOptions + { + PreferredBindings = [ProtocolBindingNames.JsonRpc] + }; + + // Act & Assert - factory should throw when no matching binding exists + Assert.ThrowsAny(() => card.AsAIAgent(options: options)); + } + + [Fact] + public void AsAIAgent_WithNoSupportedInterfaces_ThrowsException() + { + // Arrange + var card = new AgentCard + { + Name = "No Interfaces Agent", + Description = "Agent with no supported interfaces", + }; + + // Act & Assert + Assert.ThrowsAny(() => card.AsAIAgent()); + } + internal sealed class HttpMessageHandlerStub : HttpMessageHandler { public Queue ResponsesToReturn { get; } = new(); @@ -86,13 +186,18 @@ protected override async Task SendAsync(HttpRequestMessage Content = new StringContent(json, Encoding.UTF8, "application/json") }; } - else if (response is AgentMessage message) + else if (response is Message message) { - var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("response-id", message); + var sendMessageResponse = new SendMessageResponse { Message = message }; + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Result = JsonSerializer.SerializeToNode(sendMessageResponse, A2AJsonUtilities.DefaultOptions) + }; return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json") + Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions), Encoding.UTF8, "application/json") }; } diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs index 97c9ca7c05..5fdfb1ff89 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs @@ -40,7 +40,7 @@ public void ToChatMessages_WithEmptyArtifactsAndNoUserInputRequests_ReturnsNull( { Id = "task1", Artifacts = [], - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act @@ -58,7 +58,7 @@ public void ToChatMessages_WithNullArtifactsAndNoUserInputRequests_ReturnsNull() { Id = "task1", Artifacts = null, - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act @@ -76,7 +76,7 @@ public void ToAIContents_WithEmptyArtifactsAndNoUserInputRequests_ReturnsNull() { Id = "task1", Artifacts = [], - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act @@ -94,7 +94,7 @@ public void ToAIContents_WithNullArtifactsAndNoUserInputRequests_ReturnsNull() { Id = "task1", Artifacts = null, - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act @@ -110,14 +110,14 @@ public void ToChatMessages_WithValidArtifact_ReturnsChatMessages() // Arrange var artifact = new Artifact { - Parts = [new TextPart { Text = "response" }], + Parts = [Part.FromText("response")], }; var agentTask = new AgentTask { Id = "task1", Artifacts = [artifact], - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act @@ -136,15 +136,15 @@ public void ToAIContents_WithMultipleArtifacts_FlattenAllContents() // Arrange var artifact1 = new Artifact { - Parts = [new TextPart { Text = "content1" }], + Parts = [Part.FromText("content1")], }; var artifact2 = new Artifact { Parts = [ - new TextPart { Text = "content2" }, - new TextPart { Text = "content3" } + Part.FromText("content2"), + Part.FromText("content3") ], }; @@ -152,7 +152,7 @@ public void ToAIContents_WithMultipleArtifacts_FlattenAllContents() { Id = "task1", Artifacts = [artifact1, artifact2], - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs index b18abd4485..1f6cfa65f0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs @@ -22,9 +22,9 @@ public void ToChatMessage_WithMultiplePartsMetadataAndRawRepresentation_ReturnsC Name = "comprehensive-artifact", Parts = [ - new TextPart { Text = "First part" }, - new TextPart { Text = "Second part" }, - new TextPart { Text = "Third part" } + Part.FromText("First part"), + Part.FromText("Second part"), + Part.FromText("Third part") ], Metadata = new Dictionary { @@ -66,9 +66,9 @@ public void ToAIContents_WithMultipleParts_ReturnsCorrectList() Name = "test", Parts = [ - new TextPart { Text = "Part 1" }, - new TextPart { Text = "Part 2" }, - new TextPart { Text = "Part 3" } + Part.FromText("Part 1"), + Part.FromText("Part 2"), + Part.FromText("Part 3") ], Metadata = null }; diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs index dcc45e8fce..8a664b7fc9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs @@ -37,7 +37,7 @@ public async Task GetAIAgentAsync_WithValidAgentCard_ReturnsAIAgentAsync() { Name = "Test Agent", Description = "A test agent for unit testing", - Url = "http://test-endpoint/agent" + SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }] }); // Act @@ -60,15 +60,15 @@ public async Task RunIAgentAsync_WithUrlFromAgentCard_SendsRequestToTheUrlAsync( // Arrange this._handler.ResponsesToReturn.Enqueue(new AgentCard { - Url = "http://test-endpoint/agent" + SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }] }); - this._handler.ResponsesToReturn.Enqueue(new AgentMessage + this._handler.ResponsesToReturn.Enqueue(new Message { - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }], + Role = Role.Agent, + Parts = [Part.FromText("Response")], }); - var agent = await this._resolver.GetAIAgentAsync(this._httpClient); + var agent = await this._resolver.GetAIAgentAsync(httpClient: this._httpClient); // Act await agent.RunAsync("Test input"); @@ -78,6 +78,41 @@ public async Task RunIAgentAsync_WithUrlFromAgentCard_SendsRequestToTheUrlAsync( Assert.Equal(new Uri("http://test-endpoint/agent"), this._handler.CapturedUris[1]); } + [Fact] + public async Task GetAIAgentAsync_WithOptions_PassesOptionsToFactoryAsync() + { + // Arrange + this._handler.ResponsesToReturn.Enqueue(new AgentCard + { + Name = "Options Agent", + Description = "Agent with multiple interfaces", + SupportedInterfaces = + [ + new AgentInterface { Url = "http://httpjson/agent", ProtocolBinding = ProtocolBindingNames.HttpJson }, + new AgentInterface { Url = "http://jsonrpc/agent", ProtocolBinding = ProtocolBindingNames.JsonRpc }, + ] + }); + this._handler.ResponsesToReturn.Enqueue(new Message + { + Role = Role.Agent, + Parts = [Part.FromText("Response")], + }); + + var options = new A2AClientOptions + { + PreferredBindings = [ProtocolBindingNames.JsonRpc] + }; + + var agent = await this._resolver.GetAIAgentAsync(httpClient: this._httpClient, options: options); + + // Act + await agent.RunAsync("Test input"); + + // Assert + Assert.Equal(2, this._handler.CapturedUris.Count); + Assert.Equal(new Uri("http://jsonrpc/agent"), this._handler.CapturedUris[1]); + } + public void Dispose() { this._handler.Dispose(); @@ -104,13 +139,18 @@ protected override async Task SendAsync(HttpRequestMessage Content = new StringContent(json, Encoding.UTF8, "application/json") }; } - else if (response is AgentMessage message) + else if (response is Message message) { - var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("response-id", message); + var sendMessageResponse = new SendMessageResponse { Message = message }; + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Result = JsonSerializer.SerializeToNode(sendMessageResponse, A2AJsonUtilities.DefaultOptions) + }; return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json") + Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions), Encoding.UTF8, "application/json") }; } diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AClientExtensionsTests.cs index 9ad4d982a9..80b5107bf1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AClientExtensionsTests.cs @@ -30,4 +30,40 @@ public void GetAIAgent_WithAllParameters_ReturnsA2AAgentWithSpecifiedProperties( Assert.Equal(TestName, agent.Name); Assert.Equal(TestDescription, agent.Description); } + + [Fact] + public void GetAIAgent_WithIA2AClient_ReturnsA2AAgentWithSpecifiedProperties() + { + // Arrange - use IA2AClient reference type to verify the extension method works with the interface + IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint")); + + const string TestId = "ia2a-agent-id"; + const string TestName = "IA2A Agent"; + const string TestDescription = "Agent created from IA2AClient"; + + // Act + var agent = a2aClient.AsAIAgent(TestId, TestName, TestDescription); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + Assert.Equal(TestId, agent.Id); + Assert.Equal(TestName, agent.Name); + Assert.Equal(TestDescription, agent.Description); + } + + [Fact] + public void GetAIAgent_WithIA2AClient_ExposesClientViaGetService() + { + // Arrange + IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint")); + + // Act + var agent = a2aClient.AsAIAgent(); + + // Assert + var service = agent.GetService(typeof(IA2AClient)); + Assert.NotNull(service); + Assert.Same(a2aClient, service); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/ChatMessageExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/ChatMessageExtensionsTests.cs index 8d771c679c..bb502bbea0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/ChatMessageExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/ChatMessageExtensionsTests.cs @@ -32,20 +32,19 @@ public void ToA2AMessage_WithMessageContainingMultipleContents_AddsAllContentsAs Assert.NotNull(a2aMessage.MessageId); Assert.NotEmpty(a2aMessage.MessageId); - Assert.Equal(MessageRole.User, a2aMessage.Role); + Assert.Equal(Role.User, a2aMessage.Role); Assert.NotNull(a2aMessage.Parts); Assert.Equal(3, a2aMessage.Parts.Count); - var filePart = Assert.IsType(a2aMessage.Parts[0]); - Assert.NotNull(filePart.File); - Assert.Equal("https://example.com/report.pdf", filePart.File.Uri?.ToString()); + Assert.Equal(PartContentCase.Url, a2aMessage.Parts[0].ContentCase); + Assert.Equal("https://example.com/report.pdf", a2aMessage.Parts[0].Url); - var secondTextPart = Assert.IsType(a2aMessage.Parts[1]); - Assert.Equal("please summarize the file content", secondTextPart.Text); + Assert.Equal(PartContentCase.Text, a2aMessage.Parts[1].ContentCase); + Assert.Equal("please summarize the file content", a2aMessage.Parts[1].Text); - var thirdTextPart = Assert.IsType(a2aMessage.Parts[2]); - Assert.Equal("and send it to me over email", thirdTextPart.Text); + Assert.Equal(PartContentCase.Text, a2aMessage.Parts[2].ContentCase); + Assert.Equal("and send it to me over email", a2aMessage.Parts[2].Text); } [Fact] @@ -71,19 +70,18 @@ public void ToA2AMessage_WithMixedMessages_AddsAllContentsAsParts() Assert.NotNull(a2aMessage.MessageId); Assert.NotEmpty(a2aMessage.MessageId); - Assert.Equal(MessageRole.User, a2aMessage.Role); + Assert.Equal(Role.User, a2aMessage.Role); Assert.NotNull(a2aMessage.Parts); Assert.Equal(3, a2aMessage.Parts.Count); - var filePart = Assert.IsType(a2aMessage.Parts[0]); - Assert.NotNull(filePart.File); - Assert.Equal("https://example.com/report.pdf", filePart.File.Uri?.ToString()); + Assert.Equal(PartContentCase.Url, a2aMessage.Parts[0].ContentCase); + Assert.Equal("https://example.com/report.pdf", a2aMessage.Parts[0].Url); - var secondTextPart = Assert.IsType(a2aMessage.Parts[1]); - Assert.Equal("please summarize the file content", secondTextPart.Text); + Assert.Equal(PartContentCase.Text, a2aMessage.Parts[1].ContentCase); + Assert.Equal("please summarize the file content", a2aMessage.Parts[1].Text); - var thirdTextPart = Assert.IsType(a2aMessage.Parts[2]); - Assert.Equal("and send it to me over email", thirdTextPart.Text); + Assert.Equal(PartContentCase.Text, a2aMessage.Parts[2].ContentCase); + Assert.Equal("and send it to me over email", a2aMessage.Parts[2].Text); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj index d33de0613b..97541f6a94 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj @@ -1,5 +1,9 @@ + + $(TargetFrameworksCore) + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs new file mode 100644 index 0000000000..65eeeb1fa6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs @@ -0,0 +1,893 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +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 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 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(System.Text.Json.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/EndpointRouteA2ABuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs similarity index 53% rename from dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs index a848528888..5c235e649f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using A2A; using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.AI; @@ -10,15 +9,15 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; /// -/// Tests for MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions.MapA2A method. +/// Tests for A2AEndpointRouteBuilderExtensions and A2AServerServiceCollectionExtensions methods. /// -public sealed class EndpointRouteA2ABuilderExtensionsTests +public sealed class A2AEndpointRouteBuilderExtensionsTests { /// - /// Verifies that MapA2A throws ArgumentNullException for null endpoints. + /// Verifies that MapA2AHttpJson throws ArgumentNullException for null endpoints. /// [Fact] - public void MapA2A_WithAgentBuilder_NullEndpoints_ThrowsArgumentNullException() + public void MapA2AHttpJson_WithAgentBuilder_NullEndpoints_ThrowsArgumentNullException() { // Arrange AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; @@ -29,16 +28,16 @@ public void MapA2A_WithAgentBuilder_NullEndpoints_ThrowsArgumentNullException() // Act & Assert ArgumentNullException exception = Assert.Throws(() => - endpoints.MapA2A(agentBuilder, "/a2a")); + endpoints.MapA2AHttpJson(agentBuilder, "/a2a")); Assert.Equal("endpoints", exception.ParamName); } /// - /// Verifies that MapA2A throws ArgumentNullException for null agentBuilder. + /// Verifies that MapA2AHttpJson throws ArgumentNullException for null agentBuilder. /// [Fact] - public void MapA2A_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException() + public void MapA2AHttpJson_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); @@ -51,429 +50,391 @@ public void MapA2A_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException // Act & Assert ArgumentNullException exception = Assert.Throws(() => - app.MapA2A(agentBuilder, "/a2a")); + app.MapA2AHttpJson(agentBuilder, "/a2a")); Assert.Equal("agentBuilder", exception.ParamName); } /// - /// Verifies that MapA2A with IHostedAgentBuilder correctly maps the agent with default task manager configuration. + /// Verifies that MapA2AHttpJson with IHostedAgentBuilder correctly maps the agent with default configuration. /// [Fact] - public void MapA2A_WithAgentBuilder_DefaultConfiguration_Succeeds() + 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.MapA2A(agentBuilder, "/a2a"); + var result = app.MapA2AHttpJson(agentBuilder, "/a2a"); Assert.NotNull(result); - Assert.NotNull(app); } /// - /// Verifies that MapA2A with IHostedAgentBuilder and custom task manager configuration succeeds. + /// Verifies that MapA2AHttpJson with string agent name correctly maps the agent. /// [Fact] - public void MapA2A_WithAgentBuilder_CustomTaskManagerConfiguration_Succeeds() + public void MapA2AHttpJson_WithAgentName_DefaultConfiguration_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddA2AServer("agent"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", taskManager => { }); + var result = app.MapA2AHttpJson("agent", "/a2a"); Assert.NotNull(result); - Assert.NotNull(app); } /// - /// Verifies that MapA2A with IHostedAgentBuilder and agent card succeeds. + /// Verifies that MapA2AJsonRpc with IHostedAgentBuilder correctly maps the agent. /// [Fact] - public void MapA2A_WithAgentBuilder_WithAgentCard_Succeeds() + 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(); - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", agentCard); + var result = app.MapA2AJsonRpc(agentBuilder, "/a2a"); Assert.NotNull(result); - Assert.NotNull(app); } /// - /// Verifies that MapA2A with IHostedAgentBuilder, agent card, and custom task manager configuration succeeds. + /// Verifies that MapA2AJsonRpc with string agent name correctly maps the agent. /// [Fact] - public void MapA2A_WithAgentBuilder_WithAgentCardAndCustomConfiguration_Succeeds() + public void MapA2AJsonRpc_WithAgentName_DefaultConfiguration_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddA2AServer("agent"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", agentCard, taskManager => { }); + var result = app.MapA2AJsonRpc("agent", "/a2a"); Assert.NotNull(result); - Assert.NotNull(app); } /// - /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using string agent name. + /// Verifies that both MapA2AHttpJson and MapA2AJsonRpc can be called for the same agent. /// [Fact] - public void MapA2A_WithAgentName_NullEndpoints_ThrowsArgumentNullException() + public void MapA2AHttpJson_And_MapA2AJsonRpc_SameAgent_Succeeds() { // Arrange - AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; - - // Act & Assert - ArgumentNullException exception = Assert.Throws(() => - endpoints.MapA2A("agent", "/a2a")); + 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(); - Assert.Equal("endpoints", exception.ParamName); + // 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 MapA2A with string agent name correctly maps the agent. + /// Verifies that multiple agents can be mapped to different paths. /// [Fact] - public void MapA2A_WithAgentName_DefaultConfiguration_Succeeds() + public void MapA2AHttpJson_MultipleAgents_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + 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 - var result = app.MapA2A("agent", "/a2a"); - Assert.NotNull(result); + app.MapA2AHttpJson(agent1Builder, "/a2a/agent1"); + app.MapA2AHttpJson(agent2Builder, "/a2a/agent2"); Assert.NotNull(app); } /// - /// Verifies that MapA2A with string agent name and custom task manager configuration succeeds. + /// Verifies that custom paths can be specified for A2A endpoints. /// [Fact] - public void MapA2A_WithAgentName_CustomTaskManagerConfiguration_Succeeds() + public void MapA2AHttpJson_WithCustomPath_AcceptsValidPath() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(); builder.Services.AddLogging(); using WebApplication app = builder.Build(); // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a", taskManager => { }); - Assert.NotNull(result); + app.MapA2AHttpJson(agentBuilder, "/custom/a2a/path"); Assert.NotNull(app); } /// - /// Verifies that MapA2A with string agent name and agent card succeeds. + /// Verifies that AddA2AServer with custom A2AServerRegistrationOptions succeeds. /// [Fact] - public void MapA2A_WithAgentName_WithAgentCard_Succeeds() + public void AddA2AServer_WithCustomOptions_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(options => options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported); 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); + var result = app.MapA2AHttpJson(agentBuilder, "/a2a"); Assert.NotNull(result); - Assert.NotNull(app); } /// - /// Verifies that MapA2A with string agent name, agent card, and custom task manager configuration succeeds. + /// Verifies that MapA2AHttpJson throws ArgumentNullException for null endpoints when using string agent name. /// [Fact] - public void MapA2A_WithAgentName_WithAgentCardAndCustomConfiguration_Succeeds() + public void MapA2AHttpJson_WithAgentName_NullEndpoints_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(); + AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + endpoints.MapA2AHttpJson("agent", "/a2a")); - // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a", agentCard, taskManager => { }); - Assert.NotNull(result); - Assert.NotNull(app); + Assert.Equal("endpoints", exception.ParamName); } /// - /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using AIAgent. + /// Verifies that MapA2AJsonRpc throws ArgumentNullException for null endpoints when using string agent name. /// [Fact] - public void MapA2A_WithAIAgent_NullEndpoints_ThrowsArgumentNullException() + public void MapA2AJsonRpc_WithAgentName_NullEndpoints_ThrowsArgumentNullException() { // Arrange AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; // Act & Assert ArgumentNullException exception = Assert.Throws(() => - endpoints.MapA2A((AIAgent)null!, "/a2a")); + endpoints.MapA2AJsonRpc("agent", "/a2a")); Assert.Equal("endpoints", exception.ParamName); } /// - /// Verifies that MapA2A with AIAgent correctly maps the agent. + /// Verifies that MapA2AHttpJson throws ArgumentNullException for null agentName. /// [Fact] - public void MapA2A_WithAIAgent_DefaultConfiguration_Succeeds() + public void MapA2AHttpJson_WithAgentName_NullAgentName_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(); - AIAgent agent = app.Services.GetRequiredKeyedService("agent"); - // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a"); - Assert.NotNull(result); - Assert.NotNull(app); + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + app.MapA2AHttpJson((string)null!, "/a2a")); + + Assert.Equal("agentName", exception.ParamName); } /// - /// Verifies that MapA2A with AIAgent and custom task manager configuration succeeds. + /// Verifies that MapA2AHttpJson throws ArgumentException for empty agentName. /// [Fact] - public void MapA2A_WithAIAgent_CustomTaskManagerConfiguration_Succeeds() + public void MapA2AHttpJson_WithAgentName_EmptyAgentName_ThrowsArgumentException() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); - AIAgent agent = app.Services.GetRequiredKeyedService("agent"); - // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a", taskManager => { }); - Assert.NotNull(result); - Assert.NotNull(app); + // Act & Assert + ArgumentException exception = Assert.Throws(() => + app.MapA2AHttpJson(string.Empty, "/a2a")); + + Assert.Equal("agentName", exception.ParamName); } /// - /// Verifies that MapA2A with AIAgent and agent card succeeds. + /// Verifies that MapA2AHttpJson throws ArgumentNullException for null path. /// [Fact] - public void MapA2A_WithAIAgent_WithAgentCard_Succeeds() + public void MapA2AHttpJson_NullPath_ThrowsArgumentNullException() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(); 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); + // Act & Assert + Assert.Throws(() => + app.MapA2AHttpJson(agentBuilder, null!)); } /// - /// Verifies that MapA2A with AIAgent, agent card, and custom task manager configuration succeeds. + /// Verifies that MapA2AHttpJson throws ArgumentException for whitespace-only path. /// [Fact] - public void MapA2A_WithAIAgent_WithAgentCardAndCustomConfiguration_Succeeds() + public void MapA2AHttpJson_WhitespacePath_ThrowsArgumentException() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(); 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 + Assert.Throws(() => + app.MapA2AHttpJson(agentBuilder, " ")); + } - // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a", agentCard, taskManager => { }); - Assert.NotNull(result); - Assert.NotNull(app); + /// + /// 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 MapA2A throws ArgumentNullException for null endpoints when using ITaskManager. + /// Verifies that AddA2AServer throws ArgumentNullException for null agentName. /// [Fact] - public void MapA2A_WithTaskManager_NullEndpoints_ThrowsArgumentNullException() + public void AddA2AServer_NullAgentName_ThrowsArgumentNullException() { // Arrange - AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; - ITaskManager taskManager = null!; + IServiceCollection services = new ServiceCollection(); // Act & Assert ArgumentNullException exception = Assert.Throws(() => - endpoints.MapA2A(taskManager, "/a2a")); + services.AddA2AServer((string)null!)); - Assert.Equal("endpoints", exception.ParamName); + Assert.Equal("agentName", exception.ParamName); } /// - /// Verifies that multiple agents can be mapped to different paths. + /// Verifies that AddA2AServer throws ArgumentException for empty agentName. /// [Fact] - public void MapA2A_MultipleAgents_Succeeds() + public void AddA2AServer_EmptyAgentName_ThrowsArgumentException() { // 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(); + IServiceCollection services = new ServiceCollection(); - // Act & Assert - Should not throw - app.MapA2A(agent1Builder, "/a2a/agent1"); - app.MapA2A(agent2Builder, "/a2a/agent2"); - Assert.NotNull(app); + // Act & Assert + ArgumentException exception = Assert.Throws(() => + services.AddA2AServer(string.Empty)); + + Assert.Equal("agentName", exception.ParamName); } /// - /// Verifies that custom paths can be specified for A2A endpoints. + /// Verifies that AddA2AServer on IHostedAgentBuilder throws ArgumentNullException for null builder. /// [Fact] - public void MapA2A_WithCustomPath_AcceptsValidPath() + public void AddA2AServer_NullAgentBuilder_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"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); + IHostedAgentBuilder agentBuilder = null!; - // Act & Assert - Should not throw - app.MapA2A(agentBuilder, "/custom/a2a/path"); - Assert.NotNull(app); + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + agentBuilder.AddA2AServer()); + + Assert.Equal("agentBuilder", exception.ParamName); } /// - /// Verifies that task manager configuration callback is invoked correctly. + /// Verifies that MapA2AHttpJson throws InvalidOperationException when no A2AServer has been + /// registered for the specified agent via AddA2AServer. /// [Fact] - public void MapA2A_WithAgentBuilder_TaskManagerConfigurationCallbackInvoked() + public void MapA2AHttpJson_WithoutAddA2AServer_ThrowsInvalidOperationException() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); - bool configureCallbackInvoked = false; - - // Act - app.MapA2A(agentBuilder, "/a2a", taskManager => - { - configureCallbackInvoked = true; - Assert.NotNull(taskManager); - }); + // Act & Assert + InvalidOperationException exception = Assert.Throws(() => + app.MapA2AHttpJson("agent", "/a2a")); - // Assert - Assert.True(configureCallbackInvoked); + Assert.Contains("agent", exception.Message); + Assert.Contains("AddA2AServer", exception.Message); } /// - /// Verifies that agent card with all properties is accepted. + /// Verifies that MapA2AJsonRpc throws InvalidOperationException when no A2AServer has been + /// registered for the specified agent via AddA2AServer. /// [Fact] - public void MapA2A_WithAgentBuilder_FullAgentCard_Succeeds() + public void MapA2AJsonRpc_WithoutAddA2AServer_ThrowsInvalidOperationException() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A comprehensive test agent" - }; + // Act & Assert + InvalidOperationException exception = Assert.Throws(() => + app.MapA2AJsonRpc("agent", "/a2a")); - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", agentCard); - Assert.NotNull(result); + 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/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..6128e0eefc --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using A2A; + +namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class AgentRunModeTests +{ + /// + /// Verifies that AllowBackgroundWhen throws ArgumentNullException for null delegate. + /// + [Fact] + public void AllowBackgroundWhen_NullDelegate_ThrowsArgumentNullException() + { + // Arrange & Act & Assert + Assert.Throws(() => + AgentRunMode.AllowBackgroundWhen(null!)); + } + + /// + /// Verifies that DisallowBackground equals another DisallowBackground instance. + /// + [Fact] + public void Equals_DisallowBackground_AreEqual() + { + // Arrange + var mode1 = AgentRunMode.DisallowBackground; + var mode2 = AgentRunMode.DisallowBackground; + + // Act & Assert + Assert.True(mode1.Equals(mode2)); + Assert.True(mode1 == mode2); + Assert.False(mode1 != mode2); + Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode()); + } + + /// + /// Verifies that AllowBackgroundIfSupported equals another AllowBackgroundIfSupported instance. + /// + [Fact] + public void Equals_AllowBackgroundIfSupported_AreEqual() + { + // Arrange + var mode1 = AgentRunMode.AllowBackgroundIfSupported; + var mode2 = AgentRunMode.AllowBackgroundIfSupported; + + // Act & Assert + Assert.True(mode1.Equals(mode2)); + Assert.True(mode1 == mode2); + } + + /// + /// Verifies that DisallowBackground and AllowBackgroundIfSupported are not equal. + /// + [Fact] + public void Equals_DifferentModes_AreNotEqual() + { + // Arrange + var disallow = AgentRunMode.DisallowBackground; + var allow = AgentRunMode.AllowBackgroundIfSupported; + + // Act & Assert + Assert.False(disallow.Equals(allow)); + Assert.False(disallow == allow); + Assert.True(disallow != allow); + } + + /// + /// Verifies that Equals returns false for null. + /// + [Fact] + public void Equals_Null_ReturnsFalse() + { + // Arrange + var mode = AgentRunMode.DisallowBackground; + + // Act & Assert + Assert.False(mode.Equals(null)); + Assert.False(mode.Equals((object?)null)); + Assert.False(mode == null); + Assert.True(mode != null); + } + + /// + /// Verifies that two null AgentRunMode values are equal. + /// + [Fact] + public void Equals_BothNull_AreEqual() + { + // Arrange + AgentRunMode? mode1 = null; + AgentRunMode? mode2 = null; + + // Act & Assert + Assert.True(mode1 == mode2); + Assert.False(mode1 != mode2); + } + + /// + /// Verifies that ToString returns expected values. + /// + [Fact] + public void ToString_ReturnsExpectedValues() + { + // Act & Assert + Assert.Equal("message", AgentRunMode.DisallowBackground.ToString()); + Assert.Equal("task", AgentRunMode.AllowBackgroundIfSupported.ToString()); + Assert.Equal("dynamic", AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)).ToString()); + } + + /// + /// Verifies that Equals works correctly with object parameter. + /// + [Fact] + public void Equals_WithObjectParameter_WorksCorrectly() + { + // Arrange + var mode = AgentRunMode.DisallowBackground; + + // Act & Assert + Assert.True(mode.Equals((object)AgentRunMode.DisallowBackground)); + Assert.False(mode.Equals((object)AgentRunMode.AllowBackgroundIfSupported)); + Assert.False(mode.Equals("not a run mode")); + } + + /// + /// Verifies that two AllowBackgroundWhen instances with different delegates are considered equal, + /// because equality is based on the mode value ("dynamic"), not the delegate. + /// + [Fact] + public void Equals_AllowBackgroundWhen_DifferentDelegates_AreEqual() + { + // Arrange + var mode1 = AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)); + var mode2 = AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false)); + + // Act & Assert + Assert.True(mode1.Equals(mode2)); + Assert.True(mode1 == mode2); + Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode()); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs index 69eaf3a535..1106802463 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs @@ -10,66 +10,66 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests.Converters; public class MessageConverterTests { [Fact] - public void ToChatMessages_MessageSendParams_Null_ReturnsEmptyCollection() + public void ToChatMessages_SendMessageRequest_Null_ReturnsEmptyCollection() { - MessageSendParams? messageSendParams = null; + SendMessageRequest? sendMessageRequest = null; - var result = messageSendParams!.ToChatMessages(); + var result = sendMessageRequest!.ToChatMessages(); Assert.NotNull(result); Assert.Empty(result); } [Fact] - public void ToChatMessages_MessageSendParams_WithNullMessage_ReturnsEmptyCollection() + public void ToChatMessages_SendMessageRequest_WithNullMessage_ReturnsEmptyCollection() { - var messageSendParams = new MessageSendParams + var sendMessageRequest = new SendMessageRequest { Message = null! }; - var result = messageSendParams.ToChatMessages(); + var result = sendMessageRequest.ToChatMessages(); Assert.NotNull(result); Assert.Empty(result); } [Fact] - public void ToChatMessages_MessageSendParams_WithMessageWithoutParts_ReturnsEmptyCollection() + public void ToChatMessages_SendMessageRequest_WithMessageWithoutParts_ReturnsEmptyCollection() { - var messageSendParams = new MessageSendParams + var sendMessageRequest = new SendMessageRequest { - Message = new AgentMessage + Message = new Message { MessageId = "test-id", - Role = MessageRole.User, + Role = Role.User, Parts = null! } }; - var result = messageSendParams.ToChatMessages(); + var result = sendMessageRequest.ToChatMessages(); Assert.NotNull(result); Assert.Empty(result); } [Fact] - public void ToChatMessages_MessageSendParams_WithValidTextMessage_ReturnsCorrectChatMessage() + public void ToChatMessages_SendMessageRequest_WithValidTextMessage_ReturnsCorrectChatMessage() { - var messageSendParams = new MessageSendParams + var sendMessageRequest = new SendMessageRequest { - Message = new AgentMessage + Message = new Message { MessageId = "test-id", - Role = MessageRole.User, + Role = Role.User, Parts = [ - new TextPart { Text = "Hello, world!" } + new Part { Text = "Hello, world!" } ] } }; - var result = messageSendParams.ToChatMessages(); + var result = sendMessageRequest.ToChatMessages(); Assert.NotNull(result); Assert.Single(result);