From f976c88c6400ed14dc02a4ce539974a7a2d01bb5 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Tue, 21 Apr 2026 22:50:08 +0100 Subject: [PATCH 1/2] .NET: Refactor A2A hosting registration into A2AServerServiceCollectionExtensions - Rename A2AHostingOptions to A2AServerRegistrationOptions - Move server registration logic from A2AEndpointRouteBuilderExtensions and AIAgentExtensions into new A2AServerServiceCollectionExtensions - Remove A2AProtocolBinding and AIAgentExtensions (consolidated) - Update samples and tests to use the new registration API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../A2AClientServer/A2AServer/Program.cs | 15 +- .../AgentWebChat.AgentHost/Program.cs | 9 +- .../A2AEndpointRouteBuilderExtensions.cs | 166 +++---- ...ft.Agents.AI.Hosting.A2A.AspNetCore.csproj | 9 +- .../A2AProtocolBinding.cs | 29 -- ...ons.cs => A2AServerRegistrationOptions.cs} | 14 +- .../A2AServerServiceCollectionExtensions.cs | 160 +++++++ .../AIAgentExtensions.cs | 40 -- ...nsionsTests.cs => A2AAgentHandlerTests.cs} | 386 ++++++++-------- .../A2AEndpointRouteBuilderExtensionsTests.cs | 427 +++++------------- 10 files changed, 532 insertions(+), 723 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AProtocolBinding.cs rename dotnet/src/Microsoft.Agents.AI.Hosting.A2A/{A2AHostingOptions.cs => A2AServerRegistrationOptions.cs} (63%) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs rename dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/{AIAgentExtensionsTests.cs => A2AAgentHandlerTests.cs} (88%) diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs index 854a48535d..c12a1c9431 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs @@ -3,7 +3,6 @@ using A2A.AspNetCore; using A2AServer; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Hosting.A2A; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; @@ -26,10 +25,6 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddHttpClient().AddLogging(); -var app = builder.Build(); - -var httpClient = app.Services.GetRequiredService().CreateClient(); -var logger = app.Logger; IConfigurationRoot configuration = new ConfigurationBuilder() .AddEnvironmentVariables() @@ -39,7 +34,7 @@ string? apiKey = configuration["OPENAI_API_KEY"]; string model = configuration["OPENAI_CHAT_MODEL_NAME"] ?? "gpt-5.4-mini"; string? endpoint = configuration["AZURE_AI_PROJECT_ENDPOINT"]; -string[] agentUrls = (app.Configuration["urls"] ?? "http://localhost:5000").Split(';'); +string[] agentUrls = (builder.Configuration["urls"] ?? "http://localhost:5000").Split(';'); var invoiceQueryPlugin = new InvoiceQuery(); IList tools = @@ -106,9 +101,11 @@ You specialize in handling queries related to logistics. throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided"); } -app.MapA2A( - hostA2AAgent, - path: "/", protocolBindings: A2AProtocolBinding.JsonRpc | A2AProtocolBinding.HttpJson); +builder.AddA2AServer(hostA2AAgent); + +var app = builder.Build(); +app.MapA2AHttpJson(hostA2AAgent, "/"); +app.MapA2AJsonRpc(hostA2AAgent, "/"); app.MapWellKnownAgentCard(hostA2AAgentCard); diff --git a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs index 1c5a1ba605..c18dbad3a4 100644 --- a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -147,6 +147,9 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te instructions: "you are a dependency inject agent. Tell me all about dependency injection."); }); +pirateAgentBuilder.AddA2AServer(); +knightsKnavesAgentBuilder.AddA2AServer(); + var app = builder.Build(); app.MapOpenApi(); @@ -155,9 +158,9 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te // Configure the HTTP request pipeline. app.UseExceptionHandler(); -// attach a2a with simple message communication -app.MapA2A(pirateAgentBuilder, path: "/a2a/pirate"); -app.MapA2A(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves"); +// Expose A2A servers over HTTP with JSON payloads +app.MapA2AHttpJson(pirateAgentBuilder, path: "/a2a/pirate"); +app.MapA2AHttpJson(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves"); app.MapDevUI(); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs index fd1ad6db20..a57ed07890 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs @@ -6,183 +6,133 @@ using A2A.AspNetCore; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; -using Microsoft.Agents.AI.Hosting.A2A; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.DiagnosticIds; namespace Microsoft.AspNetCore.Builder; /// -/// Provides extension methods for configuring A2A endpoints for AI agents. +/// Provides extension methods for mapping A2A protocol endpoints for AI agents. /// [Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] public static class A2AEndpointRouteBuilderExtensions { /// - /// Maps A2A endpoints for the specified agent to the given path. + /// Maps A2A HTTP+JSON endpoints for the specified agent to the given path. + /// An for the agent must be registered first by calling + /// AddA2AServer during service registration. /// /// The to add the A2A endpoints to. - /// The configuration builder for . + /// The configuration builder for the agent. /// 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) + public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path) { ArgumentNullException.ThrowIfNull(agentBuilder); - return endpoints.MapA2A(agentBuilder.Name, path, protocolBindings, agentRunMode); + return endpoints.MapA2AHttpJson(agentBuilder.Name, path); } /// - /// Maps A2A endpoints for the specified agent to the given path. + /// Maps A2A HTTP+JSON endpoints for the specified agent to the given path. + /// An for the agent must be registered first by calling + /// AddA2AServer during service registration. /// /// The to add the A2A endpoints to. - /// The configuration builder for . + /// The agent whose name identifies the registered A2A server. /// 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) + public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, AIAgent agent, string path) { - ArgumentNullException.ThrowIfNull(agentBuilder); + ArgumentNullException.ThrowIfNull(agent); + ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name)); - return endpoints.MapA2A(agentBuilder.Name, path, configureOptions); + return endpoints.MapA2AHttpJson(agent.Name, path); } /// - /// Maps A2A endpoints for the agent with the specified name to the given path. + /// Maps A2A HTTP+JSON endpoints for the agent with the specified name to the given path. + /// An for the agent must be registered first by calling + /// AddA2AServer during service registration. /// /// The to add the A2A endpoints to. /// The name of the agent to use for A2A protocol integration. /// The route path prefix for A2A endpoints. - /// 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) + public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, string agentName, string path) { ArgumentNullException.ThrowIfNull(endpoints); ArgumentException.ThrowIfNullOrEmpty(agentName); + ArgumentException.ThrowIfNullOrWhiteSpace(path); - var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); + var a2aServer = endpoints.ServiceProvider.GetKeyedService(agentName) + ?? throw new InvalidOperationException( + $"No A2AServer is registered for agent '{agentName}'. " + + $"Call services.AddA2AServer(\"{agentName}\") or agentBuilder.AddA2AServer() during service registration to register one."); - return endpoints.MapA2A(agent, path, protocolBindings, agentRunMode); + // TODO: The stub AgentCard is temporary and will be removed once the A2A SDK either removes the + // agentCard parameter of MapHttpA2A or makes it optional. MapHttpA2A exposes the agent card via a + // GET {path}/card endpoint that is not part of the A2A spec, so it is not expected to be consumed + // by any agent - returning a stub agent card here is safe. + var stubAgentCard = new AgentCard { Name = "A2A Agent" }; + + return endpoints.MapHttpA2A(a2aServer, stubAgentCard, path); } /// - /// Maps A2A endpoints for the agent with the specified name to the given path. + /// Maps A2A JSON-RPC endpoints for the specified agent to the given path. + /// An for the agent must be registered first by calling + /// AddA2AServer during service registration. /// /// The to add the A2A endpoints to. - /// The name of the agent to use for A2A protocol integration. + /// The configuration builder for the agent. /// 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) + public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path) { - ArgumentNullException.ThrowIfNull(endpoints); - ArgumentException.ThrowIfNullOrEmpty(agentName); - - var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); + ArgumentNullException.ThrowIfNull(agentBuilder); - return endpoints.MapA2A(agent, path, configureOptions); + return endpoints.MapA2AJsonRpc(agentBuilder.Name, path); } /// - /// Maps A2A endpoints for the specified agent to the given path. + /// Maps A2A JSON-RPC endpoints for the specified agent to the given path. + /// An for the agent must be registered first by calling + /// AddA2AServer during service registration. /// /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. + /// The agent whose name identifies the registered A2A server. /// 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) + public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, AIAgent agent, string path) { - 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); + ArgumentNullException.ThrowIfNull(agent); + ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name)); + + return endpoints.MapA2AJsonRpc(agent.Name, path); } /// - /// Maps A2A endpoints for the specified agent to the given path. + /// Maps A2A JSON-RPC endpoints for the agent with the specified name to the given path. + /// An for the agent must be registered first by calling + /// AddA2AServer during service registration. /// /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. + /// 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, AIAgent agent, string path, Action? configureOptions = null) + public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, string agentName, string path) { ArgumentNullException.ThrowIfNull(endpoints); - ArgumentNullException.ThrowIfNull(agent); + ArgumentException.ThrowIfNullOrEmpty(agentName); 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); - } + var a2aServer = endpoints.ServiceProvider.GetKeyedService(agentName) + ?? throw new InvalidOperationException( + $"No A2AServer is registered for agent '{agentName}'. " + + $"Call services.AddA2AServer(\"{agentName}\") or agentBuilder.AddA2AServer() during service registration to register one."); - return result ?? throw new InvalidOperationException("At least one A2A protocol binding must be specified."); + return endpoints.MapA2A(a2aServer, path); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj index 4829b56b9e..366dc48900 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj @@ -1,9 +1,12 @@ - + $(TargetFrameworksCore) Microsoft.Agents.AI.Hosting.A2A.AspNetCore preview + + $(NoWarn);RT0002 @@ -21,11 +24,11 @@ - + - + Microsoft Agent Framework Hosting A2A ASP.NET Core diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AProtocolBinding.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AProtocolBinding.cs deleted file mode 100644 index ad7cd32870..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AProtocolBinding.cs +++ /dev/null @@ -1,29 +0,0 @@ -// 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/A2AHostingOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerRegistrationOptions.cs similarity index 63% rename from dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AHostingOptions.cs rename to dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerRegistrationOptions.cs index ac12fb5cec..7bd30f9a7c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AHostingOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerRegistrationOptions.cs @@ -7,21 +7,11 @@ namespace Microsoft.Agents.AI.Hosting.A2A; /// -/// Options for configuring A2A endpoint hosting behavior. +/// Options for configuring A2A server registration. /// [Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] -public sealed class A2AHostingOptions +public sealed class A2AServerRegistrationOptions { - /// - /// 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. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs new file mode 100644 index 0000000000..9dbb95b989 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using A2A; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Hosting.A2A; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Provides extension methods for registering A2A server instances in the dependency injection container. +/// +[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] +public static class A2AServerServiceCollectionExtensions +{ + /// + /// Registers an in the dependency injection container, keyed by the agent name + /// specified in the . This method only registers the server; to expose it + /// as an HTTP endpoint, call one of the MapA2AHttpJson or MapA2AJsonRpc endpoint mapping + /// methods during application startup. + /// + /// The agent builder whose name identifies the agent. + /// An optional callback to configure . + /// The for chaining. + public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(agentBuilder); + + agentBuilder.ServiceCollection.AddA2AServer(agentBuilder.Name, configureOptions); + + return agentBuilder; + } + + /// + /// Registers an in the dependency injection container, keyed by the specified + /// agent name. This method only registers the server; to expose it as an HTTP endpoint, call one of the + /// MapA2AHttpJson or MapA2AJsonRpc endpoint mapping methods during application startup. + /// + /// The host application builder to configure. + /// The name of the agent to create an A2A server for. + /// An optional callback to configure . + /// The for chaining. + public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddA2AServer(agentName, configureOptions); + + return builder; + } + + /// + /// Registers an in the dependency injection container for the specified + /// instance, keyed by the agent's . This method only + /// registers the server; to expose it as an HTTP endpoint, call one of the MapA2AHttpJson or + /// MapA2AJsonRpc endpoint mapping methods during application startup. + /// + /// The host application builder to configure. + /// The agent instance to create an A2A server for. + /// An optional callback to configure . + /// The for chaining. + public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddA2AServer(agent, configureOptions); + + return builder; + } + + /// + /// Registers an in the dependency injection container, keyed by the specified + /// agent name. This method only registers the server; to expose it as an HTTP endpoint, call one of the + /// MapA2AHttpJson or MapA2AJsonRpc endpoint mapping methods during application startup. + /// + /// The service collection to add the A2A server to. + /// The name of the agent to create an A2A server for. + /// An optional callback to configure . + /// The for chaining. + public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrEmpty(agentName); + + A2AServerRegistrationOptions? options = null; + if (configureOptions is not null) + { + options = new A2AServerRegistrationOptions(); + configureOptions(options); + } + + services.AddKeyedSingleton(agentName, (sp, _) => + { + var agent = sp.GetRequiredKeyedService(agentName); + return CreateA2AServer(sp, agent, options); + }); + + return services; + } + + /// + /// Registers an in the dependency injection container for the specified + /// instance, keyed by the agent's . This method only + /// registers the server; to expose it as an HTTP endpoint, call one of the MapA2AHttpJson or + /// MapA2AJsonRpc endpoint mapping methods during application startup. + /// + /// The service collection to add the A2A server to. + /// The agent instance to create an A2A server for. + /// An optional callback to configure . + /// The for chaining. + public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(agent); + ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name)); + + A2AServerRegistrationOptions? options = null; + if (configureOptions is not null) + { + options = new A2AServerRegistrationOptions(); + configureOptions(options); + } + + services.AddKeyedSingleton(agent.Name, (sp, _) => CreateA2AServer(sp, agent, options)); + + return services; + } + + private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAgent agent, A2AServerRegistrationOptions? options) + { + var agentHandler = serviceProvider.GetKeyedService(agent.Name); + if (agentHandler is null) + { + var agentSessionStore = serviceProvider.GetKeyedService(agent.Name); + var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground; + + var hostAgent = new AIHostAgent( + innerAgent: agent, + sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore()); + + agentHandler = new A2AAgentHandler(hostAgent, runMode); + } + + var loggerFactory = serviceProvider.GetService() ?? NullLoggerFactory.Instance; + var taskStore = serviceProvider.GetKeyedService(agent.Name) ?? new InMemoryTaskStore(); + + return new A2AServer( + agentHandler, + taskStore, + new ChannelEventNotifier(), + loggerFactory.CreateLogger(), + options?.ServerOptions); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs deleted file mode 100644 index bcecd26fbc..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Diagnostics.CodeAnalysis; -using A2A; -using Microsoft.Shared.DiagnosticIds; - -namespace Microsoft.Agents.AI.Hosting.A2A; - -/// -/// Provides extension methods for attaching A2A (Agent2Agent) messaging capabilities to an . -/// -[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] -public static class AIAgentExtensions -{ - /// - /// Creates an that bridges the specified to - /// the A2A (Agent2Agent) protocol. - /// - /// Agent to attach A2A messaging processing capabilities to. - /// The store to store session contents and metadata. - /// Controls the response behavior of the agent run. - /// An that handles A2A message execution and cancellation. - public static IAgentHandler MapA2A( - this AIAgent agent, - AgentSessionStore? agentSessionStore = null, - AgentRunMode? runMode = null) - { - ArgumentNullException.ThrowIfNull(agent); - ArgumentNullException.ThrowIfNull(agent.Name); - - runMode ??= AgentRunMode.DisallowBackground; - - var hostAgent = new AIHostAgent( - innerAgent: agent, - sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore()); - - return new A2AAgentHandler(hostAgent, runMode); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs similarity index 88% rename from dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs rename to dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs index c472ea2d01..e85c503589 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs @@ -13,36 +13,10 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; /// -/// Unit tests for the class. +/// Unit tests for the class. /// -public sealed class AIAgentExtensionsTests +public sealed class A2AAgentHandlerTests { - /// - /// Verifies that MapA2A throws ArgumentNullException for null agent. - /// - [Fact] - public void MapA2A_NullAgent_ThrowsArgumentNullException() - { - // Arrange - AIAgent agent = null!; - - // Act & Assert - Assert.Throws(() => agent.MapA2A()); - } - - /// - /// Verifies that MapA2A returns a non-null IAgentHandler. - /// - [Fact] - public void MapA2A_ValidAgent_ReturnsNonNullHandler() - { - // Arrange & Act - IAgentHandler handler = CreateAgentMock(_ => { }).Object.MapA2A(); - - // Assert - Assert.NotNull(handler); - } - /// /// Verifies that when metadata is null, the options passed to RunAsync have /// AllowBackgroundResponses disabled and no AdditionalProperties. @@ -52,7 +26,7 @@ public async Task ExecuteAsync_WhenMetadataIsNull_PassesOptionsWithNoAdditionalP { // Arrange AgentRunOptions? capturedOptions = null; - IAgentHandler handler = CreateAgentMock(options => capturedOptions = options).Object.MapA2A(); + A2AAgentHandler handler = CreateHandler(CreateAgentMock(options => capturedOptions = options)); // Act await InvokeExecuteAsync(handler, new RequestContext @@ -82,7 +56,7 @@ public async Task ExecuteAsync_WhenResponseHasAdditionalProperties_ReturnsMessag { AdditionalProperties = additionalProps }; - IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response)); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -109,7 +83,7 @@ public async Task ExecuteAsync_WhenResponseHasNullAdditionalProperties_ReturnsMe { AdditionalProperties = null }; - IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response)); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -133,7 +107,7 @@ public async Task ExecuteAsync_WhenResponseHasEmptyAdditionalProperties_ReturnsM { AdditionalProperties = [] }; - IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response)); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -154,8 +128,9 @@ public async Task ExecuteAsync_DisallowBackgroundMode_SetsAllowBackgroundRespons { // Arrange AgentRunOptions? capturedOptions = null; - IAgentHandler handler = CreateAgentMock(options => capturedOptions = options) - .Object.MapA2A(runMode: AgentRunMode.DisallowBackground); + A2AAgentHandler handler = CreateHandler( + CreateAgentMock(options => capturedOptions = options), + runMode: AgentRunMode.DisallowBackground); // Act await InvokeExecuteAsync(handler, new RequestContext @@ -176,8 +151,9 @@ public async Task ExecuteAsync_AllowBackgroundIfSupportedMode_SetsAllowBackgroun { // Arrange AgentRunOptions? capturedOptions = null; - IAgentHandler handler = CreateAgentMock(options => capturedOptions = options) - .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported); + A2AAgentHandler handler = CreateHandler( + CreateAgentMock(options => capturedOptions = options), + runMode: AgentRunMode.AllowBackgroundIfSupported); // Act await InvokeExecuteAsync(handler, new RequestContext @@ -198,8 +174,9 @@ public async Task ExecuteAsync_DynamicMode_WithFalseCallback_SetsAllowBackground { // Arrange AgentRunOptions? capturedOptions = null; - IAgentHandler handler = CreateAgentMock(options => capturedOptions = options) - .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false))); + A2AAgentHandler handler = CreateHandler( + CreateAgentMock(options => capturedOptions = options), + runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false))); // Act await InvokeExecuteAsync(handler, new RequestContext @@ -220,8 +197,9 @@ public async Task ExecuteAsync_DynamicMode_WithTrueCallback_SetsAllowBackgroundR { // Arrange AgentRunOptions? capturedOptions = null; - IAgentHandler handler = CreateAgentMock(options => capturedOptions = options) - .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true))); + A2AAgentHandler handler = CreateHandler( + CreateAgentMock(options => capturedOptions = options), + runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true))); // Act await InvokeExecuteAsync(handler, new RequestContext @@ -247,7 +225,7 @@ public async Task ExecuteAsync_WhenResponseHasContinuationToken_EmitsTaskStatusE { ContinuationToken = CreateTestContinuationToken() }; - IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response)); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -272,7 +250,7 @@ public async Task ExecuteAsync_WhenMessageHasContextId_UsesProvidedContextIdAsyn { // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); - IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response)); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -302,7 +280,7 @@ public async Task ExecuteAsync_OnContinuation_WhenComplete_EmitsArtifactAndCompl { // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]); - IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response)); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -332,7 +310,7 @@ public async Task ExecuteAsync_OnContinuation_WhenAgentThrows_EmitsFailedStatusA int callCount = 0; Mock agentMock = CreateAgentMockWithCallCount(ref callCount, _ => throw new InvalidOperationException("Agent failed")); - IAgentHandler handler = agentMock.Object.MapA2A(); + A2AAgentHandler handler = CreateHandler(agentMock); // Act & Assert var events = new EventCollector(); @@ -369,7 +347,7 @@ public async Task ExecuteAsync_OnContinuation_WhenOperationCancelled_DoesNotEmit int callCount = 0; Mock agentMock = CreateAgentMockWithCallCount(ref callCount, _ => throw new OperationCanceledException("Cancelled")); - IAgentHandler handler = agentMock.Object.MapA2A(); + A2AAgentHandler handler = CreateHandler(agentMock); // Act & Assert var events = new EventCollector(); @@ -402,7 +380,7 @@ await Assert.ThrowsAsync(() => public async Task ExecuteAsync_WithReferenceTaskIds_ThrowsNotSupportedExceptionAsync() { // Arrange - IAgentHandler handler = CreateAgentMock(_ => { }).Object.MapA2A(); + A2AAgentHandler handler = CreateHandler(CreateAgentMock(_ => { })); // Act & Assert await Assert.ThrowsAsync(() => @@ -426,7 +404,7 @@ public async Task ExecuteAsync_WhenContextIdIsNull_GeneratesContextIdAsync() { // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); - IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response)); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -456,7 +434,7 @@ public async Task ExecuteAsync_WhenMessageIsNull_SucceedsWithEmptyMessagesAsync( { // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); - IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response)); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -480,8 +458,9 @@ public async Task ExecuteAsync_DynamicMode_DelegateReceivesRequestContextAsync() { // Arrange A2ARunDecisionContext? capturedContext = null; - IAgentHandler handler = CreateAgentMock(_ => { }) - .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((ctx, _) => + A2AAgentHandler handler = CreateHandler( + CreateAgentMock(_ => { }), + runMode: AgentRunMode.AllowBackgroundWhen((ctx, _) => { capturedContext = ctx; return ValueTask.FromResult(false); @@ -508,7 +487,7 @@ public async Task ExecuteAsync_DynamicMode_DelegateReceivesRequestContextAsync() public async Task CancelAsync_EmitsCanceledStatusAsync() { // Arrange - IAgentHandler handler = CreateAgentMock(_ => { }).Object.MapA2A(); + A2AAgentHandler handler = CreateHandler(CreateAgentMock(_ => { })); var events = new EventCollector(); var eventQueue = new AgentEventQueue(); var readerTask = ReadEventsAsync(eventQueue, events); @@ -534,145 +513,16 @@ await handler.CancelAsync( #pragma warning restore MEAI001 - private static Mock CreateAgentMock(Action optionsCallback) - { - Mock agentMock = new() { CallBase = true }; - agentMock.SetupGet(x => x.Name).Returns("TestAgent"); - agentMock - .Protected() - .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) - .ReturnsAsync(new TestAgentSession()); - agentMock - .Protected() - .Setup>("RunCoreAsync", - ItExpr.IsAny>(), - ItExpr.IsAny(), - ItExpr.IsAny(), - ItExpr.IsAny()) - .Callback, AgentSession?, AgentRunOptions?, CancellationToken>( - (_, _, options, _) => optionsCallback(options)) - .ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Test response")])); - - return agentMock; - } - - private static Mock CreateAgentMockWithResponse(AgentResponse response) - { - Mock agentMock = new() { CallBase = true }; - agentMock.SetupGet(x => x.Name).Returns("TestAgent"); - agentMock - .Protected() - .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) - .ReturnsAsync(new TestAgentSession()); - agentMock - .Protected() - .Setup>("RunCoreAsync", - ItExpr.IsAny>(), - ItExpr.IsAny(), - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(response); - - return agentMock; - } - - private static Mock CreateAgentMockWithCallCount( - ref int callCount, - Func responseFactory) - { - StrongBox callCountBox = new(callCount); - - Mock agentMock = new() { CallBase = true }; - agentMock.SetupGet(x => x.Name).Returns("TestAgent"); - agentMock - .Protected() - .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) - .ReturnsAsync(new TestAgentSession()); - agentMock - .Protected() - .Setup>("RunCoreAsync", - ItExpr.IsAny>(), - ItExpr.IsAny(), - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(() => - { - int currentCall = Interlocked.Increment(ref callCountBox.Value); - return responseFactory(currentCall); - }); - - return agentMock; - } - - private static async Task InvokeExecuteAsync(IAgentHandler handler, RequestContext context) - { - var eventQueue = new AgentEventQueue(); - await handler.ExecuteAsync(context, eventQueue, CancellationToken.None); - eventQueue.Complete(null); - } - - 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 sealed class EventCollector - { - public List Messages { get; } = []; - public List Tasks { get; } = []; - public List StatusUpdates { get; } = []; - public List ArtifactUpdates { get; } = []; - } - - private sealed class TestAgentSession : AgentSession; - /// - /// Verifies that when no session store is provided, MapA2A uses InMemoryAgentSessionStore - /// and the handler can execute successfully. + /// Verifies that when no session store is provided, the handler uses InMemoryAgentSessionStore + /// and can execute successfully. /// [Fact] - public async Task MapA2A_WithNullSessionStore_UsesInMemorySessionStoreAndExecutesSuccessfullyAsync() + public async Task Handler_WithNullSessionStore_UsesInMemorySessionStoreAndExecutesSuccessfullyAsync() { // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); - IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(agentSessionStore: null); + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: null); // Act var events = await CollectEventsAsync(handler, new RequestContext @@ -698,7 +548,7 @@ public async Task MapA2A_WithNullSessionStore_UsesInMemorySessionStoreAndExecute /// default InMemoryAgentSessionStore. /// [Fact] - public async Task MapA2A_WithCustomSessionStore_UsesProvidedSessionStoreAsync() + public async Task Handler_WithCustomSessionStore_UsesProvidedSessionStoreAsync() { // Arrange var mockSessionStore = new Mock(); @@ -717,7 +567,7 @@ public async Task MapA2A_WithCustomSessionStore_UsesProvidedSessionStoreAsync() .Returns(ValueTask.CompletedTask); AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); - IAgentHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(agentSessionStore: mockSessionStore.Object); + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: mockSessionStore.Object); // Act await InvokeExecuteAsync(handler, new RequestContext @@ -754,7 +604,7 @@ public async Task MapA2A_WithCustomSessionStore_UsesProvidedSessionStoreAsync() /// persists sessions across multiple calls with the same context ID. /// [Fact] - public async Task MapA2A_WithNullSessionStore_SessionIsPersistedAcrossCallsAsync() + public async Task Handler_WithNullSessionStore_SessionIsPersistedAcrossCallsAsync() { // Arrange - track how many times CreateSessionCoreAsync is called int createSessionCallCount = 0; @@ -790,7 +640,7 @@ public async Task MapA2A_WithNullSessionStore_SessionIsPersistedAcrossCallsAsync ItExpr.IsAny()) .ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Reply")])); - IAgentHandler handler = agentMock.Object.MapA2A(agentSessionStore: null); + A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: null); var context = new RequestContext { @@ -822,8 +672,9 @@ public async Task ExecuteAsync_DynamicMode_WhenCallbackThrows_PropagatesExceptio { // Arrange bool agentInvoked = false; - IAgentHandler handler = CreateAgentMock(_ => agentInvoked = true) - .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, _) => + A2AAgentHandler handler = CreateHandler( + CreateAgentMock(_ => agentInvoked = true), + runMode: AgentRunMode.AllowBackgroundWhen((_, _) => throw new InvalidOperationException("Callback failed"))); // Act & Assert @@ -845,8 +696,9 @@ public async Task ExecuteAsync_DynamicMode_CancellationTokenIsPropagatedToCallba // Arrange CancellationToken capturedToken = default; using var cts = new CancellationTokenSource(); - IAgentHandler handler = CreateAgentMock(_ => { }) - .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, ct) => + A2AAgentHandler handler = CreateHandler( + CreateAgentMock(_ => { }), + runMode: AgentRunMode.AllowBackgroundWhen((_, ct) => { capturedToken = ct; return ValueTask.FromResult(false); @@ -876,8 +728,9 @@ public async Task ExecuteAsync_OnContinuation_RunModeIsAppliedAsync() { // Arrange AgentRunOptions? capturedOptions = null; - IAgentHandler handler = CreateAgentMock(options => capturedOptions = options) - .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported); + A2AAgentHandler handler = CreateHandler( + CreateAgentMock(options => capturedOptions = options), + runMode: AgentRunMode.AllowBackgroundIfSupported); // Act await InvokeExecuteAsync(handler, new RequestContext @@ -894,4 +747,147 @@ public async Task ExecuteAsync_OnContinuation_RunModeIsAppliedAsync() Assert.NotNull(capturedOptions); Assert.True(capturedOptions.AllowBackgroundResponses); } -} + + private static A2AAgentHandler CreateHandler( + Mock agentMock, + AgentRunMode? runMode = null, + AgentSessionStore? agentSessionStore = null) + { + runMode ??= AgentRunMode.DisallowBackground; + + var hostAgent = new AIHostAgent( + innerAgent: agentMock.Object, + sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore()); + + return new A2AAgentHandler(hostAgent, runMode); + } + + private static Mock CreateAgentMock(Action optionsCallback) + { + Mock agentMock = new() { CallBase = true }; + agentMock.SetupGet(x => x.Name).Returns("TestAgent"); + agentMock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .ReturnsAsync(new TestAgentSession()); + agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>( + (_, _, options, _) => optionsCallback(options)) + .ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Test response")])); + + return agentMock; + } + + private static Mock CreateAgentMockWithResponse(AgentResponse response) + { + Mock agentMock = new() { CallBase = true }; + agentMock.SetupGet(x => x.Name).Returns("TestAgent"); + agentMock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .ReturnsAsync(new TestAgentSession()); + agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + + return agentMock; + } + + private static Mock CreateAgentMockWithCallCount( + ref int callCount, + Func responseFactory) + { + StrongBox callCountBox = new(callCount); + + Mock agentMock = new() { CallBase = true }; + agentMock.SetupGet(x => x.Name).Returns("TestAgent"); + agentMock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .ReturnsAsync(new TestAgentSession()); + agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => + { + int currentCall = Interlocked.Increment(ref callCountBox.Value); + return responseFactory(currentCall); + }); + + return agentMock; + } + + private static async Task InvokeExecuteAsync(A2AAgentHandler handler, RequestContext context) + { + var eventQueue = new AgentEventQueue(); + await handler.ExecuteAsync(context, eventQueue, CancellationToken.None); + eventQueue.Complete(null); + } + + private static async Task CollectEventsAsync(A2AAgentHandler handler, RequestContext context) + { + var events = new EventCollector(); + var eventQueue = new AgentEventQueue(); + var readerTask = ReadEventsAsync(eventQueue, events); + + await handler.ExecuteAsync(context, eventQueue, CancellationToken.None); + eventQueue.Complete(null); + await readerTask; + + return events; + } + + private static async Task ReadEventsAsync(AgentEventQueue eventQueue, EventCollector collector) + { + await foreach (var response in eventQueue) + { + switch (response.PayloadCase) + { + case StreamResponseCase.Message: + collector.Messages.Add(response.Message!); + break; + case StreamResponseCase.Task: + collector.Tasks.Add(response.Task!); + break; + case StreamResponseCase.StatusUpdate: + collector.StatusUpdates.Add(response.StatusUpdate!); + break; + case StreamResponseCase.ArtifactUpdate: + collector.ArtifactUpdates.Add(response.ArtifactUpdate!); + break; + } + } + } + +#pragma warning disable MEAI001 + private static ResponseContinuationToken CreateTestContinuationToken() + { + return ResponseContinuationToken.FromBytes(new byte[] { 0x01, 0x02, 0x03 }); + } +#pragma warning restore MEAI001 + + private sealed class EventCollector + { + public List Messages { get; } = []; + public List Tasks { get; } = []; + public List StatusUpdates { get; } = []; + public List ArtifactUpdates { get; } = []; + } + + private sealed class TestAgentSession : AgentSession; +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs index 783fecea96..ff3e2e3b0b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs @@ -9,15 +9,15 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; /// -/// Tests for A2AEndpointRouteBuilderExtensions.MapA2A method. +/// Tests for A2AEndpointRouteBuilderExtensions and A2AServerServiceCollectionExtensions methods. /// public sealed class A2AEndpointRouteBuilderExtensionsTests { /// - /// Verifies that MapA2A throws ArgumentNullException for null endpoints. + /// Verifies that MapA2AHttpJson throws ArgumentNullException for null endpoints. /// [Fact] - public void MapA2A_WithAgentBuilder_NullEndpoints_ThrowsArgumentNullException() + public void MapA2AHttpJson_WithAgentBuilder_NullEndpoints_ThrowsArgumentNullException() { // Arrange AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; @@ -28,16 +28,16 @@ public void MapA2A_WithAgentBuilder_NullEndpoints_ThrowsArgumentNullException() // Act & Assert ArgumentNullException exception = Assert.Throws(() => - endpoints.MapA2A(agentBuilder, "/a2a")); + endpoints.MapA2AHttpJson(agentBuilder, "/a2a")); Assert.Equal("endpoints", exception.ParamName); } /// - /// Verifies that MapA2A throws ArgumentNullException for null agentBuilder. + /// Verifies that MapA2AHttpJson throws ArgumentNullException for null agentBuilder. /// [Fact] - public void MapA2A_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException() + public void MapA2AHttpJson_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); @@ -50,202 +50,118 @@ public void MapA2A_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException // Act & Assert ArgumentNullException exception = Assert.Throws(() => - app.MapA2A(agentBuilder, "/a2a")); + app.MapA2AHttpJson(agentBuilder, "/a2a")); Assert.Equal("agentBuilder", exception.ParamName); } /// - /// Verifies that MapA2A with IHostedAgentBuilder correctly maps the agent with default configuration. + /// Verifies that MapA2AHttpJson with IHostedAgentBuilder correctly maps the agent with default configuration. /// [Fact] - public void MapA2A_WithAgentBuilder_DefaultConfiguration_Succeeds() + public void MapA2AHttpJson_WithAgentBuilder_DefaultConfiguration_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(); builder.Services.AddLogging(); using WebApplication app = builder.Build(); // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a"); + var result = app.MapA2AHttpJson(agentBuilder, "/a2a"); Assert.NotNull(result); } /// - /// Verifies that MapA2A with IHostedAgentBuilder and custom A2AHostingOptions succeeds. + /// Verifies that MapA2AHttpJson with string agent name correctly maps the agent. /// [Fact] - public void MapA2A_WithAgentBuilder_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.Services.AddLogging(); - using WebApplication app = builder.Build(); - - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", options => { }); - Assert.NotNull(result); - } - - /// - /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using string agent name. - /// - [Fact] - public void MapA2A_WithAgentName_NullEndpoints_ThrowsArgumentNullException() - { - // Arrange - AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; - - // Act & Assert - ArgumentNullException exception = Assert.Throws(() => - endpoints.MapA2A("agent", "/a2a")); - - Assert.Equal("endpoints", exception.ParamName); - } - - /// - /// Verifies that MapA2A with string agent name correctly maps the agent. - /// - [Fact] - public void MapA2A_WithAgentName_DefaultConfiguration_Succeeds() + public void MapA2AHttpJson_WithAgentName_DefaultConfiguration_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddA2AServer("agent"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a"); + var result = app.MapA2AHttpJson("agent", "/a2a"); Assert.NotNull(result); } /// - /// Verifies that MapA2A with string agent name and custom A2AHostingOptions succeeds. + /// Verifies that MapA2AJsonRpc with IHostedAgentBuilder correctly maps the agent. /// [Fact] - public void MapA2A_WithAgentName_CustomA2AHostingOptionsConfiguration_Succeeds() + public void MapA2AJsonRpc_WithAgentBuilder_DefaultConfiguration_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a", options => { }); - Assert.NotNull(result); - } - - /// - /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using AIAgent. - /// - [Fact] - public void MapA2A_WithAIAgent_NullEndpoints_ThrowsArgumentNullException() - { - // Arrange - AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; - - // Act & Assert - ArgumentNullException exception = Assert.Throws(() => - endpoints.MapA2A((AIAgent)null!, "/a2a")); - - Assert.Equal("endpoints", exception.ParamName); - } - - /// - /// Verifies that MapA2A with AIAgent correctly maps the agent. - /// - [Fact] - public void MapA2A_WithAIAgent_DefaultConfiguration_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(); builder.Services.AddLogging(); using WebApplication app = builder.Build(); - AIAgent agent = app.Services.GetRequiredKeyedService("agent"); // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a"); + var result = app.MapA2AJsonRpc(agentBuilder, "/a2a"); Assert.NotNull(result); } /// - /// Verifies that MapA2A with AIAgent and custom A2AHostingOptions succeeds. + /// Verifies that MapA2AJsonRpc with string agent name correctly maps the agent. /// [Fact] - public void MapA2A_WithAIAgent_CustomA2AHostingOptionsConfiguration_Succeeds() + public void MapA2AJsonRpc_WithAgentName_DefaultConfiguration_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddA2AServer("agent"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); - AIAgent agent = app.Services.GetRequiredKeyedService("agent"); // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a", options => { }); + var result = app.MapA2AJsonRpc("agent", "/a2a"); Assert.NotNull(result); } /// - /// Verifies that MapA2A with IHostedAgentBuilder and A2AHostingOptions with AgentRunMode succeeds. + /// Verifies that both MapA2AHttpJson and MapA2AJsonRpc can be called for the same agent. /// [Fact] - public void MapA2A_WithAgentBuilder_CustomOptionsAndRunMode_Succeeds() + public void MapA2AHttpJson_And_MapA2AJsonRpc_SameAgent_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(); builder.Services.AddLogging(); using WebApplication app = builder.Build(); // Act & Assert - Should not throw - var 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", options => options.AgentRunMode = AgentRunMode.DisallowBackground); - Assert.NotNull(result); + var httpResult = app.MapA2AHttpJson(agentBuilder, "/a2a"); + var rpcResult = app.MapA2AJsonRpc(agentBuilder, "/a2a"); + Assert.NotNull(httpResult); + Assert.NotNull(rpcResult); } /// /// Verifies that multiple agents can be mapped to different paths. /// [Fact] - public void MapA2A_MultipleAgents_Succeeds() + public void MapA2AHttpJson_MultipleAgents_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); @@ -253,12 +169,14 @@ public void MapA2A_MultipleAgents_Succeeds() builder.Services.AddKeyedSingleton("chat-client", mockChatClient); IHostedAgentBuilder agent1Builder = builder.AddAIAgent("agent1", "Instructions1", chatClientServiceKey: "chat-client"); IHostedAgentBuilder agent2Builder = builder.AddAIAgent("agent2", "Instructions2", chatClientServiceKey: "chat-client"); + agent1Builder.AddA2AServer(); + agent2Builder.AddA2AServer(); builder.Services.AddLogging(); using WebApplication app = builder.Build(); // Act & Assert - Should not throw - app.MapA2A(agent1Builder, "/a2a/agent1"); - app.MapA2A(agent2Builder, "/a2a/agent2"); + app.MapA2AHttpJson(agent1Builder, "/a2a/agent1"); + app.MapA2AHttpJson(agent2Builder, "/a2a/agent2"); Assert.NotNull(app); } @@ -266,350 +184,211 @@ public void MapA2A_MultipleAgents_Succeeds() /// Verifies that custom paths can be specified for A2A endpoints. /// [Fact] - public void MapA2A_WithCustomPath_AcceptsValidPath() + public void MapA2AHttpJson_WithCustomPath_AcceptsValidPath() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(); builder.Services.AddLogging(); using WebApplication app = builder.Build(); // Act & Assert - Should not throw - app.MapA2A(agentBuilder, "/custom/a2a/path"); + app.MapA2AHttpJson(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(); - - bool configureCallbackInvoked = false; - - // Act - app.MapA2A(agentBuilder, "/a2a", options => - { - configureCallbackInvoked = true; - Assert.NotNull(options); - }); - - // Assert - Assert.True(configureCallbackInvoked); - } - - /// - /// Verifies that MapA2A with JsonRpc protocolBindings succeeds. + /// Verifies that AddA2AServer with custom A2AServerRegistrationOptions succeeds. /// [Fact] - public void MapA2A_WithJsonRpcProtocol_Succeeds() + public void AddA2AServer_WithCustomOptions_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(options => options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported); builder.Services.AddLogging(); using WebApplication app = builder.Build(); // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", options => options.ProtocolBindings = A2AProtocolBinding.JsonRpc); + var result = app.MapA2AHttpJson(agentBuilder, "/a2a"); Assert.NotNull(result); } /// - /// Verifies that MapA2A with both protocols succeeds. + /// Verifies that MapA2AHttpJson throws ArgumentNullException for null endpoints when using string agent name. /// [Fact] - public void MapA2A_WithBothProtocols_Succeeds() + public void MapA2AHttpJson_WithAgentName_NullEndpoints_ThrowsArgumentNullException() { // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", options => options.ProtocolBindings = A2AProtocolBinding.HttpJson | A2AProtocolBinding.JsonRpc); - Assert.NotNull(result); - } + AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; - /// - /// 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 + ArgumentNullException exception = Assert.Throws(() => + endpoints.MapA2AHttpJson("agent", "/a2a")); - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", A2AProtocolBinding.HttpJson); - Assert.NotNull(result); + Assert.Equal("endpoints", exception.ParamName); } /// - /// Verifies that MapA2A with IHostedAgentBuilder and direct protocolBindings and run mode parameters succeeds. + /// Verifies that MapA2AJsonRpc throws ArgumentNullException for null endpoints when using string agent name. /// [Fact] - public void MapA2A_WithAgentBuilder_DirectProtocolAndRunMode_Succeeds() + public void MapA2AJsonRpc_WithAgentName_NullEndpoints_ThrowsArgumentNullException() { // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", A2AProtocolBinding.HttpJson, AgentRunMode.AllowBackgroundIfSupported); - Assert.NotNull(result); - } + AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; - /// - /// 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 + ArgumentNullException exception = Assert.Throws(() => + endpoints.MapA2AJsonRpc("agent", "/a2a")); - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", protocolBindings: null, agentRunMode: AgentRunMode.DisallowBackground); - Assert.NotNull(result); + Assert.Equal("endpoints", exception.ParamName); } /// - /// Verifies that MapA2A with string agent name and direct protocolBindings parameter succeeds. + /// Verifies that MapA2AHttpJson throws ArgumentNullException for null agentName. /// [Fact] - public void MapA2A_WithAgentName_DirectProtocol_Succeeds() + public void MapA2AHttpJson_WithAgentName_NullAgentName_ThrowsArgumentNullException() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); - // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a", A2AProtocolBinding.JsonRpc); - Assert.NotNull(result); - } - - /// - /// Verifies that MapA2A with string agent name and direct protocolBindings and run mode parameters succeeds. - /// - [Fact] - public void MapA2A_WithAgentName_DirectProtocolAndRunMode_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 + ArgumentNullException exception = Assert.Throws(() => + app.MapA2AHttpJson((string)null!, "/a2a")); - // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a", A2AProtocolBinding.HttpJson, AgentRunMode.AllowBackgroundIfSupported); - Assert.NotNull(result); + Assert.Equal("agentName", exception.ParamName); } /// - /// Verifies that MapA2A with AIAgent and direct protocolBindings parameter succeeds. + /// Verifies that MapA2AHttpJson throws ArgumentException for empty agentName. /// [Fact] - public void MapA2A_WithAIAgent_DirectProtocol_Succeeds() + public void MapA2AHttpJson_WithAgentName_EmptyAgentName_ThrowsArgumentException() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); - AIAgent agent = app.Services.GetRequiredKeyedService("agent"); - // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a", A2AProtocolBinding.HttpJson); - Assert.NotNull(result); + // Act & Assert + ArgumentException exception = Assert.Throws(() => + app.MapA2AHttpJson(string.Empty, "/a2a")); + + Assert.Equal("agentName", exception.ParamName); } /// - /// Verifies that MapA2A with AIAgent and direct protocolBindings and run mode parameters succeeds. + /// Verifies that MapA2AHttpJson throws ArgumentNullException for null path. /// [Fact] - public void MapA2A_WithAIAgent_DirectProtocolAndRunMode_Succeeds() + public void MapA2AHttpJson_NullPath_ThrowsArgumentNullException() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(); builder.Services.AddLogging(); using WebApplication app = builder.Build(); - AIAgent agent = app.Services.GetRequiredKeyedService("agent"); - // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a", A2AProtocolBinding.HttpJson, AgentRunMode.AllowBackgroundIfSupported); - Assert.NotNull(result); + // Act & Assert + Assert.Throws(() => + app.MapA2AHttpJson(agentBuilder, null!)); } /// - /// Verifies that MapA2A with AIAgent, null protocolBindings, and direct run mode defaults correctly. + /// Verifies that MapA2AHttpJson throws ArgumentException for whitespace-only path. /// [Fact] - public void MapA2A_WithAIAgent_NullProtocolAndDirectRunMode_Succeeds() + public void MapA2AHttpJson_WhitespacePath_ThrowsArgumentException() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - 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", protocolBindings: null, agentRunMode: AgentRunMode.DisallowBackground); - Assert.NotNull(result); - } - - /// - /// Verifies that MapA2A throws ArgumentNullException for null agentName (string overload with configureOptions). - /// - [Fact] - public void MapA2A_WithAgentName_NullAgentName_ThrowsArgumentNullException() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(); builder.Services.AddLogging(); using WebApplication app = builder.Build(); // Act & Assert - ArgumentNullException exception = Assert.Throws(() => - app.MapA2A((string)null!, "/a2a")); - - Assert.Equal("agentName", exception.ParamName); + Assert.Throws(() => + app.MapA2AHttpJson(agentBuilder, " ")); } /// - /// Verifies that MapA2A throws ArgumentNullException for null agentName (string overload with protocolBindings). + /// Verifies that AddA2AServer throws ArgumentNullException for null services. /// [Fact] - public void MapA2A_WithAgentName_NullAgentName_ProtocolOverload_ThrowsArgumentNullException() + public void AddA2AServer_NullServices_ThrowsArgumentNullException() { // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); + IServiceCollection services = null!; // Act & Assert ArgumentNullException exception = Assert.Throws(() => - app.MapA2A((string)null!, "/a2a", A2AProtocolBinding.HttpJson)); + services.AddA2AServer("agent")); - Assert.Equal("agentName", exception.ParamName); + Assert.Equal("services", exception.ParamName); } /// - /// Verifies that MapA2A throws ArgumentException for empty agentName (string overload with configureOptions). + /// Verifies that AddA2AServer throws ArgumentNullException for null agentName. /// [Fact] - public void MapA2A_WithAgentName_EmptyAgentName_ThrowsArgumentException() + public void AddA2AServer_NullAgentName_ThrowsArgumentNullException() { // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); + IServiceCollection services = new ServiceCollection(); // Act & Assert - ArgumentException exception = Assert.Throws(() => - app.MapA2A(string.Empty, "/a2a")); + ArgumentNullException exception = Assert.Throws(() => + services.AddA2AServer((string)null!)); Assert.Equal("agentName", exception.ParamName); } /// - /// Verifies that MapA2A throws ArgumentException for empty agentName (string overload with protocolBindings). + /// Verifies that AddA2AServer throws ArgumentException for empty agentName. /// [Fact] - public void MapA2A_WithAgentName_EmptyAgentName_ProtocolOverload_ThrowsArgumentException() + public void AddA2AServer_EmptyAgentName_ThrowsArgumentException() { // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); + IServiceCollection services = new ServiceCollection(); // Act & Assert ArgumentException exception = Assert.Throws(() => - app.MapA2A(string.Empty, "/a2a", A2AProtocolBinding.HttpJson)); + services.AddA2AServer(string.Empty)); Assert.Equal("agentName", exception.ParamName); } /// - /// Verifies that MapA2A throws ArgumentException for null path. + /// Verifies that AddA2AServer on IHostedAgentBuilder throws ArgumentNullException for null builder. /// [Fact] - public void MapA2A_WithAIAgent_NullPath_ThrowsArgumentException() + public void AddA2AServer_NullAgentBuilder_ThrowsArgumentNullException() { // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - AIAgent agent = app.Services.GetRequiredKeyedService("agent"); + IHostedAgentBuilder agentBuilder = null!; // Act & Assert - Assert.Throws(() => - app.MapA2A(agent, null!)); - } - - /// - /// 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"); + ArgumentNullException exception = Assert.Throws(() => + agentBuilder.AddA2AServer()); - // Act & Assert - Assert.Throws(() => - app.MapA2A(agent, " ")); + Assert.Equal("agentBuilder", exception.ParamName); } } From c9380a6f22842cee6b404dc5d246e6bcdf5a6314 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Tue, 21 Apr 2026 23:27:36 +0100 Subject: [PATCH 2/2] address copilot comments --- ...ft.Agents.AI.Hosting.A2A.AspNetCore.csproj | 4 +- .../A2AAgentHandlerTests.cs | 2 +- .../A2AEndpointRouteBuilderExtensionsTests.cs | 46 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj index 366dc48900..200aa29ccc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj @@ -16,7 +16,7 @@ true true - + @@ -28,7 +28,7 @@ - + Microsoft Agent Framework Hosting A2A ASP.NET Core diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs index e85c503589..65eeeb1fa6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs @@ -890,4 +890,4 @@ private sealed class EventCollector } private sealed class TestAgentSession : AgentSession; -} \ No newline at end of file +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs index ff3e2e3b0b..5c235e649f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs @@ -391,4 +391,50 @@ public void AddA2AServer_NullAgentBuilder_ThrowsArgumentNullException() Assert.Equal("agentBuilder", exception.ParamName); } + + /// + /// Verifies that MapA2AHttpJson throws InvalidOperationException when no A2AServer has been + /// registered for the specified agent via AddA2AServer. + /// + [Fact] + public void MapA2AHttpJson_WithoutAddA2AServer_ThrowsInvalidOperationException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert + InvalidOperationException exception = Assert.Throws(() => + app.MapA2AHttpJson("agent", "/a2a")); + + Assert.Contains("agent", exception.Message); + Assert.Contains("AddA2AServer", exception.Message); + } + + /// + /// Verifies that MapA2AJsonRpc throws InvalidOperationException when no A2AServer has been + /// registered for the specified agent via AddA2AServer. + /// + [Fact] + public void MapA2AJsonRpc_WithoutAddA2AServer_ThrowsInvalidOperationException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert + InvalidOperationException exception = Assert.Throws(() => + app.MapA2AJsonRpc("agent", "/a2a")); + + Assert.Contains("agent", exception.Message); + Assert.Contains("AddA2AServer", exception.Message); + } }