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; 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 @@ for (var i = 0; i < args.Length; i++) var builder = WebApplication.CreateBuilder(args); builder.Services.AddHttpClient().AddLogging(); -var app = builder.Build(); - -var httpClient = app.Services.GetRequiredService().CreateClient(); -var logger = app.Logger; IConfigurationRoot configuration = new ConfigurationBuilder() .AddEnvironmentVariables() @@ -39,7 +34,7 @@ IConfigurationRoot configuration = new ConfigurationBuilder() string? apiKey = configuration["OPENAI_API_KEY"]; string model = configuration["OPENAI_CHAT_MODEL_NAME"] ?? "gpt-5.4-mini"; string? endpoint = configuration["AZURE_AI_PROJECT_ENDPOINT"]; -string[] agentUrls = (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 @@ else 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 @@ builder.Services.AddKeyedSingleton("my-di-matchingname-agent", (sp, nam instructions: "you are a dependency inject agent. Tell me all about dependency injection."); }); +pirateAgentBuilder.AddA2AServer(); +knightsKnavesAgentBuilder.AddA2AServer(); + var app = builder.Build(); app.MapOpenApi(); @@ -155,9 +158,9 @@ app.UseSwaggerUI(options => options.SwaggerEndpoint("/openapi/v1.json", "Agents // Configure the HTTP request pipeline. app.UseExceptionHandler(); -// attach a2a with simple message communication -app.MapA2A(pirateAgentBuilder, path: "/a2a/pirate"); -app.MapA2A(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves"); +// 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; 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); - - 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); + return endpoints.MapA2AHttpJson(agent.Name, path); } - private static A2AServer CreateA2AServer(IEndpointRouteBuilder endpoints, AIAgent agent, A2AHostingOptions? options) + /// + /// Maps A2A HTTP+JSON endpoints for the agent with the specified name to the given path. + /// An for the agent must be registered first by calling + /// AddA2AServer during service registration. + /// + /// The to add the A2A endpoints to. + /// The name of the agent to use for A2A protocol integration. + /// The route path prefix for A2A endpoints. + /// An for further endpoint configuration. + public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, string agentName, string path) { - 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); - } + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentException.ThrowIfNullOrEmpty(agentName); + ArgumentException.ThrowIfNullOrWhiteSpace(path); - var loggerFactory = endpoints.ServiceProvider.GetService() ?? NullLoggerFactory.Instance; - var taskStore = endpoints.ServiceProvider.GetKeyedService(agent.Name) ?? new InMemoryTaskStore(); + 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 new A2AServer( - agentHandler, - taskStore, - new ChannelEventNotifier(), - loggerFactory.CreateLogger(), - options?.ServerOptions); + // 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); } - private static IEndpointConventionBuilder MapA2AEndpoints(IEndpointRouteBuilder endpoints, A2AServer a2aServer, string path, A2AProtocolBinding? protocolBindings) + /// + /// Maps A2A JSON-RPC endpoints for the specified agent to the given path. + /// An for the agent must be registered first by calling + /// AddA2AServer during service registration. + /// + /// The to add the A2A endpoints to. + /// The configuration builder for the agent. + /// The route path prefix for A2A endpoints. + /// An for further endpoint configuration. + public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path) { - protocolBindings ??= A2AProtocolBinding.HttpJson; + ArgumentNullException.ThrowIfNull(agentBuilder); - IEndpointConventionBuilder? result = null; + return endpoints.MapA2AJsonRpc(agentBuilder.Name, path); + } - if (protocolBindings.Value.HasFlag(A2AProtocolBinding.JsonRpc)) - { - result = endpoints.MapA2A(a2aServer, path); - } + /// + /// Maps A2A JSON-RPC endpoints for the specified agent to the given path. + /// An for the agent must be registered first by calling + /// AddA2AServer during service registration. + /// + /// The to add the A2A endpoints to. + /// The agent whose name identifies the registered A2A server. + /// The route path prefix for A2A endpoints. + /// An for further endpoint configuration. + public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, AIAgent agent, string path) + { + ArgumentNullException.ThrowIfNull(agent); + ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name)); - 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" }; + return endpoints.MapA2AJsonRpc(agent.Name, path); + } - result = endpoints.MapHttpA2A(a2aServer, stubAgentCard, path); - } + /// + /// Maps A2A JSON-RPC endpoints for the agent with the specified name to the given path. + /// An for the agent must be registered first by calling + /// AddA2AServer during service registration. + /// + /// The to add the A2A endpoints to. + /// The name of the agent to use for A2A protocol integration. + /// The route path prefix for A2A endpoints. + /// An for further endpoint configuration. + public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, string agentName, string path) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentException.ThrowIfNullOrEmpty(agentName); + ArgumentException.ThrowIfNullOrWhiteSpace(path); - return result ?? throw new InvalidOperationException("At least one A2A protocol binding must be specified."); + var a2aServer = endpoints.ServiceProvider.GetKeyedService(agentName) + ?? throw new InvalidOperationException( + $"No A2AServer is registered for agent '{agentName}'. " + + $"Call services.AddA2AServer(\"{agentName}\") or agentBuilder.AddA2AServer() during service registration to register one."); + + return endpoints.MapA2A(a2aServer, path); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj index 4829b56b9e..200aa29ccc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj @@ -1,9 +1,12 @@ - + $(TargetFrameworksCore) Microsoft.Agents.AI.Hosting.A2A.AspNetCore preview + + $(NoWarn);RT0002 @@ -13,7 +16,7 @@ true true - + @@ -21,7 +24,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/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 @@ using Microsoft.Shared.DiagnosticIds; 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..65eeeb1fa6 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 @@ using Moq.Protected; 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 sealed class AIAgentExtensionsTests { // 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 sealed class AIAgentExtensionsTests { 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 sealed class AIAgentExtensionsTests { 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 sealed class AIAgentExtensionsTests { 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 sealed class AIAgentExtensionsTests { // 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 sealed class AIAgentExtensionsTests { // 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 sealed class AIAgentExtensionsTests { // 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 sealed class AIAgentExtensionsTests { // 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 sealed class AIAgentExtensionsTests { 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 sealed class AIAgentExtensionsTests { // 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 sealed class AIAgentExtensionsTests { // 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 sealed class AIAgentExtensionsTests 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 sealed class AIAgentExtensionsTests 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 @@ public sealed class AIAgentExtensionsTests 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 sealed class AIAgentExtensionsTests { // 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 sealed class AIAgentExtensionsTests { // 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 sealed class AIAgentExtensionsTests { // 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 sealed class AIAgentExtensionsTests 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,6 +513,255 @@ public sealed class AIAgentExtensionsTests #pragma warning restore MEAI001 + /// + /// Verifies that when no session store is provided, the handler uses InMemoryAgentSessionStore + /// and can execute successfully. + /// + [Fact] + public async Task Handler_WithNullSessionStore_UsesInMemorySessionStoreAndExecutesSuccessfullyAsync() + { + // Arrange + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: null); + + // Act + var events = await CollectEventsAsync(handler, new RequestContext + { + StreamingResponse = false, + TaskId = "", + ContextId = "ctx-1", + Message = new Message + { + MessageId = "test-id", + Role = Role.User, + Parts = [new Part { Text = "Hello" }] + } + }); + + // Assert + Message message = Assert.Single(events.Messages); + Assert.Equal("Reply", message.Parts![0].Text); + } + + /// + /// Verifies that when a custom session store is provided, it is used instead of the + /// default InMemoryAgentSessionStore. + /// + [Fact] + public async Task Handler_WithCustomSessionStore_UsesProvidedSessionStoreAsync() + { + // Arrange + var mockSessionStore = new Mock(); + mockSessionStore + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new TestAgentSession()); + mockSessionStore + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(ValueTask.CompletedTask); + + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: mockSessionStore.Object); + + // Act + await InvokeExecuteAsync(handler, new RequestContext + { + StreamingResponse = false, + TaskId = "", + ContextId = "ctx-1", + Message = new Message + { + MessageId = "test-id", + Role = Role.User, + Parts = [new Part { Text = "Hello" }] + } + }); + + // Assert - verify the custom session store was called + mockSessionStore.Verify( + x => x.GetSessionAsync( + It.IsAny(), + It.Is(s => s == "ctx-1"), + It.IsAny()), + Times.Once); + mockSessionStore.Verify( + x => x.SaveSessionAsync( + It.IsAny(), + It.Is(s => s == "ctx-1"), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + /// + /// Verifies that when no session store is provided, the default InMemoryAgentSessionStore + /// persists sessions across multiple calls with the same context ID. + /// + [Fact] + public async Task Handler_WithNullSessionStore_SessionIsPersistedAcrossCallsAsync() + { + // Arrange - track how many times CreateSessionCoreAsync is called + int createSessionCallCount = 0; + var sessionInstance = new TestAgentSession(); + + Mock agentMock = new() { CallBase = true }; + agentMock.SetupGet(x => x.Name).Returns("TestAgent"); + agentMock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .Callback(() => Interlocked.Increment(ref createSessionCallCount)) + .ReturnsAsync(() => new TestAgentSession()); + agentMock + .Protected() + .Setup>("SerializeSessionCoreAsync", + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(System.Text.Json.JsonDocument.Parse("{}").RootElement); + agentMock + .Protected() + .Setup>("DeserializeSessionCoreAsync", + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(sessionInstance); + agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Reply")])); + + A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: null); + + var context = new RequestContext + { + StreamingResponse = false, + TaskId = "", + ContextId = "ctx-persistent", + Message = new Message + { + MessageId = "test-id", + Role = Role.User, + Parts = [new Part { Text = "Hello" }] + } + }; + + // Act - call twice with the same context ID + await InvokeExecuteAsync(handler, context); + await InvokeExecuteAsync(handler, context); + + // Assert - CreateSessionCoreAsync should be called once (first call creates, second retrieves from store) + Assert.Equal(1, createSessionCallCount); + } + + /// + /// Verifies that when the AllowBackgroundWhen delegate throws, the exception propagates + /// and the agent is not invoked. + /// + [Fact] + public async Task ExecuteAsync_DynamicMode_WhenCallbackThrows_PropagatesExceptionAsync() + { + // Arrange + bool agentInvoked = false; + A2AAgentHandler handler = CreateHandler( + CreateAgentMock(_ => agentInvoked = true), + runMode: AgentRunMode.AllowBackgroundWhen((_, _) => + throw new InvalidOperationException("Callback failed"))); + + // Act & Assert + await Assert.ThrowsAsync(() => + InvokeExecuteAsync(handler, new RequestContext + { + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + })); + + Assert.False(agentInvoked); + } + + /// + /// Verifies that the CancellationToken is propagated to the AllowBackgroundWhen delegate. + /// + [Fact] + public async Task ExecuteAsync_DynamicMode_CancellationTokenIsPropagatedToCallbackAsync() + { + // Arrange + CancellationToken capturedToken = default; + using var cts = new CancellationTokenSource(); + A2AAgentHandler handler = CreateHandler( + CreateAgentMock(_ => { }), + runMode: AgentRunMode.AllowBackgroundWhen((_, ct) => + { + capturedToken = ct; + return ValueTask.FromResult(false); + })); + + // Act + var eventQueue = new AgentEventQueue(); + await handler.ExecuteAsync( + new RequestContext + { + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }, + eventQueue, + cts.Token); + eventQueue.Complete(null); + + // Assert + Assert.Equal(cts.Token, capturedToken); + } + + /// + /// Verifies that the agent run mode is applied on the continuation/task-update path, + /// not just the new message path. + /// + [Fact] + public async Task ExecuteAsync_OnContinuation_RunModeIsAppliedAsync() + { + // Arrange + AgentRunOptions? capturedOptions = null; + A2AAgentHandler handler = CreateHandler( + CreateAgentMock(options => capturedOptions = options), + runMode: AgentRunMode.AllowBackgroundIfSupported); + + // Act + await InvokeExecuteAsync(handler, new RequestContext + { + StreamingResponse = false, + TaskId = "task-1", + ContextId = "ctx-1", + Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] }, + + Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] } + }); + + // Assert + Assert.NotNull(capturedOptions); + Assert.True(capturedOptions.AllowBackgroundResponses); + } + + private static A2AAgentHandler CreateHandler( + Mock agentMock, + AgentRunMode? runMode = null, + AgentSessionStore? agentSessionStore = null) + { + runMode ??= AgentRunMode.DisallowBackground; + + var hostAgent = new AIHostAgent( + innerAgent: agentMock.Object, + sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore()); + + return new A2AAgentHandler(hostAgent, runMode); + } + private static Mock CreateAgentMock(Action optionsCallback) { Mock agentMock = new() { CallBase = true }; @@ -604,14 +832,14 @@ public sealed class AIAgentExtensionsTests return agentMock; } - private static async Task InvokeExecuteAsync(IAgentHandler handler, RequestContext context) + 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(IAgentHandler handler, RequestContext context) + private static async Task CollectEventsAsync(A2AAgentHandler handler, RequestContext context) { var events = new EventCollector(); var eventQueue = new AgentEventQueue(); @@ -662,236 +890,4 @@ public sealed class AIAgentExtensionsTests } 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() - { - // 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()) - .Callback(() => Interlocked.Increment(ref createSessionCallCount)) - .ReturnsAsync(() => new TestAgentSession()); - agentMock - .Protected() - .Setup>("SerializeSessionCoreAsync", - ItExpr.IsAny(), - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(System.Text.Json.JsonDocument.Parse("{}").RootElement); - agentMock - .Protected() - .Setup>("DeserializeSessionCoreAsync", - ItExpr.IsAny(), - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(sessionInstance); - agentMock - .Protected() - .Setup>("RunCoreAsync", - ItExpr.IsAny>(), - ItExpr.IsAny(), - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Reply")])); - - IAgentHandler handler = agentMock.Object.MapA2A(agentSessionStore: null); - - var context = new RequestContext - { - StreamingResponse = false, - TaskId = "", - ContextId = "ctx-persistent", - Message = new Message - { - MessageId = "test-id", - Role = Role.User, - Parts = [new Part { Text = "Hello" }] - } - }; - - // Act - call twice with the same context ID - await InvokeExecuteAsync(handler, context); - await InvokeExecuteAsync(handler, context); - - // Assert - CreateSessionCoreAsync should be called once (first call creates, second retrieves from store) - Assert.Equal(1, createSessionCallCount); - } - - /// - /// Verifies that when the AllowBackgroundWhen delegate throws, the exception propagates - /// and the agent is not invoked. - /// - [Fact] - public async Task ExecuteAsync_DynamicMode_WhenCallbackThrows_PropagatesExceptionAsync() - { - // Arrange - bool agentInvoked = false; - 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/A2AEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs index 783fecea96..5c235e649f 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 @@ using Microsoft.Extensions.DependencyInjection; 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 sealed class A2AEndpointRouteBuilderExtensionsTests // 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 sealed class A2AEndpointRouteBuilderExtensionsTests // 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() + public void MapA2AHttpJson_WithAgentName_DefaultConfiguration_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddA2AServer("agent"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + var result = app.MapA2AHttpJson("agent", "/a2a"); + Assert.NotNull(result); + } + + /// + /// Verifies that MapA2AJsonRpc with IHostedAgentBuilder correctly maps the agent. + /// + [Fact] + public void MapA2AJsonRpc_WithAgentBuilder_DefaultConfiguration_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(); builder.Services.AddLogging(); using WebApplication app = builder.Build(); // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", options => { }); + var result = app.MapA2AJsonRpc(agentBuilder, "/a2a"); Assert.NotNull(result); } /// - /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using string agent name. + /// Verifies that MapA2AJsonRpc with string agent name correctly maps the agent. /// [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 MapA2AJsonRpc_WithAgentName_DefaultConfiguration_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); IChatClient mockChatClient = new DummyChatClient(); builder.Services.AddKeyedSingleton("chat-client", mockChatClient); builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddA2AServer("agent"); builder.Services.AddLogging(); using WebApplication app = builder.Build(); // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a"); + var result = app.MapA2AJsonRpc("agent", "/a2a"); Assert.NotNull(result); } /// - /// Verifies that MapA2A with string agent name and custom A2AHostingOptions succeeds. + /// Verifies that both MapA2AHttpJson and MapA2AJsonRpc can be called for the same agent. /// [Fact] - public void MapA2A_WithAgentName_CustomA2AHostingOptionsConfiguration_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"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - AIAgent agent = app.Services.GetRequiredKeyedService("agent"); - - // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a"); - Assert.NotNull(result); - } - - /// - /// Verifies that MapA2A with AIAgent and custom A2AHostingOptions succeeds. - /// - [Fact] - public void MapA2A_WithAIAgent_CustomA2AHostingOptionsConfiguration_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", options => { }); - Assert.NotNull(result); - } - - /// - /// Verifies that MapA2A with IHostedAgentBuilder and A2AHostingOptions with AgentRunMode succeeds. - /// - [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 sealed class A2AEndpointRouteBuilderExtensionsTests 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,246 +184,79 @@ public sealed class A2AEndpointRouteBuilderExtensionsTests /// 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. + /// Verifies that AddA2AServer with custom A2AServerRegistrationOptions succeeds. /// [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. - /// - [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(); + AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", options => options.ProtocolBindings = A2AProtocolBinding.HttpJson | A2AProtocolBinding.JsonRpc); - Assert.NotNull(result); + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + endpoints.MapA2AHttpJson("agent", "/a2a")); + + Assert.Equal("endpoints", exception.ParamName); } /// - /// Verifies that MapA2A with IHostedAgentBuilder and direct protocolBindings parameter succeeds. + /// Verifies that MapA2AJsonRpc throws ArgumentNullException for null endpoints when using string agent name. /// [Fact] - public void MapA2A_WithAgentBuilder_DirectProtocol_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(); + AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", A2AProtocolBinding.HttpJson); - Assert.NotNull(result); + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + endpoints.MapA2AJsonRpc("agent", "/a2a")); + + Assert.Equal("endpoints", exception.ParamName); } /// - /// Verifies that MapA2A with IHostedAgentBuilder and direct protocolBindings and run mode parameters succeeds. + /// Verifies that MapA2AHttpJson throws ArgumentNullException for null agentName. /// [Fact] - 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(); - 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 - Should not throw - var result = app.MapA2A("agent", "/a2a", A2AProtocolBinding.HttpJson, AgentRunMode.AllowBackgroundIfSupported); - Assert.NotNull(result); - } - - /// - /// Verifies that MapA2A with AIAgent and direct protocolBindings parameter succeeds. - /// - [Fact] - public void MapA2A_WithAIAgent_DirectProtocol_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", A2AProtocolBinding.HttpJson); - Assert.NotNull(result); - } - - /// - /// Verifies that MapA2A with AIAgent and direct protocolBindings and run mode parameters succeeds. - /// - [Fact] - public void MapA2A_WithAIAgent_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(); - AIAgent agent = app.Services.GetRequiredKeyedService("agent"); - - // 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", 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() + public void MapA2AHttpJson_WithAgentName_NullAgentName_ThrowsArgumentNullException() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); @@ -514,34 +265,16 @@ public sealed class A2AEndpointRouteBuilderExtensionsTests // Act & Assert ArgumentNullException exception = Assert.Throws(() => - app.MapA2A((string)null!, "/a2a")); + app.MapA2AHttpJson((string)null!, "/a2a")); Assert.Equal("agentName", exception.ParamName); } /// - /// Verifies that MapA2A throws ArgumentNullException for null agentName (string overload with protocolBindings). + /// Verifies that MapA2AHttpJson throws ArgumentException for empty agentName. /// [Fact] - public void MapA2A_WithAgentName_NullAgentName_ProtocolOverload_ThrowsArgumentNullException() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - // Act & Assert - ArgumentNullException exception = Assert.Throws(() => - app.MapA2A((string)null!, "/a2a", A2AProtocolBinding.HttpJson)); - - Assert.Equal("agentName", exception.ParamName); - } - - /// - /// Verifies that MapA2A throws ArgumentException for empty agentName (string overload with configureOptions). - /// - [Fact] - public void MapA2A_WithAgentName_EmptyAgentName_ThrowsArgumentException() + public void MapA2AHttpJson_WithAgentName_EmptyAgentName_ThrowsArgumentException() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); @@ -550,54 +283,121 @@ public sealed class A2AEndpointRouteBuilderExtensionsTests // Act & Assert ArgumentException exception = Assert.Throws(() => - app.MapA2A(string.Empty, "/a2a")); + app.MapA2AHttpJson(string.Empty, "/a2a")); Assert.Equal("agentName", exception.ParamName); } /// - /// Verifies that MapA2A throws ArgumentException for empty agentName (string overload with protocolBindings). + /// Verifies that MapA2AHttpJson throws ArgumentNullException for null path. /// [Fact] - public void MapA2A_WithAgentName_EmptyAgentName_ProtocolOverload_ThrowsArgumentException() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - // Act & Assert - ArgumentException exception = Assert.Throws(() => - app.MapA2A(string.Empty, "/a2a", A2AProtocolBinding.HttpJson)); - - Assert.Equal("agentName", exception.ParamName); - } - - /// - /// Verifies that MapA2A throws ArgumentException for null path. - /// - [Fact] - public void MapA2A_WithAIAgent_NullPath_ThrowsArgumentException() + 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 Assert.Throws(() => - app.MapA2A(agent, null!)); + app.MapA2AHttpJson(agentBuilder, null!)); } /// - /// Verifies that MapA2A throws ArgumentException for whitespace-only path. + /// Verifies that MapA2AHttpJson throws ArgumentException for whitespace-only path. /// [Fact] - public void MapA2A_WithAIAgent_WhitespacePath_ThrowsArgumentException() + public void MapA2AHttpJson_WhitespacePath_ThrowsArgumentException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert + Assert.Throws(() => + app.MapA2AHttpJson(agentBuilder, " ")); + } + + /// + /// Verifies that AddA2AServer throws ArgumentNullException for null services. + /// + [Fact] + public void AddA2AServer_NullServices_ThrowsArgumentNullException() + { + // Arrange + IServiceCollection services = null!; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + services.AddA2AServer("agent")); + + Assert.Equal("services", exception.ParamName); + } + + /// + /// Verifies that AddA2AServer throws ArgumentNullException for null agentName. + /// + [Fact] + public void AddA2AServer_NullAgentName_ThrowsArgumentNullException() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + services.AddA2AServer((string)null!)); + + Assert.Equal("agentName", exception.ParamName); + } + + /// + /// Verifies that AddA2AServer throws ArgumentException for empty agentName. + /// + [Fact] + public void AddA2AServer_EmptyAgentName_ThrowsArgumentException() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + + // Act & Assert + ArgumentException exception = Assert.Throws(() => + services.AddA2AServer(string.Empty)); + + Assert.Equal("agentName", exception.ParamName); + } + + /// + /// Verifies that AddA2AServer on IHostedAgentBuilder throws ArgumentNullException for null builder. + /// + [Fact] + public void AddA2AServer_NullAgentBuilder_ThrowsArgumentNullException() + { + // Arrange + IHostedAgentBuilder agentBuilder = null!; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + agentBuilder.AddA2AServer()); + + Assert.Equal("agentBuilder", exception.ParamName); + } + + /// + /// Verifies that MapA2AHttpJson throws InvalidOperationException when no A2AServer has been + /// registered for the specified agent via AddA2AServer. + /// + [Fact] + public void MapA2AHttpJson_WithoutAddA2AServer_ThrowsInvalidOperationException() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); @@ -606,10 +406,35 @@ public sealed class A2AEndpointRouteBuilderExtensionsTests 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, " ")); + 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); } }