diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs index 28e0c5fe5e..fd8d838428 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs @@ -153,12 +153,23 @@ private static AgentCard GetLogisticsAgentCard(string[] agentUrls) private static List CreateAgentInterfaces(string[] agentUrls) { - return agentUrls.Select(url => new AgentInterface + List agentInterfaces = []; + + agentInterfaces.AddRange(agentUrls.Select(url => new AgentInterface { Url = url, ProtocolBinding = "JSONRPC", ProtocolVersion = "1.0", - }).ToList(); + })); + + agentInterfaces.AddRange(agentUrls.Select(url => new AgentInterface + { + Url = url, + ProtocolBinding = "HTTP+JSON", + ProtocolVersion = "1.0", + })); + + return agentInterfaces; } #endregion } diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs index 773d57e9fc..854a48535d 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs @@ -3,6 +3,7 @@ using A2A.AspNetCore; using A2AServer; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.A2A; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; @@ -107,7 +108,8 @@ You specialize in handling queries related to logistics. app.MapA2A( hostA2AAgent, - path: "/", - agentCard: hostA2AAgentCard); + path: "/", protocolBindings: A2AProtocolBinding.JsonRpc | A2AProtocolBinding.HttpJson); + +app.MapWellKnownAgentCard(hostA2AAgentCard); await app.RunAsync(); diff --git a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs index 15e7cbbd86..1c5a1ba605 100644 --- a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using A2A; using A2A.AspNetCore; using AgentWebChat.AgentHost; using AgentWebChat.AgentHost.Custom; @@ -156,15 +157,7 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te // attach a2a with simple message communication app.MapA2A(pirateAgentBuilder, path: "/a2a/pirate"); -app.MapA2A(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves", agentCard: new() -{ - Name = "Knights and Knaves", - Description = "An agent that helps you solve the knights and knaves puzzle.", - Version = "1.0", - - // Url can be not set, and SDK will help assign it. - // Url = "http://localhost:5390/a2a/knights-and-knaves" -}); +app.MapA2A(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves"); app.MapDevUI(); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000000..fd1ad6db20 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using A2A; +using A2A.AspNetCore; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Hosting.A2A; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// Provides extension methods for configuring A2A endpoints for AI agents. +/// +[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] +public static class A2AEndpointRouteBuilderExtensions +{ + /// + /// Maps A2A endpoints for the specified agent to the given path. + /// + /// The to add the A2A endpoints to. + /// The configuration builder for . + /// The route path prefix for A2A endpoints. + /// The A2A protocol binding(s) to expose. When , defaults to . + /// The agent run mode that controls how the agent responds to A2A requests. When , defaults to . + /// An for further endpoint configuration. + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, A2AProtocolBinding? protocolBindings, AgentRunMode? agentRunMode = null) + { + ArgumentNullException.ThrowIfNull(agentBuilder); + + return endpoints.MapA2A(agentBuilder.Name, path, protocolBindings, agentRunMode); + } + + /// + /// Maps A2A endpoints for the specified agent to the given path. + /// + /// The to add the A2A endpoints to. + /// The configuration builder for . + /// The route path prefix for A2A endpoints. + /// An optional callback to configure . + /// An for further endpoint configuration. + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(agentBuilder); + + return endpoints.MapA2A(agentBuilder.Name, path, configureOptions); + } + + /// + /// Maps A2A endpoints for the agent with the specified name to the given path. + /// + /// The to add the A2A endpoints to. + /// The name of the agent to use for A2A protocol integration. + /// The route path prefix for A2A endpoints. + /// The A2A protocol binding(s) to expose. When , defaults to . + /// The agent run mode that controls how the agent responds to A2A requests. When , defaults to . + /// An for further endpoint configuration. + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, A2AProtocolBinding? protocolBindings, AgentRunMode? agentRunMode = null) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentException.ThrowIfNullOrEmpty(agentName); + + var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); + + return endpoints.MapA2A(agent, path, protocolBindings, agentRunMode); + } + + /// + /// Maps A2A endpoints for the agent with the specified name to the given path. + /// + /// The to add the A2A endpoints to. + /// The name of the agent to use for A2A protocol integration. + /// The route path prefix for A2A endpoints. + /// An optional callback to configure . + /// An for further endpoint configuration. + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentException.ThrowIfNullOrEmpty(agentName); + + var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); + + return endpoints.MapA2A(agent, path, configureOptions); + } + + /// + /// Maps A2A endpoints for the specified agent to the given path. + /// + /// The to add the A2A endpoints to. + /// The agent to use for A2A protocol integration. + /// The route path prefix for A2A endpoints. + /// The A2A protocol binding(s) to expose. When , defaults to . + /// The agent run mode that controls how the agent responds to A2A requests. When , defaults to . + /// An for further endpoint configuration. + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, A2AProtocolBinding? protocolBindings, AgentRunMode? agentRunMode = null) + { + Action? configureOptions = null; + + if (protocolBindings is not null || agentRunMode is not null) + { + configureOptions = options => + { + options.ProtocolBindings = protocolBindings; + options.AgentRunMode = agentRunMode; + }; + } + + return endpoints.MapA2A(agent, path, configureOptions); + } + + /// + /// Maps A2A endpoints for the specified agent to the given path. + /// + /// The to add the A2A endpoints to. + /// The agent to use for A2A protocol integration. + /// The route path prefix for A2A endpoints. + /// An optional callback to configure . + /// An for further endpoint configuration. + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(agent); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name)); + + A2AHostingOptions? options = null; + if (configureOptions is not null) + { + options = new A2AHostingOptions(); + configureOptions(options); + } + + var a2aServer = CreateA2AServer(endpoints, agent, options); + + return MapA2AEndpoints(endpoints, a2aServer, path, options?.ProtocolBindings); + } + + private static A2AServer CreateA2AServer(IEndpointRouteBuilder endpoints, AIAgent agent, A2AHostingOptions? options) + { + var agentHandler = endpoints.ServiceProvider.GetKeyedService(agent.Name); + if (agentHandler is null) + { + var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(agent.Name); + agentHandler = agent.MapA2A(agentSessionStore: agentSessionStore, runMode: options?.AgentRunMode); + } + + var loggerFactory = endpoints.ServiceProvider.GetService() ?? NullLoggerFactory.Instance; + var taskStore = endpoints.ServiceProvider.GetKeyedService(agent.Name) ?? new InMemoryTaskStore(); + + return new A2AServer( + agentHandler, + taskStore, + new ChannelEventNotifier(), + loggerFactory.CreateLogger(), + options?.ServerOptions); + } + + private static IEndpointConventionBuilder MapA2AEndpoints(IEndpointRouteBuilder endpoints, A2AServer a2aServer, string path, A2AProtocolBinding? protocolBindings) + { + protocolBindings ??= A2AProtocolBinding.HttpJson; + + IEndpointConventionBuilder? result = null; + + if (protocolBindings.Value.HasFlag(A2AProtocolBinding.JsonRpc)) + { + result = endpoints.MapA2A(a2aServer, path); + } + + if (protocolBindings.Value.HasFlag(A2AProtocolBinding.HttpJson)) + { + // TODO: The stub AgentCard is temporary and will be removed once the A2A SDK either removes the + // agentCard parameter of MapHttpA2A or makes it optional. MapHttpA2A exposes the agent card via a + // GET {path}/card endpoint that is not part of the A2A spec, so it is not expected to be consumed + // by any agent - returning a stub agent card here is safe. + var stubAgentCard = new AgentCard { Name = "A2A Agent" }; + + result = endpoints.MapHttpA2A(a2aServer, stubAgentCard, path); + } + + return result ?? throw new InvalidOperationException("At least one A2A protocol binding must be specified."); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs deleted file mode 100644 index af3ff093ee..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs +++ /dev/null @@ -1,385 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Diagnostics.CodeAnalysis; -using A2A; -using A2A.AspNetCore; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Hosting; -using Microsoft.Agents.AI.Hosting.A2A; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Shared.DiagnosticIds; - -namespace Microsoft.AspNetCore.Builder; - -/// -/// Provides extension methods for configuring A2A (Agent2Agent) communication in a host application builder. -/// -[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] -public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions -{ - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The configuration builder for . - /// The route group to use for A2A endpoints. - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path) - => endpoints.MapA2A(agentBuilder, path, _ => { }); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The configuration builder for . - /// The route group to use for A2A endpoints. - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentRunMode agentRunMode) - { - ArgumentNullException.ThrowIfNull(agentBuilder); - return endpoints.MapA2A(agentBuilder.Name, path, agentRunMode); - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The name of the agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path) - => endpoints.MapA2A(agentName, path, _ => { }); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The name of the agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentRunMode agentRunMode) - { - ArgumentNullException.ThrowIfNull(endpoints); - var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - return endpoints.MapA2A(agent, path, _ => { }, agentRunMode); - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The configuration builder for . - /// The route group to use for A2A endpoints. - /// The callback to configure . - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, Action configureTaskManager) - { - ArgumentNullException.ThrowIfNull(agentBuilder); - return endpoints.MapA2A(agentBuilder.Name, path, configureTaskManager); - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The name of the agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// The callback to configure . - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, Action configureTaskManager) - { - ArgumentNullException.ThrowIfNull(endpoints); - var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - return endpoints.MapA2A(agent, path, configureTaskManager); - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The configuration builder for . - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard) - => endpoints.MapA2A(agentBuilder, path, agentCard, _ => { }); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The name of the agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard) - => endpoints.MapA2A(agentName, path, agentCard, _ => { }); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The configuration builder for . - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, AgentRunMode agentRunMode) - { - ArgumentNullException.ThrowIfNull(agentBuilder); - return endpoints.MapA2A(agentBuilder.Name, path, agentCard, agentRunMode); - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The name of the agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, AgentRunMode agentRunMode) - { - ArgumentNullException.ThrowIfNull(endpoints); - var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - return endpoints.MapA2A(agent, path, agentCard, agentRunMode); - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The configuration builder for . - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// The callback to configure . - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, Action configureTaskManager) - { - ArgumentNullException.ThrowIfNull(agentBuilder); - return endpoints.MapA2A(agentBuilder.Name, path, agentCard, configureTaskManager); - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The name of the agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// The callback to configure . - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action configureTaskManager) - => endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The name of the agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// The callback to configure . - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action configureTaskManager, AgentRunMode agentRunMode) - { - ArgumentNullException.ThrowIfNull(endpoints); - var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - return endpoints.MapA2A(agent, path, agentCard, configureTaskManager, agentRunMode); - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path) - => endpoints.MapA2A(agent, path, _ => { }); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentRunMode agentRunMode) - => endpoints.MapA2A(agent, path, _ => { }, agentRunMode); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// The callback to configure . - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager) - => endpoints.MapA2A(agent, path, configureTaskManager, AgentRunMode.DisallowBackground); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// The callback to configure . - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager, AgentRunMode agentRunMode) - { - ArgumentNullException.ThrowIfNull(endpoints); - ArgumentNullException.ThrowIfNull(agent); - - var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); - var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(agent.Name); - var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore, runMode: agentRunMode); - var endpointConventionBuilder = endpoints.MapA2A(taskManager, path); - - configureTaskManager(taskManager); - return endpointConventionBuilder; - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard) - => endpoints.MapA2A(agent, path, agentCard, _ => { }); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, AgentRunMode agentRunMode) - => endpoints.MapA2A(agent, path, agentCard, _ => { }, agentRunMode); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// The callback to configure . - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager) - => endpoints.MapA2A(agent, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// The callback to configure . - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager, AgentRunMode agentRunMode) - { - ArgumentNullException.ThrowIfNull(endpoints); - ArgumentNullException.ThrowIfNull(agent); - - var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); - var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(agent.Name); - var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory, runMode: agentRunMode); - var endpointConventionBuilder = endpoints.MapA2A(taskManager, path); - - configureTaskManager(taskManager); - - return endpointConventionBuilder; - } - - /// - /// Maps HTTP A2A communication endpoints to the specified path using the provided TaskManager. - /// TaskManager should be preconfigured before calling this method. - /// - /// The to add the A2A endpoints to. - /// Pre-configured A2A TaskManager to use for A2A endpoints handling. - /// The route group to use for A2A endpoints. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, ITaskManager taskManager, string path) - { - // note: current SDK version registers multiple `.well-known/agent.json` handlers here. - // it makes app return HTTP 500, but will be fixed once new A2A SDK is released. - // see https://github.com/microsoft/agent-framework/issues/476 for details - A2ARouteBuilderExtensions.MapA2A(endpoints, taskManager, path); - return endpoints.MapHttpA2A(taskManager, path); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs new file mode 100644 index 0000000000..fd4a6945f1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using A2A; +using Microsoft.Agents.AI.Hosting.A2A.Converters; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Hosting.A2A; + +/// +/// An implementation that bridges an to the +/// A2A (Agent2Agent) protocol. Handles message execution and cancellation by delegating to +/// the underlying agent and translating responses into A2A events. +/// +[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] +internal sealed class A2AAgentHandler : IAgentHandler +{ + private readonly AIHostAgent _hostAgent; + private readonly AgentRunMode _runMode; + + /// + /// Initializes a new instance of the class. + /// + /// The hosted agent that provides the execution logic. + /// Controls whether the agent runs in background mode. + public A2AAgentHandler( + AIHostAgent hostAgent, + AgentRunMode runMode) + { + this._hostAgent = hostAgent; + this._runMode = runMode; + } + + /// + public Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + if (context.IsContinuation) + { + return this.HandleTaskUpdateAsync(context, eventQueue, cancellationToken); + } + + return this.HandleNewMessageAsync(context, eventQueue, cancellationToken); + } + + /// + public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId); + await taskUpdater.CancelAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + var contextId = context.ContextId ?? Guid.NewGuid().ToString("N"); + var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); + + // AIAgent does not support resuming from arbitrary prior tasks. + // Throw explicitly so the client gets a clear error rather than a response + // that silently ignores the referenced task context. + if (context.Message?.ReferenceTaskIds is { Count: > 0 }) + { + throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context."); + } + + List chatMessages = context.Message is not null ? [context.Message.ToChatMessage()] : []; + + // Decide whether to run in background based on user preferences and agent capabilities + var decisionContext = new A2ARunDecisionContext(context); + var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false); + + var options = context.Metadata is not { Count: > 0 } + ? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses } + : new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() }; + + var response = await this._hostAgent.RunAsync( + chatMessages, + session: session, + options: options, + cancellationToken: cancellationToken).ConfigureAwait(false); + + await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); + + if (response.ContinuationToken is null) + { + // Return a lightweight message response (no task lifecycle needed). + var message = CreateMessageFromResponse(contextId, response); + await eventQueue.EnqueueMessageAsync(message, cancellationToken).ConfigureAwait(false); + } + else + { + // Long-running operation: emit task lifecycle events. + var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId); + await taskUpdater.SubmitAsync(cancellationToken).ConfigureAwait(false); + + Message? progressMessage = response.Messages.Count > 0 + ? CreateMessageFromResponse(contextId, response) + : null; + + await taskUpdater.StartWorkAsync(progressMessage, cancellationToken).ConfigureAwait(false); + } + } + + private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + var contextId = context.ContextId ?? Guid.NewGuid().ToString("N"); + var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); + + List chatMessages = ExtractChatMessagesFromTaskHistory(context.Task); + + var decisionContext = new A2ARunDecisionContext(context); + var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false); + + var options = context.Metadata is not { Count: > 0 } + ? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses } + : new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() }; + + AgentResponse response; + try + { + response = await this._hostAgent.RunAsync( + chatMessages, + session: session, + options: options, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + var failUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId); + await failUpdater.FailAsync(message: null, cancellationToken).ConfigureAwait(false); + throw; + } + + await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); + + if (response.ContinuationToken is null) + { + // Complete the task with an artifact containing the response. + var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId); + await taskUpdater.AddArtifactAsync(response.Messages.ToParts(), cancellationToken: cancellationToken).ConfigureAwait(false); + await taskUpdater.CompleteAsync(message: null, cancellationToken).ConfigureAwait(false); + } + else + { + // Still working: emit progress status. + var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId); + + Message? progressMessage = response.Messages.Count > 0 + ? CreateMessageFromResponse(contextId, response) + : null; + + await taskUpdater.StartWorkAsync(progressMessage, cancellationToken).ConfigureAwait(false); + } + } + + private static Message CreateMessageFromResponse(string contextId, AgentResponse response) => + new() + { + MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"), + ContextId = contextId, + Role = Role.Agent, + Parts = response.Messages.ToParts(), + Metadata = response.AdditionalProperties?.ToA2AMetadata() + }; + + private static List ExtractChatMessagesFromTaskHistory(AgentTask? agentTask) + { + if (agentTask?.History is not { Count: > 0 }) + { + return []; + } + + var chatMessages = new List(agentTask.History.Count); + foreach (var message in agentTask.History) + { + chatMessages.Add(message.ToChatMessage()); + } + + return chatMessages; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AHostingOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AHostingOptions.cs new file mode 100644 index 0000000000..ac12fb5cec --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AHostingOptions.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using A2A; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Hosting.A2A; + +/// +/// Options for configuring A2A endpoint hosting behavior. +/// +[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] +public sealed class A2AHostingOptions +{ + /// + /// Gets or sets the A2A protocol binding(s) to expose. + /// + /// + /// When , defaults to . + /// Use the bitwise OR operator to enable multiple bindings + /// (e.g., A2AProtocolBinding.HttpJson | A2AProtocolBinding.JsonRpc). + /// + public A2AProtocolBinding? ProtocolBindings { get; set; } + + /// + /// Gets or sets the agent run mode that controls how the agent responds to A2A requests. + /// + /// + /// When , defaults to . + /// + public AgentRunMode? AgentRunMode { get; set; } + + /// + /// Gets or sets the A2A server options used to configure the underlying . + /// + /// + /// When , no custom server options are applied. + /// + public A2AServerOptions? ServerOptions { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AProtocolBinding.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AProtocolBinding.cs new file mode 100644 index 0000000000..ad7cd32870 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AProtocolBinding.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Hosting.A2A; + +/// +/// Specifies which A2A protocol binding(s) to expose when mapping A2A endpoints. +/// +/// +/// This is a flags enum. Combine values using the bitwise OR operator to enable multiple bindings +/// (e.g., A2AProtocolBinding.HttpJson | A2AProtocolBinding.JsonRpc). +/// +[Flags] +[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] +public enum A2AProtocolBinding +{ + /// + /// Expose the agent via the HTTP+JSON/REST protocol binding. + /// + HttpJson = 1, + + /// + /// Expose the agent via the JSON-RPC protocol binding. + /// + JsonRpc = 2, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs index 6ff49f6ecb..3e78afea8c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs @@ -9,13 +9,13 @@ namespace Microsoft.Agents.AI.Hosting.A2A; /// public sealed class A2ARunDecisionContext { - internal A2ARunDecisionContext(MessageSendParams messageSendParams) + internal A2ARunDecisionContext(RequestContext requestContext) { - this.MessageSendParams = messageSendParams; + this.RequestContext = requestContext; } /// - /// Gets the parameters of the incoming A2A message that triggered this run. + /// Gets the request context of the incoming A2A request that triggered this run. /// - public MessageSendParams MessageSendParams { get; } + public RequestContext RequestContext { get; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index 31c520755f..bcecd26fbc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -1,15 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; using A2A; -using Microsoft.Agents.AI.Hosting.A2A.Converters; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI.Hosting.A2A; @@ -20,27 +13,18 @@ namespace Microsoft.Agents.AI.Hosting.A2A; [Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] public static class AIAgentExtensions { - // Metadata key used to store continuation tokens for long-running background operations - // in the AgentTask.Metadata dictionary, persisted by the task store. - private const string ContinuationTokenMetadataKey = "__a2a__continuationToken"; - /// - /// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified . + /// Creates an that bridges the specified to + /// the A2A (Agent2Agent) protocol. /// /// Agent to attach A2A messaging processing capabilities to. - /// Instance of to configure for A2A messaging. New instance will be created if not passed. - /// The logger factory to use for creating instances. /// The store to store session contents and metadata. /// Controls the response behavior of the agent run. - /// Optional for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to if not provided. - /// The configured . - public static ITaskManager MapA2A( + /// An that handles A2A message execution and cancellation. + public static IAgentHandler MapA2A( this AIAgent agent, - ITaskManager? taskManager = null, - ILoggerFactory? loggerFactory = null, AgentSessionStore? agentSessionStore = null, - AgentRunMode? runMode = null, - JsonSerializerOptions? jsonSerializerOptions = null) + AgentRunMode? runMode = null) { ArgumentNullException.ThrowIfNull(agent); ArgumentNullException.ThrowIfNull(agent.Name); @@ -49,261 +33,8 @@ public static ITaskManager MapA2A( var hostAgent = new AIHostAgent( innerAgent: agent, - sessionStore: agentSessionStore ?? new NoopAgentSessionStore()); - - taskManager ??= new TaskManager(); - - // Resolve the JSON serializer options for continuation token serialization. May be custom for the user's agent. - JsonSerializerOptions continuationTokenJsonOptions = jsonSerializerOptions ?? A2AHostingJsonUtilities.DefaultOptions; - - // OnMessageReceived handles both message-only and task-based flows. - // The A2A SDK prioritizes OnMessageReceived over OnTaskCreated when both are set, - // so we consolidate all initial message handling here and return either - // an AgentMessage or AgentTask depending on the agent response. - // When the agent returns a ContinuationToken (long-running operation), a task is - // created for stateful tracking. Otherwise a lightweight AgentMessage is returned. - // See https://github.com/a2aproject/a2a-dotnet/issues/275 - taskManager.OnMessageReceived += (p, ct) => OnMessageReceivedAsync(p, hostAgent, runMode, taskManager, continuationTokenJsonOptions, ct); - - // Task flow for subsequent updates and cancellations - taskManager.OnTaskUpdated += (t, ct) => OnTaskUpdatedAsync(t, hostAgent, taskManager, continuationTokenJsonOptions, ct); - taskManager.OnTaskCancelled += OnTaskCancelledAsync; - - return taskManager; - } - - /// - /// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified . - /// - /// Agent to attach A2A messaging processing capabilities to. - /// The agent card to return on query. - /// Instance of to configure for A2A messaging. New instance will be created if not passed. - /// The logger factory to use for creating instances. - /// The store to store session contents and metadata. - /// Controls the response behavior of the agent run. - /// Optional for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to if not provided. - /// The configured . - public static ITaskManager MapA2A( - this AIAgent agent, - AgentCard agentCard, - ITaskManager? taskManager = null, - ILoggerFactory? loggerFactory = null, - AgentSessionStore? agentSessionStore = null, - AgentRunMode? runMode = null, - JsonSerializerOptions? jsonSerializerOptions = null) - { - taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore, runMode, jsonSerializerOptions); - - taskManager.OnAgentCardQuery += (context, query) => - { - // A2A SDK assigns the url on its own - // we can help user if they did not set Url explicitly. - if (string.IsNullOrEmpty(agentCard.Url)) - { - agentCard.Url = context.TrimEnd('/'); - } - - return Task.FromResult(agentCard); - }; - return taskManager; - } - - private static async Task OnMessageReceivedAsync( - MessageSendParams messageSendParams, - AIHostAgent hostAgent, - AgentRunMode runMode, - ITaskManager taskManager, - JsonSerializerOptions continuationTokenJsonOptions, - CancellationToken cancellationToken) - { - // AIAgent does not support resuming from arbitrary prior tasks. - // Throw explicitly so the client gets a clear error rather than a response - // that silently ignores the referenced task context. - // Follow-ups on the *same* task are handled via OnTaskUpdated instead. - if (messageSendParams.Message.ReferenceTaskIds is { Count: > 0 }) - { - throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context. Use OnTaskUpdated for follow-ups on the same task."); - } - - var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N"); - var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); - - // Decide whether to run in background based on user preferences and agent capabilities - var decisionContext = new A2ARunDecisionContext(messageSendParams); - var allowBackgroundResponses = await runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false); - - var options = messageSendParams.Metadata is not { Count: > 0 } - ? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses } - : new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() }; - - var response = await hostAgent.RunAsync( - messageSendParams.ToChatMessages(), - session: session, - options: options, - cancellationToken: cancellationToken).ConfigureAwait(false); - - await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); - - if (response.ContinuationToken is null) - { - return CreateMessageFromResponse(contextId, response); - } - - var agentTask = await InitializeTaskAsync(contextId, messageSendParams.Message, taskManager, cancellationToken).ConfigureAwait(false); - StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions); - await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false); - return agentTask; - } - - private static async Task OnTaskUpdatedAsync( - AgentTask agentTask, - AIHostAgent hostAgent, - ITaskManager taskManager, - JsonSerializerOptions continuationTokenJsonOptions, - CancellationToken cancellationToken) - { - var contextId = agentTask.ContextId ?? Guid.NewGuid().ToString("N"); - var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); - - try - { - // Discard any stale continuation token — the incoming user message supersedes - // any previous background operation. AF agents don't support updating existing - // background responses (long-running operations); we start a fresh run from the - // existing session using the full chat history (which includes the new message). - agentTask.Metadata?.Remove(ContinuationTokenMetadataKey); - - await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false); - - var response = await hostAgent.RunAsync( - ExtractChatMessagesFromTaskHistory(agentTask), - session: session, - options: new AgentRunOptions { AllowBackgroundResponses = true }, - cancellationToken: cancellationToken).ConfigureAwait(false); - - await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); - - if (response.ContinuationToken is not null) - { - StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions); - await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false); - } - else - { - await CompleteWithArtifactAsync(agentTask.Id, response, taskManager, cancellationToken).ConfigureAwait(false); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception) - { - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Failed, - final: true, - cancellationToken: cancellationToken).ConfigureAwait(false); - throw; - } - } - - private static Task OnTaskCancelledAsync(AgentTask agentTask, CancellationToken cancellationToken) - { - // Remove the continuation token from metadata if present. - // The task has already been marked as cancelled by the TaskManager. - agentTask.Metadata?.Remove(ContinuationTokenMetadataKey); - return Task.CompletedTask; - } - - private static AgentMessage CreateMessageFromResponse(string contextId, AgentResponse response) => - new() - { - MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"), - ContextId = contextId, - Role = MessageRole.Agent, - Parts = response.Messages.ToParts(), - Metadata = response.AdditionalProperties?.ToA2AMetadata() - }; - - // Task outputs should be returned as artifacts rather than messages: - // https://a2a-protocol.org/latest/specification/#37-messages-and-artifacts - private static Artifact CreateArtifactFromResponse(AgentResponse response) => - new() - { - ArtifactId = response.ResponseId ?? Guid.NewGuid().ToString("N"), - Parts = response.Messages.ToParts(), - Metadata = response.AdditionalProperties?.ToA2AMetadata() - }; - - private static async Task InitializeTaskAsync( - string contextId, - AgentMessage originalMessage, - ITaskManager taskManager, - CancellationToken cancellationToken) - { - AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false); - - // Add the original user message to the task history. - // The A2A SDK does this internally when it creates tasks via OnTaskCreated. - agentTask.History ??= []; - agentTask.History.Add(originalMessage); - - // Notify subscribers of the Submitted state per the A2A spec: https://a2a-protocol.org/latest/specification/#413-taskstate - await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Submitted, cancellationToken: cancellationToken).ConfigureAwait(false); - - return agentTask; - } - - private static void StoreContinuationToken( - AgentTask agentTask, - ResponseContinuationToken token, - JsonSerializerOptions continuationTokenJsonOptions) - { - // Serialize the continuation token into the task's metadata so it survives - // across requests and is cleaned up with the task itself. - agentTask.Metadata ??= []; - agentTask.Metadata[ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement( - token, - continuationTokenJsonOptions.GetTypeInfo(typeof(ResponseContinuationToken))); - } - - private static async Task TransitionToWorkingAsync( - string taskId, - string contextId, - AgentResponse response, - ITaskManager taskManager, - CancellationToken cancellationToken) - { - // Include any intermediate progress messages from the response as a status message. - AgentMessage? progressMessage = response.Messages.Count > 0 ? CreateMessageFromResponse(contextId, response) : null; - await taskManager.UpdateStatusAsync(taskId, TaskState.Working, message: progressMessage, cancellationToken: cancellationToken).ConfigureAwait(false); - } - - private static async Task CompleteWithArtifactAsync( - string taskId, - AgentResponse response, - ITaskManager taskManager, - CancellationToken cancellationToken) - { - var artifact = CreateArtifactFromResponse(response); - await taskManager.ReturnArtifactAsync(taskId, artifact, cancellationToken).ConfigureAwait(false); - await taskManager.UpdateStatusAsync(taskId, TaskState.Completed, final: true, cancellationToken: cancellationToken).ConfigureAwait(false); - } - - private static List ExtractChatMessagesFromTaskHistory(AgentTask agentTask) - { - if (agentTask.History is not { Count: > 0 }) - { - return []; - } - - var chatMessages = new List(agentTask.History.Count); - foreach (var message in agentTask.History) - { - chatMessages.Add(message.ToChatMessage()); - } + sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore()); - return chatMessages; + return new A2AAgentHandler(hostAgent, runMode); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs index 087df96aae..094a5156c0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs @@ -28,7 +28,7 @@ private AgentRunMode(string value, Func - /// Dissallows the background responses from the agent. Is equivalent to configuring as false. + /// Disallows the background responses from the agent. Is equivalent to configuring as false. /// In the A2A protocol terminology will make responses be returned as AgentMessage. /// public static AgentRunMode DisallowBackground => new(MessageValue); @@ -79,7 +79,7 @@ internal ValueTask ShouldRunInBackgroundAsync(A2ARunDecisionContext contex } // No delegate provided — fall back to "message" behavior. - return ValueTask.FromResult(true); + return ValueTask.FromResult(false); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs index 5d2381a235..b2f57fc09e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs @@ -31,21 +31,21 @@ public static List ToParts(this IList chatMessages) return parts; } /// - /// Converts A2A MessageSendParams to a collection of Microsoft.Extensions.AI ChatMessage objects. + /// Converts A2A SendMessageRequest to a collection of Microsoft.Extensions.AI ChatMessage objects. /// - /// The A2A message send parameters to convert. + /// The A2A send message request to convert. /// A read-only collection of ChatMessage objects. - public static List ToChatMessages(this MessageSendParams messageSendParams) + public static List ToChatMessages(this SendMessageRequest sendMessageRequest) { - if (messageSendParams is null) + if (sendMessageRequest is null) { return []; } var result = new List(); - if (messageSendParams.Message?.Parts is not null) + if (sendMessageRequest.Message?.Parts is not null) { - result.Add(messageSendParams.Message.ToChatMessage()); + result.Add(sendMessageRequest.Message.ToChatMessage()); } return result; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs similarity index 55% rename from dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs index a848528888..783fecea96 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using A2A; using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.AI; @@ -10,9 +9,9 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; /// -/// Tests for MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions.MapA2A method. +/// Tests for A2AEndpointRouteBuilderExtensions.MapA2A method. /// -public sealed class EndpointRouteA2ABuilderExtensionsTests +public sealed class A2AEndpointRouteBuilderExtensionsTests { /// /// Verifies that MapA2A throws ArgumentNullException for null endpoints. @@ -57,7 +56,7 @@ public void MapA2A_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException } /// - /// Verifies that MapA2A with IHostedAgentBuilder correctly maps the agent with default task manager configuration. + /// Verifies that MapA2A with IHostedAgentBuilder correctly maps the agent with default configuration. /// [Fact] public void MapA2A_WithAgentBuilder_DefaultConfiguration_Succeeds() @@ -73,14 +72,13 @@ public void MapA2A_WithAgentBuilder_DefaultConfiguration_Succeeds() // Act & Assert - Should not throw var result = app.MapA2A(agentBuilder, "/a2a"); Assert.NotNull(result); - Assert.NotNull(app); } /// - /// Verifies that MapA2A with IHostedAgentBuilder and custom task manager configuration succeeds. + /// Verifies that MapA2A with IHostedAgentBuilder and custom A2AHostingOptions succeeds. /// [Fact] - public void MapA2A_WithAgentBuilder_CustomTaskManagerConfiguration_Succeeds() + public void MapA2A_WithAgentBuilder_CustomA2AHostingOptionsConfiguration_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); @@ -91,84 +89,85 @@ public void MapA2A_WithAgentBuilder_CustomTaskManagerConfiguration_Succeeds() using WebApplication app = builder.Build(); // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", taskManager => { }); + var result = app.MapA2A(agentBuilder, "/a2a", options => { }); Assert.NotNull(result); - Assert.NotNull(app); } /// - /// Verifies that MapA2A with IHostedAgentBuilder and agent card succeeds. + /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using string agent name. /// [Fact] - public void MapA2A_WithAgentBuilder_WithAgentCard_Succeeds() + public void MapA2A_WithAgentName_NullEndpoints_ThrowsArgumentNullException() + { + // Arrange + AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + endpoints.MapA2A("agent", "/a2a")); + + Assert.Equal("endpoints", exception.ParamName); + } + + /// + /// Verifies that MapA2A with string agent name correctly maps the agent. + /// + [Fact] + public void MapA2A_WithAgentName_DefaultConfiguration_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", agentCard); + var result = app.MapA2A("agent", "/a2a"); Assert.NotNull(result); - Assert.NotNull(app); } /// - /// Verifies that MapA2A with IHostedAgentBuilder, agent card, and custom task manager configuration succeeds. + /// Verifies that MapA2A with string agent name and custom A2AHostingOptions succeeds. /// [Fact] - public void MapA2A_WithAgentBuilder_WithAgentCardAndCustomConfiguration_Succeeds() + public void MapA2A_WithAgentName_CustomA2AHostingOptionsConfiguration_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", agentCard, taskManager => { }); + var result = app.MapA2A("agent", "/a2a", options => { }); Assert.NotNull(result); - Assert.NotNull(app); } /// - /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using string agent name. + /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using AIAgent. /// [Fact] - public void MapA2A_WithAgentName_NullEndpoints_ThrowsArgumentNullException() + public void MapA2A_WithAIAgent_NullEndpoints_ThrowsArgumentNullException() { // Arrange AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; // Act & Assert ArgumentNullException exception = Assert.Throws(() => - endpoints.MapA2A("agent", "/a2a")); + endpoints.MapA2A((AIAgent)null!, "/a2a")); Assert.Equal("endpoints", exception.ParamName); } /// - /// Verifies that MapA2A with string agent name correctly maps the agent. + /// Verifies that MapA2A with AIAgent correctly maps the agent. /// [Fact] - public void MapA2A_WithAgentName_DefaultConfiguration_Succeeds() + public void MapA2A_WithAIAgent_DefaultConfiguration_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); @@ -177,18 +176,18 @@ public void MapA2A_WithAgentName_DefaultConfiguration_Succeeds() builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); + AIAgent agent = app.Services.GetRequiredKeyedService("agent"); // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a"); + var result = app.MapA2A(agent, "/a2a"); Assert.NotNull(result); - Assert.NotNull(app); } /// - /// Verifies that MapA2A with string agent name and custom task manager configuration succeeds. + /// Verifies that MapA2A with AIAgent and custom A2AHostingOptions succeeds. /// [Fact] - public void MapA2A_WithAgentName_CustomTaskManagerConfiguration_Succeeds() + public void MapA2A_WithAIAgent_CustomA2AHostingOptionsConfiguration_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); @@ -197,86 +196,218 @@ public void MapA2A_WithAgentName_CustomTaskManagerConfiguration_Succeeds() builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); + AIAgent agent = app.Services.GetRequiredKeyedService("agent"); // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a", taskManager => { }); + var result = app.MapA2A(agent, "/a2a", options => { }); Assert.NotNull(result); - Assert.NotNull(app); } /// - /// Verifies that MapA2A with string agent name and agent card succeeds. + /// Verifies that MapA2A with IHostedAgentBuilder and A2AHostingOptions with AgentRunMode succeeds. /// [Fact] - public void MapA2A_WithAgentName_WithAgentCard_Succeeds() + public void MapA2A_WithAgentBuilder_CustomOptionsAndRunMode_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; + // Act & Assert - Should not throw + var result = app.MapA2A(agentBuilder, "/a2a", options => options.AgentRunMode = AgentRunMode.DisallowBackground); + Assert.NotNull(result); + } + + /// + /// Verifies that MapA2A with string agentName and A2AHostingOptions with AgentRunMode succeeds. + /// + [Fact] + public void MapA2A_WithAgentName_CustomOptionsAndRunMode_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a", agentCard); + var result = app.MapA2A("agent", "/a2a", options => options.AgentRunMode = AgentRunMode.DisallowBackground); Assert.NotNull(result); + } + + /// + /// Verifies that multiple agents can be mapped to different paths. + /// + [Fact] + public void MapA2A_MultipleAgents_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agent1Builder = builder.AddAIAgent("agent1", "Instructions1", chatClientServiceKey: "chat-client"); + IHostedAgentBuilder agent2Builder = builder.AddAIAgent("agent2", "Instructions2", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + app.MapA2A(agent1Builder, "/a2a/agent1"); + app.MapA2A(agent2Builder, "/a2a/agent2"); Assert.NotNull(app); } /// - /// Verifies that MapA2A with string agent name, agent card, and custom task manager configuration succeeds. + /// Verifies that custom paths can be specified for A2A endpoints. /// [Fact] - public void MapA2A_WithAgentName_WithAgentCardAndCustomConfiguration_Succeeds() + public void MapA2A_WithCustomPath_AcceptsValidPath() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + app.MapA2A(agentBuilder, "/custom/a2a/path"); + Assert.NotNull(app); + } + + /// + /// Verifies that A2AHostingOptions configuration callback is invoked correctly. + /// + [Fact] + public void MapA2A_WithAgentBuilder_A2AHostingOptionsConfigurationCallbackInvoked() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); - var agentCard = new AgentCard + bool configureCallbackInvoked = false; + + // Act + app.MapA2A(agentBuilder, "/a2a", options => { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; + configureCallbackInvoked = true; + Assert.NotNull(options); + }); + + // Assert + Assert.True(configureCallbackInvoked); + } + + /// + /// Verifies that MapA2A with JsonRpc protocolBindings succeeds. + /// + [Fact] + public void MapA2A_WithJsonRpcProtocol_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a", agentCard, taskManager => { }); + var result = app.MapA2A(agentBuilder, "/a2a", options => options.ProtocolBindings = A2AProtocolBinding.JsonRpc); Assert.NotNull(result); - Assert.NotNull(app); } /// - /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using AIAgent. + /// Verifies that MapA2A with both protocols succeeds. /// [Fact] - public void MapA2A_WithAIAgent_NullEndpoints_ThrowsArgumentNullException() + public void MapA2A_WithBothProtocols_Succeeds() { // Arrange - AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); - // Act & Assert - ArgumentNullException exception = Assert.Throws(() => - endpoints.MapA2A((AIAgent)null!, "/a2a")); + // Act & Assert - Should not throw + var result = app.MapA2A(agentBuilder, "/a2a", options => options.ProtocolBindings = A2AProtocolBinding.HttpJson | A2AProtocolBinding.JsonRpc); + Assert.NotNull(result); + } - Assert.Equal("endpoints", exception.ParamName); + /// + /// Verifies that MapA2A with IHostedAgentBuilder and direct protocolBindings parameter succeeds. + /// + [Fact] + public void MapA2A_WithAgentBuilder_DirectProtocol_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + var result = app.MapA2A(agentBuilder, "/a2a", A2AProtocolBinding.HttpJson); + Assert.NotNull(result); } /// - /// Verifies that MapA2A with AIAgent correctly maps the agent. + /// Verifies that MapA2A with IHostedAgentBuilder and direct protocolBindings and run mode parameters succeeds. /// [Fact] - public void MapA2A_WithAIAgent_DefaultConfiguration_Succeeds() + public void MapA2A_WithAgentBuilder_DirectProtocolAndRunMode_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + var result = app.MapA2A(agentBuilder, "/a2a", A2AProtocolBinding.HttpJson, AgentRunMode.AllowBackgroundIfSupported); + Assert.NotNull(result); + } + + /// + /// Verifies that MapA2A with IHostedAgentBuilder, null protocolBindings, and direct run mode parameter succeeds. + /// + [Fact] + public void MapA2A_WithAgentBuilder_NullProtocolAndDirectRunMode_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + var result = app.MapA2A(agentBuilder, "/a2a", protocolBindings: null, agentRunMode: AgentRunMode.DisallowBackground); + Assert.NotNull(result); + } + + /// + /// Verifies that MapA2A with string agent name and direct protocolBindings parameter succeeds. + /// + [Fact] + public void MapA2A_WithAgentName_DirectProtocol_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); @@ -285,19 +416,17 @@ public void MapA2A_WithAIAgent_DefaultConfiguration_Succeeds() builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); - AIAgent agent = app.Services.GetRequiredKeyedService("agent"); // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a"); + var result = app.MapA2A("agent", "/a2a", A2AProtocolBinding.JsonRpc); Assert.NotNull(result); - Assert.NotNull(app); } /// - /// Verifies that MapA2A with AIAgent and custom task manager configuration succeeds. + /// Verifies that MapA2A with string agent name and direct protocolBindings and run mode parameters succeeds. /// [Fact] - public void MapA2A_WithAIAgent_CustomTaskManagerConfiguration_Succeeds() + public void MapA2A_WithAgentName_DirectProtocolAndRunMode_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); @@ -306,19 +435,17 @@ public void MapA2A_WithAIAgent_CustomTaskManagerConfiguration_Succeeds() builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); - AIAgent agent = app.Services.GetRequiredKeyedService("agent"); // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a", taskManager => { }); + var result = app.MapA2A("agent", "/a2a", A2AProtocolBinding.HttpJson, AgentRunMode.AllowBackgroundIfSupported); Assert.NotNull(result); - Assert.NotNull(app); } /// - /// Verifies that MapA2A with AIAgent and agent card succeeds. + /// Verifies that MapA2A with AIAgent and direct protocolBindings parameter succeeds. /// [Fact] - public void MapA2A_WithAIAgent_WithAgentCard_Succeeds() + public void MapA2A_WithAIAgent_DirectProtocol_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); @@ -329,23 +456,16 @@ public void MapA2A_WithAIAgent_WithAgentCard_Succeeds() using WebApplication app = builder.Build(); AIAgent agent = app.Services.GetRequiredKeyedService("agent"); - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; - // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a", agentCard); + var result = app.MapA2A(agent, "/a2a", A2AProtocolBinding.HttpJson); Assert.NotNull(result); - Assert.NotNull(app); } /// - /// Verifies that MapA2A with AIAgent, agent card, and custom task manager configuration succeeds. + /// Verifies that MapA2A with AIAgent and direct protocolBindings and run mode parameters succeeds. /// [Fact] - public void MapA2A_WithAIAgent_WithAgentCardAndCustomConfiguration_Succeeds() + public void MapA2A_WithAIAgent_DirectProtocolAndRunMode_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); @@ -356,124 +476,140 @@ public void MapA2A_WithAIAgent_WithAgentCardAndCustomConfiguration_Succeeds() using WebApplication app = builder.Build(); AIAgent agent = app.Services.GetRequiredKeyedService("agent"); - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; + // Act & Assert - Should not throw + var result = app.MapA2A(agent, "/a2a", A2AProtocolBinding.HttpJson, AgentRunMode.AllowBackgroundIfSupported); + Assert.NotNull(result); + } + + /// + /// Verifies that MapA2A with AIAgent, null protocolBindings, and direct run mode defaults correctly. + /// + [Fact] + public void MapA2A_WithAIAgent_NullProtocolAndDirectRunMode_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + AIAgent agent = app.Services.GetRequiredKeyedService("agent"); // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a", agentCard, taskManager => { }); + var result = app.MapA2A(agent, "/a2a", protocolBindings: null, agentRunMode: AgentRunMode.DisallowBackground); Assert.NotNull(result); - Assert.NotNull(app); } /// - /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using ITaskManager. + /// Verifies that MapA2A throws ArgumentNullException for null agentName (string overload with configureOptions). /// [Fact] - public void MapA2A_WithTaskManager_NullEndpoints_ThrowsArgumentNullException() + public void MapA2A_WithAgentName_NullAgentName_ThrowsArgumentNullException() { // Arrange - AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; - ITaskManager taskManager = null!; + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); // Act & Assert ArgumentNullException exception = Assert.Throws(() => - endpoints.MapA2A(taskManager, "/a2a")); + app.MapA2A((string)null!, "/a2a")); - Assert.Equal("endpoints", exception.ParamName); + Assert.Equal("agentName", exception.ParamName); } /// - /// Verifies that multiple agents can be mapped to different paths. + /// Verifies that MapA2A throws ArgumentNullException for null agentName (string overload with protocolBindings). /// [Fact] - public void MapA2A_MultipleAgents_Succeeds() + public void MapA2A_WithAgentName_NullAgentName_ProtocolOverload_ThrowsArgumentNullException() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agent1Builder = builder.AddAIAgent("agent1", "Instructions1", chatClientServiceKey: "chat-client"); - IHostedAgentBuilder agent2Builder = builder.AddAIAgent("agent2", "Instructions2", chatClientServiceKey: "chat-client"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); - // Act & Assert - Should not throw - app.MapA2A(agent1Builder, "/a2a/agent1"); - app.MapA2A(agent2Builder, "/a2a/agent2"); - Assert.NotNull(app); + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + app.MapA2A((string)null!, "/a2a", A2AProtocolBinding.HttpJson)); + + Assert.Equal("agentName", exception.ParamName); } /// - /// Verifies that custom paths can be specified for A2A endpoints. + /// Verifies that MapA2A throws ArgumentException for empty agentName (string overload with configureOptions). /// [Fact] - public void MapA2A_WithCustomPath_AcceptsValidPath() + public void MapA2A_WithAgentName_EmptyAgentName_ThrowsArgumentException() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); - // Act & Assert - Should not throw - app.MapA2A(agentBuilder, "/custom/a2a/path"); - Assert.NotNull(app); + // Act & Assert + ArgumentException exception = Assert.Throws(() => + app.MapA2A(string.Empty, "/a2a")); + + Assert.Equal("agentName", exception.ParamName); } /// - /// Verifies that task manager configuration callback is invoked correctly. + /// Verifies that MapA2A throws ArgumentException for empty agentName (string overload with protocolBindings). /// [Fact] - public void MapA2A_WithAgentBuilder_TaskManagerConfigurationCallbackInvoked() + public void MapA2A_WithAgentName_EmptyAgentName_ProtocolOverload_ThrowsArgumentException() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); - bool configureCallbackInvoked = false; - - // Act - app.MapA2A(agentBuilder, "/a2a", taskManager => - { - configureCallbackInvoked = true; - Assert.NotNull(taskManager); - }); + // Act & Assert + ArgumentException exception = Assert.Throws(() => + app.MapA2A(string.Empty, "/a2a", A2AProtocolBinding.HttpJson)); - // Assert - Assert.True(configureCallbackInvoked); + Assert.Equal("agentName", exception.ParamName); } /// - /// Verifies that agent card with all properties is accepted. + /// Verifies that MapA2A throws ArgumentException for null path. /// [Fact] - public void MapA2A_WithAgentBuilder_FullAgentCard_Succeeds() + public void MapA2A_WithAIAgent_NullPath_ThrowsArgumentException() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); + AIAgent agent = app.Services.GetRequiredKeyedService("agent"); - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A comprehensive test agent" - }; + // Act & Assert + Assert.Throws(() => + app.MapA2A(agent, null!)); + } - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", agentCard); - Assert.NotNull(result); + /// + /// Verifies that MapA2A throws ArgumentException for whitespace-only path. + /// + [Fact] + public void MapA2A_WithAIAgent_WhitespacePath_ThrowsArgumentException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + AIAgent agent = app.Services.GetRequiredKeyedService("agent"); + + // Act & Assert + Assert.Throws(() => + app.MapA2A(agent, " ")); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AIntegrationTests.cs deleted file mode 100644 index f8604c7eac..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AIntegrationTests.cs +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Text.Json; -using System.Threading.Tasks; -using A2A; -using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; - -namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; - -public sealed class A2AIntegrationTests -{ - /// - /// Verifies that calling the A2A card endpoint with MapA2A returns an agent card with a URL populated. - /// - [Fact] - public async Task MapA2A_WithAgentCard_CardEndpointReturnsCardWithUrlAsync() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("test-agent", "Test instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - - using WebApplication app = builder.Build(); - - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication", - Version = "1.0" - }; - - // Map A2A with the agent card - app.MapA2A(agentBuilder, "/a2a/test-agent", agentCard); - - await app.StartAsync(); - - try - { - // Get the test server client - TestServer testServer = app.Services.GetRequiredService() as TestServer - ?? throw new InvalidOperationException("TestServer not found"); - var httpClient = testServer.CreateClient(); - - // Act - Query the agent card endpoint - var requestUri = new Uri("/a2a/test-agent/v1/card", UriKind.Relative); - var response = await httpClient.GetAsync(requestUri); - - // Assert - Assert.True(response.IsSuccessStatusCode, $"Expected successful response but got {response.StatusCode}"); - - var content = await response.Content.ReadAsStringAsync(); - var jsonDoc = JsonDocument.Parse(content); - var root = jsonDoc.RootElement; - - // Verify the card has expected properties - Assert.True(root.TryGetProperty("name", out var nameProperty)); - Assert.Equal("Test Agent", nameProperty.GetString()); - - Assert.True(root.TryGetProperty("description", out var descProperty)); - Assert.Equal("A test agent for A2A communication", descProperty.GetString()); - - // Verify the card has a URL property and it's not null/empty - Assert.True(root.TryGetProperty("url", out var urlProperty)); - Assert.NotEqual(JsonValueKind.Null, urlProperty.ValueKind); - - var url = urlProperty.GetString(); - Assert.NotNull(url); - Assert.NotEmpty(url); - Assert.StartsWith("http", url, StringComparison.OrdinalIgnoreCase); - - // agentCard's URL matches the agent endpoint - Assert.Equal($"{testServer.BaseAddress.ToString().TrimEnd('/')}/a2a/test-agent", url); - } - finally - { - await app.StopAsync(); - } - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs index 87de6e52cd..c472ea2d01 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.Runtime.CompilerServices; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using A2A; @@ -19,74 +18,46 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; public sealed class AIAgentExtensionsTests { /// - /// Verifies that when messageSendParams.Metadata is null, the options passed to RunAsync have - /// AllowBackgroundResponses enabled and no AdditionalProperties. + /// Verifies that MapA2A throws ArgumentNullException for null agent. /// [Fact] - public async Task MapA2A_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropertiesToRunAsync() + public void MapA2A_NullAgent_ThrowsArgumentNullException() { // Arrange - AgentRunOptions? capturedOptions = null; - ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A(); - - // Act - await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }, - Metadata = null - }); + AIAgent agent = null!; - // Assert - Assert.NotNull(capturedOptions); - Assert.False(capturedOptions.AllowBackgroundResponses); - Assert.Null(capturedOptions.AdditionalProperties); + // Act & Assert + Assert.Throws(() => agent.MapA2A()); } /// - /// Verifies that when messageSendParams.Metadata has values, the options.AdditionalProperties contains the converted values. + /// Verifies that MapA2A returns a non-null IAgentHandler. /// [Fact] - public async Task MapA2A_WhenMetadataHasValues_PassesOptionsWithAdditionalPropertiesToRunAsync() + public void MapA2A_ValidAgent_ReturnsNonNullHandler() { - // Arrange - AgentRunOptions? capturedOptions = null; - ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A(); - - // Act - await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }, - Metadata = new Dictionary - { - ["key1"] = JsonSerializer.SerializeToElement("value1"), - ["key2"] = JsonSerializer.SerializeToElement(42) - } - }); + // Arrange & Act + IAgentHandler handler = CreateAgentMock(_ => { }).Object.MapA2A(); // Assert - Assert.NotNull(capturedOptions); - Assert.NotNull(capturedOptions.AdditionalProperties); - Assert.Equal(2, capturedOptions.AdditionalProperties.Count); - Assert.True(capturedOptions.AdditionalProperties.ContainsKey("key1")); - Assert.True(capturedOptions.AdditionalProperties.ContainsKey("key2")); + Assert.NotNull(handler); } /// - /// Verifies that when messageSendParams.Metadata is an empty dictionary, the options passed to RunAsync have - /// AllowBackgroundResponses enabled and no AdditionalProperties. + /// Verifies that when metadata is null, the options passed to RunAsync have + /// AllowBackgroundResponses disabled and no AdditionalProperties. /// [Fact] - public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesOptionsWithNoAdditionalPropertiesToRunAsync() + public async Task ExecuteAsync_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropertiesToRunAsync() { // Arrange AgentRunOptions? capturedOptions = null; - ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A(); + IAgentHandler handler = CreateAgentMock(options => capturedOptions = options).Object.MapA2A(); // Act - await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + await InvokeExecuteAsync(handler, new RequestContext { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }, - Metadata = [] + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } }); // Assert @@ -96,10 +67,10 @@ public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesOptionsWithNoAdditi } /// - /// Verifies that when the agent response has AdditionalProperties, the returned AgentMessage.Metadata contains the converted values. + /// Verifies that when the agent response has AdditionalProperties, the returned Message.Metadata contains the converted values. /// [Fact] - public async Task MapA2A_WhenResponseHasAdditionalProperties_ReturnsAgentMessageWithMetadataAsync() + public async Task ExecuteAsync_WhenResponseHasAdditionalProperties_ReturnsMessageWithMetadataAsync() { // Arrange AdditionalPropertiesDictionary additionalProps = new() @@ -111,652 +82,454 @@ public async Task MapA2A_WhenResponseHasAdditionalProperties_ReturnsAgentMessage { AdditionalProperties = additionalProps }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + var events = await CollectEventsAsync(handler, new RequestContext { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } }); // Assert - AgentMessage agentMessage = Assert.IsType(a2aResponse); - Assert.NotNull(agentMessage.Metadata); - Assert.Equal(2, agentMessage.Metadata.Count); - Assert.True(agentMessage.Metadata.ContainsKey("responseKey1")); - Assert.True(agentMessage.Metadata.ContainsKey("responseKey2")); - Assert.Equal("responseValue1", agentMessage.Metadata["responseKey1"].GetString()); - Assert.Equal(123, agentMessage.Metadata["responseKey2"].GetInt32()); + Message message = Assert.Single(events.Messages); + Assert.NotNull(message.Metadata); + Assert.Equal(2, message.Metadata.Count); + Assert.True(message.Metadata.ContainsKey("responseKey1")); + Assert.True(message.Metadata.ContainsKey("responseKey2")); } /// - /// Verifies that when the agent response has null AdditionalProperties, the returned AgentMessage.Metadata is null. + /// Verifies that when the agent response has null AdditionalProperties, the returned Message.Metadata is null. /// [Fact] - public async Task MapA2A_WhenResponseHasNullAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync() + public async Task ExecuteAsync_WhenResponseHasNullAdditionalProperties_ReturnsMessageWithNullMetadataAsync() { // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")]) { AdditionalProperties = null }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + var events = await CollectEventsAsync(handler, new RequestContext { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } }); // Assert - AgentMessage agentMessage = Assert.IsType(a2aResponse); - Assert.Null(agentMessage.Metadata); + Message message = Assert.Single(events.Messages); + Assert.Null(message.Metadata); } /// - /// Verifies that when the agent response has empty AdditionalProperties, the returned AgentMessage.Metadata is null. + /// Verifies that when the agent response has empty AdditionalProperties, the returned Message.Metadata is null. /// [Fact] - public async Task MapA2A_WhenResponseHasEmptyAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync() + public async Task ExecuteAsync_WhenResponseHasEmptyAdditionalProperties_ReturnsMessageWithNullMetadataAsync() { // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")]) { AdditionalProperties = [] }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + var events = await CollectEventsAsync(handler, new RequestContext { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } }); // Assert - AgentMessage agentMessage = Assert.IsType(a2aResponse); - Assert.Null(agentMessage.Metadata); + Message message = Assert.Single(events.Messages); + Assert.Null(message.Metadata); } /// - /// Verifies that when runMode is Message, the result is always an AgentMessage even when - /// the agent would otherwise support background responses. + /// Verifies that when runMode is DisallowBackground, AllowBackgroundResponses is false. /// [Fact] - public async Task MapA2A_MessageMode_AlwaysReturnsAgentMessageAsync() + public async Task ExecuteAsync_DisallowBackgroundMode_SetsAllowBackgroundResponsesFalseAsync() { // Arrange AgentRunOptions? capturedOptions = null; - ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options) + IAgentHandler handler = CreateAgentMock(options => capturedOptions = options) .Object.MapA2A(runMode: AgentRunMode.DisallowBackground); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + await InvokeExecuteAsync(handler, new RequestContext { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } }); // Assert - Assert.IsType(a2aResponse); Assert.NotNull(capturedOptions); Assert.False(capturedOptions.AllowBackgroundResponses); } /// - /// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken), - /// the result is an AgentMessage because the response type is determined solely by ContinuationToken presence. + /// Verifies that in AllowBackgroundIfSupported mode, AllowBackgroundResponses is true. /// [Fact] - public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageAsync() + public async Task ExecuteAsync_AllowBackgroundIfSupportedMode_SetsAllowBackgroundResponsesTrueAsync() { // Arrange AgentRunOptions? capturedOptions = null; - ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options) + IAgentHandler handler = CreateAgentMock(options => capturedOptions = options) .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + await InvokeExecuteAsync(handler, new RequestContext { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } }); // Assert - Assert.IsType(a2aResponse); Assert.NotNull(capturedOptions); Assert.True(capturedOptions.AllowBackgroundResponses); } /// - /// Verifies that a custom Dynamic delegate returning false produces an AgentMessage - /// even when the agent completes immediately (no ContinuationToken). + /// Verifies that a custom Dynamic delegate returning false sets AllowBackgroundResponses to false. /// [Fact] - public async Task MapA2A_DynamicMode_WithFalseCallback_ReturnsAgentMessageAsync() + public async Task ExecuteAsync_DynamicMode_WithFalseCallback_SetsAllowBackgroundResponsesFalseAsync() { // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Quick reply")]); - ITaskManager taskManager = CreateAgentMockWithResponse(response) + AgentRunOptions? capturedOptions = null; + IAgentHandler handler = CreateAgentMock(options => capturedOptions = options) .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false))); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + await InvokeExecuteAsync(handler, new RequestContext { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } }); // Assert - Assert.IsType(a2aResponse); + Assert.NotNull(capturedOptions); + Assert.False(capturedOptions.AllowBackgroundResponses); } -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - /// - /// Verifies that when the agent returns a ContinuationToken, an AgentTask in Working state is returned. + /// Verifies that a custom Dynamic delegate returning true sets AllowBackgroundResponses to true. /// [Fact] - public async Task MapA2A_WhenResponseHasContinuationToken_ReturnsAgentTaskInWorkingStateAsync() + public async Task ExecuteAsync_DynamicMode_WithTrueCallback_SetsAllowBackgroundResponsesTrueAsync() { // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + AgentRunOptions? capturedOptions = null; + IAgentHandler handler = CreateAgentMock(options => capturedOptions = options) + .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true))); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + await InvokeExecuteAsync(handler, new RequestContext { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } }); // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.Equal(TaskState.Working, agentTask.Status.State); + Assert.NotNull(capturedOptions); + Assert.True(capturedOptions.AllowBackgroundResponses); } - /// - /// Verifies that when the agent returns a ContinuationToken, the returned task includes - /// intermediate messages from the initial response in its status message. - /// - [Fact] - public async Task MapA2A_WhenResponseHasContinuationToken_TaskStatusHasIntermediateMessageAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - - // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.NotNull(agentTask.Status.Message); - TextPart textPart = Assert.IsType(Assert.Single(agentTask.Status.Message.Parts)); - Assert.Equal("Starting work...", textPart.Text); - } +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. /// - /// Verifies that when the agent returns a ContinuationToken, the continuation token - /// is serialized into the AgentTask.Metadata for persistence. + /// Verifies that when the agent returns a ContinuationToken, task status events are emitted. /// [Fact] - public async Task MapA2A_WhenResponseHasContinuationToken_StoresTokenInTaskMetadataAsync() + public async Task ExecuteAsync_WhenResponseHasContinuationToken_EmitsTaskStatusEventsAsync() { // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")]) { ContinuationToken = CreateTestContinuationToken() }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + var events = await CollectEventsAsync(handler, new RequestContext { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + StreamingResponse = false, + TaskId = "task-1", + ContextId = "ctx-1", + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } }); - // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.NotNull(agentTask.Metadata); - Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); + // Assert - should have emitted status update events (Submitted + Working) + Assert.True(events.StatusUpdates.Count >= 1); + Assert.Empty(events.Messages); } /// - /// Verifies that when a task is created (Working or Completed), the original user message - /// is added to the task history, matching the A2A SDK's behavior when it creates tasks internally. + /// Verifies that when the incoming message has a ContextId, it is used for the response + /// rather than generating a new one. /// [Fact] - public async Task MapA2A_WhenTaskIsCreated_OriginalMessageIsInHistoryAsync() + public async Task ExecuteAsync_WhenMessageHasContextId_UsesProvidedContextIdAsync() { // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - AgentMessage originalMessage = new() { MessageId = "user-msg-1", Role = MessageRole.User, Parts = [new TextPart { Text = "Do something" }] }; + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); + IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + var events = await CollectEventsAsync(handler, new RequestContext { - Message = originalMessage + StreamingResponse = false, + TaskId = "", + ContextId = "my-context-123", + Message = new Message + { + MessageId = "test-id", + ContextId = "my-context-123", + Role = Role.User, + Parts = [new Part { Text = "Hello" }] + } }); // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.NotNull(agentTask.History); - Assert.Contains(agentTask.History, m => m.MessageId == "user-msg-1" && m.Role == MessageRole.User); + Message message = Assert.Single(events.Messages); + Assert.Equal("my-context-123", message.ContextId); } /// - /// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken), - /// the returned AgentMessage preserves the original context ID. + /// Verifies that on continuation when the agent completes (no ContinuationToken), task is completed with artifact. /// [Fact] - public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageWithContextIdAsync() + public async Task ExecuteAsync_OnContinuation_WhenComplete_EmitsArtifactAndCompletedAsync() { // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]); - ITaskManager taskManager = CreateAgentMockWithResponse(response) - .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported); - AgentMessage originalMessage = new() { MessageId = "user-msg-2", ContextId = "ctx-123", Role = MessageRole.User, Parts = [new TextPart { Text = "Quick task" }] }; + IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + var events = await CollectEventsAsync(handler, new RequestContext { - Message = originalMessage - }); - - // Assert - AgentMessage agentMessage = Assert.IsType(a2aResponse); - Assert.Equal("ctx-123", agentMessage.ContextId); - } + StreamingResponse = false, + Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] }, + TaskId = "task-1", + ContextId = "ctx-1", - /// - /// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token - /// and the agent returns a completed response (null ContinuationToken), the task is updated to Completed. - /// - [Fact] - public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationCompletes_TaskIsCompletedAsync() - { - // Arrange - int callCount = 0; - Mock agentMock = CreateAgentMockWithSequentialResponses( - // First call: return response with ContinuationToken (long-running) - new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) - { - ContinuationToken = CreateTestContinuationToken() - }, - // Second call (via OnTaskUpdated): return completed response - new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]), - ref callCount); - ITaskManager taskManager = agentMock.Object.MapA2A(); - - // Act — trigger OnMessageReceived to create the task - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] } }); - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.Equal(TaskState.Working, agentTask.Status.State); - // Act — invoke OnTaskUpdated to check on the background operation - await InvokeOnTaskUpdatedAsync(taskManager, agentTask); - - // Assert — task should now be completed - AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(updatedTask); - Assert.Equal(TaskState.Completed, updatedTask.Status.State); - Assert.NotNull(updatedTask.Artifacts); - Artifact artifact = Assert.Single(updatedTask.Artifacts); - TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts)); - Assert.Equal("Done!", textPart.Text); + // Assert - should have artifact + completed status + Assert.True(events.ArtifactUpdates.Count > 0); + Assert.True(events.StatusUpdates.Count > 0); + Assert.Empty(events.Messages); } /// - /// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token - /// and the agent returns another ContinuationToken, the task stays in Working state. + /// Verifies that when the agent throws during a continuation, + /// the handler emits a Failed status and re-throws the exception. /// [Fact] - public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationStillWorking_TaskRemainsWorkingAsync() + public async Task ExecuteAsync_OnContinuation_WhenAgentThrows_EmitsFailedStatusAsync() { // Arrange int callCount = 0; - Mock agentMock = CreateAgentMockWithSequentialResponses( - // First call: return response with ContinuationToken - new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) - { - ContinuationToken = CreateTestContinuationToken() - }, - // Second call (via OnTaskUpdated): still working, return another token - new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")]) - { - ContinuationToken = CreateTestContinuationToken() - }, - ref callCount); - ITaskManager taskManager = agentMock.Object.MapA2A(); - - // Act — trigger OnMessageReceived to create the task - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); + Mock agentMock = CreateAgentMockWithCallCount(ref callCount, _ => + throw new InvalidOperationException("Agent failed")); + IAgentHandler handler = agentMock.Object.MapA2A(); + + // Act & Assert + var events = new EventCollector(); + var eventQueue = new AgentEventQueue(); + var readerTask = ReadEventsAsync(eventQueue, events); + await Assert.ThrowsAsync(() => + handler.ExecuteAsync( + new RequestContext + { + StreamingResponse = false, + Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] }, + TaskId = "task-1", + ContextId = "ctx-1", - // Act — invoke OnTaskUpdated; agent still working - await InvokeOnTaskUpdatedAsync(taskManager, agentTask); + Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] } + }, + eventQueue, + CancellationToken.None)); + eventQueue.Complete(null); + await readerTask; - // Assert — task should still be in Working state - AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(updatedTask); - Assert.Equal(TaskState.Working, updatedTask.Status.State); + // Assert - should have emitted Failed status + Assert.True(events.StatusUpdates.Count > 0); } /// - /// Verifies the full lifecycle: agent starts background work, first poll returns still working, - /// second poll returns completed. + /// Verifies that when the agent throws OperationCanceledException during a continuation, + /// no Failed status is emitted. /// [Fact] - public async Task MapA2A_OnTaskUpdated_MultiplePolls_EventuallyCompletesAsync() + public async Task ExecuteAsync_OnContinuation_WhenOperationCancelled_DoesNotEmitFailedAsync() { // Arrange int callCount = 0; - Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation => - { - return invocation switch - { - // First call: start background work - 1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) - { - ContinuationToken = CreateTestContinuationToken() - }, - // Second call: still working - 2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")]) + Mock agentMock = CreateAgentMockWithCallCount(ref callCount, _ => + throw new OperationCanceledException("Cancelled")); + IAgentHandler handler = agentMock.Object.MapA2A(); + + // Act & Assert + var events = new EventCollector(); + var eventQueue = new AgentEventQueue(); + var readerTask = ReadEventsAsync(eventQueue, events); + await Assert.ThrowsAsync(() => + handler.ExecuteAsync( + new RequestContext { - ContinuationToken = CreateTestContinuationToken() + StreamingResponse = false, + Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] }, + TaskId = "task-1", + ContextId = "ctx-1", + + Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] } }, - // Third call: done - _ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "All done!")]) - }; - }); - ITaskManager taskManager = agentMock.Object.MapA2A(); + eventQueue, + CancellationToken.None)); + eventQueue.Complete(null); + await readerTask; - // Act — create the task - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Do work" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.Equal(TaskState.Working, agentTask.Status.State); - - // Act — first poll: still working - AgentTask? currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(currentTask); - await InvokeOnTaskUpdatedAsync(taskManager, currentTask); - currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(currentTask); - Assert.Equal(TaskState.Working, currentTask.Status.State); - - // Act — second poll: completed - await InvokeOnTaskUpdatedAsync(taskManager, currentTask); - currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(currentTask); - Assert.Equal(TaskState.Completed, currentTask.Status.State); - - // Assert — final output as artifact - Assert.NotNull(currentTask.Artifacts); - Artifact artifact = Assert.Single(currentTask.Artifacts); - TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts)); - Assert.Equal("All done!", textPart.Text); + // Assert - should NOT have emitted any status (OperationCanceledException is re-thrown without marking Failed) + Assert.Empty(events.StatusUpdates); } /// - /// Verifies that when the agent throws during a background operation poll, - /// the task is updated to Failed state. + /// Verifies that ReferenceTaskIds throws NotSupportedException. /// [Fact] - public async Task MapA2A_OnTaskUpdated_WhenAgentThrows_TaskIsFailedAsync() + public async Task ExecuteAsync_WithReferenceTaskIds_ThrowsNotSupportedExceptionAsync() { // Arrange - int callCount = 0; - Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation => - { - if (invocation == 1) + IAgentHandler handler = CreateAgentMock(_ => { }).Object.MapA2A(); + + // Act & Assert + await Assert.ThrowsAsync(() => + InvokeExecuteAsync(handler, new RequestContext { - return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { - ContinuationToken = CreateTestContinuationToken() - }; - } - - throw new InvalidOperationException("Agent failed"); - }); - ITaskManager taskManager = agentMock.Object.MapA2A(); - - // Act — create the task - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - - // Act — poll the task; agent throws - await Assert.ThrowsAsync(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask)); - - // Assert — task should be Failed - AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(updatedTask); - Assert.Equal(TaskState.Failed, updatedTask.Status.State); + MessageId = "test-id", + Role = Role.User, + Parts = [new Part { Text = "Hello" }], + ReferenceTaskIds = ["other-task-id"] + } + })); } /// - /// Verifies that in Task mode with a ContinuationToken, the result is an AgentTask in Working state. + /// Verifies that when ContextId is null, a new one is generated and used in the response. /// [Fact] - public async Task MapA2A_TaskMode_WhenContinuationToken_ReturnsWorkingAgentTaskAsync() + public async Task ExecuteAsync_WhenContextIdIsNull_GeneratesContextIdAsync() { // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Working on it...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response) - .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported); + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); + IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + var events = await CollectEventsAsync(handler, new RequestContext { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + StreamingResponse = false, + TaskId = "", + ContextId = null!, + Message = new Message + { + MessageId = "test-id", + Role = Role.User, + Parts = [new Part { Text = "Hello" }] + } }); // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.Equal(TaskState.Working, agentTask.Status.State); - Assert.NotNull(agentTask.Metadata); - Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); + Message message = Assert.Single(events.Messages); + Assert.NotNull(message.ContextId); + Assert.NotEmpty(message.ContextId); } /// - /// Verifies that when the agent returns a ContinuationToken with no progress messages, - /// the task transitions to Working state with a null status message. + /// Verifies that when Message is null, the handler still succeeds with empty chat messages. /// [Fact] - public async Task MapA2A_WhenContinuationTokenWithNoMessages_TaskStatusHasNullMessageAsync() + public async Task ExecuteAsync_WhenMessageIsNull_SucceedsWithEmptyMessagesAsync() { // Arrange - AgentResponse response = new([]) - { - ContinuationToken = CreateTestContinuationToken() - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); + IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + var events = await CollectEventsAsync(handler, new RequestContext { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } + StreamingResponse = false, + TaskId = "", + ContextId = "ctx", + Message = null! }); // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.Equal(TaskState.Working, agentTask.Status.State); - Assert.Null(agentTask.Status.Message); + Message message = Assert.Single(events.Messages); + Assert.Equal("ctx", message.ContextId); } /// - /// Verifies that when OnTaskUpdated is invoked on a completed task with a follow-up message - /// and no continuation token in metadata, the task processes history and completes with a new artifact. + /// Verifies that the dynamic AllowBackgroundWhen delegate receives the correct RequestContext. /// [Fact] - public async Task MapA2A_OnTaskUpdated_WhenNoContinuationToken_ProcessesHistoryAndCompletesAsync() + public async Task ExecuteAsync_DynamicMode_DelegateReceivesRequestContextAsync() { // Arrange - int callCount = 0; - Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation => - { - return invocation switch + A2ARunDecisionContext? capturedContext = null; + IAgentHandler handler = CreateAgentMock(_ => { }) + .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((ctx, _) => { - // First call: create a task with ContinuationToken - 1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) - { - ContinuationToken = CreateTestContinuationToken() - }, - // Second call (via OnTaskUpdated): complete the background operation - 2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]), - // Third call (follow-up via OnTaskUpdated): complete follow-up - _ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Follow-up done!")]) - }; - }); - ITaskManager taskManager = agentMock.Object.MapA2A(); + capturedContext = ctx; + return ValueTask.FromResult(false); + })); - // Act — create a working task (with continuation token) - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + var requestContext = new RequestContext { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - - // Act — first OnTaskUpdated: completes the background operation - await InvokeOnTaskUpdatedAsync(taskManager, agentTask); - agentTask = (await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None))!; - Assert.Equal(TaskState.Completed, agentTask.Status.State); - - // Simulate a follow-up message by adding it to history and re-submitting via OnTaskUpdated - agentTask.History ??= []; - agentTask.History.Add(new AgentMessage { MessageId = "follow-up", Role = MessageRole.User, Parts = [new TextPart { Text = "Follow up" }] }); - - // Act — invoke OnTaskUpdated without a continuation token in metadata - await InvokeOnTaskUpdatedAsync(taskManager, agentTask); - - // Assert - AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(updatedTask); - Assert.Equal(TaskState.Completed, updatedTask.Status.State); - Assert.NotNull(updatedTask.Artifacts); - Assert.Equal(2, updatedTask.Artifacts.Count); - Artifact artifact = updatedTask.Artifacts[1]; - TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts)); - Assert.Equal("Follow-up done!", textPart.Text); - } - - /// - /// Verifies that when a task is cancelled, the continuation token is removed from metadata. - /// - [Fact] - public async Task MapA2A_OnTaskCancelled_RemovesContinuationTokenFromMetadataAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting...")]) - { - ContinuationToken = CreateTestContinuationToken() + TaskId = "my-task", ContextId = "my-ctx", StreamingResponse = false, + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - - // Act — create a working task with a continuation token - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.NotNull(agentTask.Metadata); - Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); - - // Act — cancel the task - await taskManager.CancelTaskAsync(new TaskIdParams { Id = agentTask.Id }, CancellationToken.None); - - // Assert — continuation token should be removed from metadata - Assert.False(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); - } - - /// - /// Verifies that when the agent throws an OperationCanceledException during a poll, - /// it is re-thrown without marking the task as Failed. - /// - [Fact] - public async Task MapA2A_OnTaskUpdated_WhenOperationCancelled_DoesNotMarkFailedAsync() - { - // Arrange - int callCount = 0; - Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation => - { - if (invocation == 1) - { - return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - } - - throw new OperationCanceledException("Cancelled"); - }); - ITaskManager taskManager = agentMock.Object.MapA2A(); - - // Act — create the task - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - // Act — poll the task; agent throws OperationCanceledException - await Assert.ThrowsAsync(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask)); + // Act + await InvokeExecuteAsync(handler, requestContext); - // Assert — task should still be Working, not Failed - AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(updatedTask); - Assert.Equal(TaskState.Working, updatedTask.Status.State); + // Assert + Assert.NotNull(capturedContext); + Assert.Same(requestContext, capturedContext.RequestContext); } /// - /// Verifies that when the incoming message has a ContextId, it is used for the task - /// rather than generating a new one. + /// Verifies that CancelAsync emits a Canceled status event. /// [Fact] - public async Task MapA2A_WhenMessageHasContextId_UsesProvidedContextIdAsync() + public async Task CancelAsync_EmitsCanceledStatusAsync() { // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + IAgentHandler handler = CreateAgentMock(_ => { }).Object.MapA2A(); + var events = new EventCollector(); + var eventQueue = new AgentEventQueue(); + var readerTask = ReadEventsAsync(eventQueue, events); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage + await handler.CancelAsync( + new RequestContext { - MessageId = "test-id", - ContextId = "my-context-123", - Role = MessageRole.User, - Parts = [new TextPart { Text = "Hello" }] - } - }); + StreamingResponse = false, + Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] }, + TaskId = "task-1", + ContextId = "ctx-1", + Task = new AgentTask { Id = "task-1", ContextId = "ctx-1" } + }, + eventQueue, + CancellationToken.None); // Assert - AgentMessage agentMessage = Assert.IsType(a2aResponse); - Assert.Equal("my-context-123", agentMessage.ContextId); + eventQueue.Complete(null); + await readerTask; + Assert.True(events.StatusUpdates.Count > 0); } #pragma warning restore MEAI001 @@ -803,49 +576,211 @@ private static Mock CreateAgentMockWithResponse(AgentResponse response) return agentMock; } - private static async Task InvokeOnMessageReceivedAsync(ITaskManager taskManager, MessageSendParams messageSendParams) + private static Mock CreateAgentMockWithCallCount( + ref int callCount, + Func responseFactory) { - Func>? handler = taskManager.OnMessageReceived; - Assert.NotNull(handler); - return await handler.Invoke(messageSendParams, CancellationToken.None); + StrongBox callCountBox = new(callCount); + + Mock agentMock = new() { CallBase = true }; + agentMock.SetupGet(x => x.Name).Returns("TestAgent"); + agentMock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .ReturnsAsync(new TestAgentSession()); + agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => + { + int currentCall = Interlocked.Increment(ref callCountBox.Value); + return responseFactory(currentCall); + }); + + return agentMock; } - private static async Task InvokeOnTaskUpdatedAsync(ITaskManager taskManager, AgentTask agentTask) + private static async Task InvokeExecuteAsync(IAgentHandler handler, RequestContext context) { - Func? handler = taskManager.OnTaskUpdated; - Assert.NotNull(handler); - await handler.Invoke(agentTask, CancellationToken.None); + var eventQueue = new AgentEventQueue(); + await handler.ExecuteAsync(context, eventQueue, CancellationToken.None); + eventQueue.Complete(null); } -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + private static async Task CollectEventsAsync(IAgentHandler handler, RequestContext context) + { + var events = new EventCollector(); + var eventQueue = new AgentEventQueue(); + var readerTask = ReadEventsAsync(eventQueue, events); + + await handler.ExecuteAsync(context, eventQueue, CancellationToken.None); + eventQueue.Complete(null); + await readerTask; + + return events; + } + + private static async Task ReadEventsAsync(AgentEventQueue eventQueue, EventCollector collector) + { + await foreach (var response in eventQueue) + { + switch (response.PayloadCase) + { + case StreamResponseCase.Message: + collector.Messages.Add(response.Message!); + break; + case StreamResponseCase.Task: + collector.Tasks.Add(response.Task!); + break; + case StreamResponseCase.StatusUpdate: + collector.StatusUpdates.Add(response.StatusUpdate!); + break; + case StreamResponseCase.ArtifactUpdate: + collector.ArtifactUpdates.Add(response.ArtifactUpdate!); + break; + } + } + } + +#pragma warning disable MEAI001 private static ResponseContinuationToken CreateTestContinuationToken() { return ResponseContinuationToken.FromBytes(new byte[] { 0x01, 0x02, 0x03 }); } #pragma warning restore MEAI001 - private static Mock CreateAgentMockWithSequentialResponses( - AgentResponse firstResponse, - AgentResponse secondResponse, - ref int callCount) + private sealed class EventCollector { - return CreateAgentMockWithCallCount(ref callCount, invocation => - invocation == 1 ? firstResponse : secondResponse); + public List Messages { get; } = []; + public List Tasks { get; } = []; + public List StatusUpdates { get; } = []; + public List ArtifactUpdates { get; } = []; } - private static Mock CreateAgentMockWithCallCount( - ref int callCount, - Func responseFactory) + private sealed class TestAgentSession : AgentSession; + + /// + /// Verifies that when no session store is provided, MapA2A uses InMemoryAgentSessionStore + /// and the handler can execute successfully. + /// + [Fact] + public async Task MapA2A_WithNullSessionStore_UsesInMemorySessionStoreAndExecutesSuccessfullyAsync() { - // Use a StrongBox to allow the lambda to capture a mutable reference - StrongBox callCountBox = new(callCount); + // Arrange + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); + IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(agentSessionStore: null); + + // Act + var events = await CollectEventsAsync(handler, new RequestContext + { + StreamingResponse = false, + TaskId = "", + ContextId = "ctx-1", + Message = new Message + { + MessageId = "test-id", + Role = Role.User, + Parts = [new Part { Text = "Hello" }] + } + }); + + // Assert + Message message = Assert.Single(events.Messages); + Assert.Equal("Reply", message.Parts![0].Text); + } + + /// + /// Verifies that when a custom session store is provided, it is used instead of the + /// default InMemoryAgentSessionStore. + /// + [Fact] + public async Task MapA2A_WithCustomSessionStore_UsesProvidedSessionStoreAsync() + { + // Arrange + var mockSessionStore = new Mock(); + mockSessionStore + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new TestAgentSession()); + mockSessionStore + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(ValueTask.CompletedTask); + + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); + IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(agentSessionStore: mockSessionStore.Object); + + // Act + await InvokeExecuteAsync(handler, new RequestContext + { + StreamingResponse = false, + TaskId = "", + ContextId = "ctx-1", + Message = new Message + { + MessageId = "test-id", + Role = Role.User, + Parts = [new Part { Text = "Hello" }] + } + }); + + // Assert - verify the custom session store was called + mockSessionStore.Verify( + x => x.GetSessionAsync( + It.IsAny(), + It.Is(s => s == "ctx-1"), + It.IsAny()), + Times.Once); + mockSessionStore.Verify( + x => x.SaveSessionAsync( + It.IsAny(), + It.Is(s => s == "ctx-1"), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + /// + /// Verifies that when no session store is provided, the default InMemoryAgentSessionStore + /// persists sessions across multiple calls with the same context ID. + /// + [Fact] + public async Task MapA2A_WithNullSessionStore_SessionIsPersistedAcrossCallsAsync() + { + // Arrange - track how many times CreateSessionCoreAsync is called + int createSessionCallCount = 0; + var sessionInstance = new TestAgentSession(); Mock agentMock = new() { CallBase = true }; agentMock.SetupGet(x => x.Name).Returns("TestAgent"); agentMock .Protected() .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) - .ReturnsAsync(new TestAgentSession()); + .Callback(() => Interlocked.Increment(ref createSessionCallCount)) + .ReturnsAsync(() => new TestAgentSession()); + agentMock + .Protected() + .Setup>("SerializeSessionCoreAsync", + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(System.Text.Json.JsonDocument.Parse("{}").RootElement); + agentMock + .Protected() + .Setup>("DeserializeSessionCoreAsync", + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(sessionInstance); agentMock .Protected() .Setup>("RunCoreAsync", @@ -853,14 +788,110 @@ private static Mock CreateAgentMockWithCallCount( ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) - .ReturnsAsync(() => + .ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Reply")])); + + IAgentHandler handler = agentMock.Object.MapA2A(agentSessionStore: null); + + var context = new RequestContext + { + StreamingResponse = false, + TaskId = "", + ContextId = "ctx-persistent", + Message = new Message { - int currentCall = Interlocked.Increment(ref callCountBox.Value); - return responseFactory(currentCall); - }); + MessageId = "test-id", + Role = Role.User, + Parts = [new Part { Text = "Hello" }] + } + }; - return agentMock; + // Act - call twice with the same context ID + await InvokeExecuteAsync(handler, context); + await InvokeExecuteAsync(handler, context); + + // Assert - CreateSessionCoreAsync should be called once (first call creates, second retrieves from store) + Assert.Equal(1, createSessionCallCount); } - private sealed class TestAgentSession : AgentSession; + /// + /// Verifies that when the AllowBackgroundWhen delegate throws, the exception propagates + /// and the agent is not invoked. + /// + [Fact] + public async Task ExecuteAsync_DynamicMode_WhenCallbackThrows_PropagatesExceptionAsync() + { + // Arrange + bool agentInvoked = false; + IAgentHandler handler = CreateAgentMock(_ => agentInvoked = true) + .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, _) => + throw new InvalidOperationException("Callback failed"))); + + // Act & Assert + await Assert.ThrowsAsync(() => + InvokeExecuteAsync(handler, new RequestContext + { + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + })); + + Assert.False(agentInvoked); + } + + /// + /// Verifies that the CancellationToken is propagated to the AllowBackgroundWhen delegate. + /// + [Fact] + public async Task ExecuteAsync_DynamicMode_CancellationTokenIsPropagatedToCallbackAsync() + { + // Arrange + CancellationToken capturedToken = default; + using var cts = new CancellationTokenSource(); + IAgentHandler handler = CreateAgentMock(_ => { }) + .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, ct) => + { + capturedToken = ct; + return ValueTask.FromResult(false); + })); + + // Act + var eventQueue = new AgentEventQueue(); + await handler.ExecuteAsync( + new RequestContext + { + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }, + eventQueue, + cts.Token); + eventQueue.Complete(null); + + // Assert + Assert.Equal(cts.Token, capturedToken); + } + + /// + /// Verifies that the agent run mode is applied on the continuation/task-update path, + /// not just the new message path. + /// + [Fact] + public async Task ExecuteAsync_OnContinuation_RunModeIsAppliedAsync() + { + // Arrange + AgentRunOptions? capturedOptions = null; + IAgentHandler handler = CreateAgentMock(options => capturedOptions = options) + .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported); + + // Act + await InvokeExecuteAsync(handler, new RequestContext + { + StreamingResponse = false, + TaskId = "task-1", + ContextId = "ctx-1", + Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] }, + + Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] } + }); + + // Assert + Assert.NotNull(capturedOptions); + Assert.True(capturedOptions.AllowBackgroundResponses); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs new file mode 100644 index 0000000000..6128e0eefc --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using A2A; + +namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class AgentRunModeTests +{ + /// + /// Verifies that AllowBackgroundWhen throws ArgumentNullException for null delegate. + /// + [Fact] + public void AllowBackgroundWhen_NullDelegate_ThrowsArgumentNullException() + { + // Arrange & Act & Assert + Assert.Throws(() => + AgentRunMode.AllowBackgroundWhen(null!)); + } + + /// + /// Verifies that DisallowBackground equals another DisallowBackground instance. + /// + [Fact] + public void Equals_DisallowBackground_AreEqual() + { + // Arrange + var mode1 = AgentRunMode.DisallowBackground; + var mode2 = AgentRunMode.DisallowBackground; + + // Act & Assert + Assert.True(mode1.Equals(mode2)); + Assert.True(mode1 == mode2); + Assert.False(mode1 != mode2); + Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode()); + } + + /// + /// Verifies that AllowBackgroundIfSupported equals another AllowBackgroundIfSupported instance. + /// + [Fact] + public void Equals_AllowBackgroundIfSupported_AreEqual() + { + // Arrange + var mode1 = AgentRunMode.AllowBackgroundIfSupported; + var mode2 = AgentRunMode.AllowBackgroundIfSupported; + + // Act & Assert + Assert.True(mode1.Equals(mode2)); + Assert.True(mode1 == mode2); + } + + /// + /// Verifies that DisallowBackground and AllowBackgroundIfSupported are not equal. + /// + [Fact] + public void Equals_DifferentModes_AreNotEqual() + { + // Arrange + var disallow = AgentRunMode.DisallowBackground; + var allow = AgentRunMode.AllowBackgroundIfSupported; + + // Act & Assert + Assert.False(disallow.Equals(allow)); + Assert.False(disallow == allow); + Assert.True(disallow != allow); + } + + /// + /// Verifies that Equals returns false for null. + /// + [Fact] + public void Equals_Null_ReturnsFalse() + { + // Arrange + var mode = AgentRunMode.DisallowBackground; + + // Act & Assert + Assert.False(mode.Equals(null)); + Assert.False(mode.Equals((object?)null)); + Assert.False(mode == null); + Assert.True(mode != null); + } + + /// + /// Verifies that two null AgentRunMode values are equal. + /// + [Fact] + public void Equals_BothNull_AreEqual() + { + // Arrange + AgentRunMode? mode1 = null; + AgentRunMode? mode2 = null; + + // Act & Assert + Assert.True(mode1 == mode2); + Assert.False(mode1 != mode2); + } + + /// + /// Verifies that ToString returns expected values. + /// + [Fact] + public void ToString_ReturnsExpectedValues() + { + // Act & Assert + Assert.Equal("message", AgentRunMode.DisallowBackground.ToString()); + Assert.Equal("task", AgentRunMode.AllowBackgroundIfSupported.ToString()); + Assert.Equal("dynamic", AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)).ToString()); + } + + /// + /// Verifies that Equals works correctly with object parameter. + /// + [Fact] + public void Equals_WithObjectParameter_WorksCorrectly() + { + // Arrange + var mode = AgentRunMode.DisallowBackground; + + // Act & Assert + Assert.True(mode.Equals((object)AgentRunMode.DisallowBackground)); + Assert.False(mode.Equals((object)AgentRunMode.AllowBackgroundIfSupported)); + Assert.False(mode.Equals("not a run mode")); + } + + /// + /// Verifies that two AllowBackgroundWhen instances with different delegates are considered equal, + /// because equality is based on the mode value ("dynamic"), not the delegate. + /// + [Fact] + public void Equals_AllowBackgroundWhen_DifferentDelegates_AreEqual() + { + // Arrange + var mode1 = AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)); + var mode2 = AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false)); + + // Act & Assert + Assert.True(mode1.Equals(mode2)); + Assert.True(mode1 == mode2); + Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode()); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs index 69eaf3a535..1106802463 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs @@ -10,66 +10,66 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests.Converters; public class MessageConverterTests { [Fact] - public void ToChatMessages_MessageSendParams_Null_ReturnsEmptyCollection() + public void ToChatMessages_SendMessageRequest_Null_ReturnsEmptyCollection() { - MessageSendParams? messageSendParams = null; + SendMessageRequest? sendMessageRequest = null; - var result = messageSendParams!.ToChatMessages(); + var result = sendMessageRequest!.ToChatMessages(); Assert.NotNull(result); Assert.Empty(result); } [Fact] - public void ToChatMessages_MessageSendParams_WithNullMessage_ReturnsEmptyCollection() + public void ToChatMessages_SendMessageRequest_WithNullMessage_ReturnsEmptyCollection() { - var messageSendParams = new MessageSendParams + var sendMessageRequest = new SendMessageRequest { Message = null! }; - var result = messageSendParams.ToChatMessages(); + var result = sendMessageRequest.ToChatMessages(); Assert.NotNull(result); Assert.Empty(result); } [Fact] - public void ToChatMessages_MessageSendParams_WithMessageWithoutParts_ReturnsEmptyCollection() + public void ToChatMessages_SendMessageRequest_WithMessageWithoutParts_ReturnsEmptyCollection() { - var messageSendParams = new MessageSendParams + var sendMessageRequest = new SendMessageRequest { - Message = new AgentMessage + Message = new Message { MessageId = "test-id", - Role = MessageRole.User, + Role = Role.User, Parts = null! } }; - var result = messageSendParams.ToChatMessages(); + var result = sendMessageRequest.ToChatMessages(); Assert.NotNull(result); Assert.Empty(result); } [Fact] - public void ToChatMessages_MessageSendParams_WithValidTextMessage_ReturnsCorrectChatMessage() + public void ToChatMessages_SendMessageRequest_WithValidTextMessage_ReturnsCorrectChatMessage() { - var messageSendParams = new MessageSendParams + var sendMessageRequest = new SendMessageRequest { - Message = new AgentMessage + Message = new Message { MessageId = "test-id", - Role = MessageRole.User, + Role = Role.User, Parts = [ - new TextPart { Text = "Hello, world!" } + new Part { Text = "Hello, world!" } ] } }; - var result = messageSendParams.ToChatMessages(); + var result = sendMessageRequest.ToChatMessages(); Assert.NotNull(result); Assert.Single(result);