.NET: Split A2A endpoint mapping into protocol-specific methods (#5413)

* .NET: Refactor A2A hosting registration into A2AServerServiceCollectionExtensions

- Rename A2AHostingOptions to A2AServerRegistrationOptions
- Move server registration logic from A2AEndpointRouteBuilderExtensions
  and AIAgentExtensions into new A2AServerServiceCollectionExtensions
- Remove A2AProtocolBinding and AIAgentExtensions (consolidated)
- Update samples and tests to use the new registration API

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* address copilot comments

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
SergeyMenshykh
2026-04-22 10:38:48 +01:00
committed by GitHub
Unverified
parent 275363d15e
commit c54483f81e
10 changed files with 746 additions and 891 deletions
@@ -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<IHttpClientFactory>().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<AITool> 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);
@@ -147,6 +147,9 @@ builder.Services.AddKeyedSingleton<AIAgent>("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();
@@ -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;
/// <summary>
/// Provides extension methods for configuring A2A endpoints for AI agents.
/// Provides extension methods for mapping A2A protocol endpoints for AI agents.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public static class A2AEndpointRouteBuilderExtensions
{
/// <summary>
/// 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 <see cref="A2AServer"/> for the agent must be registered first by calling
/// <c>AddA2AServer</c> during service registration.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
/// <param name="agentBuilder">The configuration builder for the agent.</param>
/// <param name="path">The route path prefix for A2A endpoints.</param>
/// <param name="protocolBindings">The A2A protocol binding(s) to expose. When <see langword="null"/>, defaults to <see cref="A2AProtocolBinding.HttpJson"/>.</param>
/// <param name="agentRunMode">The agent run mode that controls how the agent responds to A2A requests. When <see langword="null"/>, defaults to <see cref="AgentRunMode.DisallowBackground"/>.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
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);
}
/// <summary>
/// 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 <see cref="A2AServer"/> for the agent must be registered first by calling
/// <c>AddA2AServer</c> during service registration.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentBuilder">The configuration builder for <see cref="AIAgent"/>.</param>
/// <param name="agent">The agent whose name identifies the registered A2A server.</param>
/// <param name="path">The route path prefix for A2A endpoints.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AHostingOptions"/>.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, Action<A2AHostingOptions>? configureOptions = null)
public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, AIAgent agent, string path)
{
ArgumentNullException.ThrowIfNull(agentBuilder);
return endpoints.MapA2A(agentBuilder.Name, path, configureOptions);
}
/// <summary>
/// Maps A2A endpoints for the agent with the specified name to the given path.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route path prefix for A2A endpoints.</param>
/// <param name="protocolBindings">The A2A protocol binding(s) to expose. When <see langword="null"/>, defaults to <see cref="A2AProtocolBinding.HttpJson"/>.</param>
/// <param name="agentRunMode">The agent run mode that controls how the agent responds to A2A requests. When <see langword="null"/>, defaults to <see cref="AgentRunMode.DisallowBackground"/>.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
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<AIAgent>(agentName);
return endpoints.MapA2A(agent, path, protocolBindings, agentRunMode);
}
/// <summary>
/// Maps A2A endpoints for the agent with the specified name to the given path.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route path prefix for A2A endpoints.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AHostingOptions"/>.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, Action<A2AHostingOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentException.ThrowIfNullOrEmpty(agentName);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapA2A(agent, path, configureOptions);
}
/// <summary>
/// Maps A2A endpoints for the specified agent to the given path.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route path prefix for A2A endpoints.</param>
/// <param name="protocolBindings">The A2A protocol binding(s) to expose. When <see langword="null"/>, defaults to <see cref="A2AProtocolBinding.HttpJson"/>.</param>
/// <param name="agentRunMode">The agent run mode that controls how the agent responds to A2A requests. When <see langword="null"/>, defaults to <see cref="AgentRunMode.DisallowBackground"/>.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, A2AProtocolBinding? protocolBindings, AgentRunMode? agentRunMode = null)
{
Action<A2AHostingOptions>? 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);
}
/// <summary>
/// Maps A2A endpoints for the specified agent to the given path.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent to use for A2A protocol integration.</param>
/// <param name="path">The route path prefix for A2A endpoints.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AHostingOptions"/>.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action<A2AHostingOptions>? 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)
/// <summary>
/// Maps A2A HTTP+JSON endpoints for the agent with the specified name to the given path.
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
/// <c>AddA2AServer</c> during service registration.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route path prefix for A2A endpoints.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, string agentName, string path)
{
var agentHandler = endpoints.ServiceProvider.GetKeyedService<IAgentHandler>(agent.Name);
if (agentHandler is null)
{
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
agentHandler = agent.MapA2A(agentSessionStore: agentSessionStore, runMode: options?.AgentRunMode);
}
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentException.ThrowIfNullOrEmpty(agentName);
ArgumentException.ThrowIfNullOrWhiteSpace(path);
var loggerFactory = endpoints.ServiceProvider.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance;
var taskStore = endpoints.ServiceProvider.GetKeyedService<ITaskStore>(agent.Name) ?? new InMemoryTaskStore();
var a2aServer = endpoints.ServiceProvider.GetKeyedService<A2AServer>(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<A2AServer>(),
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)
/// <summary>
/// Maps A2A JSON-RPC endpoints for the specified agent to the given path.
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
/// <c>AddA2AServer</c> during service registration.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentBuilder">The configuration builder for the agent.</param>
/// <param name="path">The route path prefix for A2A endpoints.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
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);
}
/// <summary>
/// Maps A2A JSON-RPC endpoints for the specified agent to the given path.
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
/// <c>AddA2AServer</c> during service registration.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agent">The agent whose name identifies the registered A2A server.</param>
/// <param name="path">The route path prefix for A2A endpoints.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
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);
}
/// <summary>
/// Maps A2A JSON-RPC endpoints for the agent with the specified name to the given path.
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
/// <c>AddA2AServer</c> during service registration.
/// </summary>
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
/// <param name="agentName">The name of the agent to use for A2A protocol integration.</param>
/// <param name="path">The route path prefix for A2A endpoints.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
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<A2AServer>(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);
}
}
@@ -1,9 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<RootNamespace>Microsoft.Agents.AI.Hosting.A2A.AspNetCore</RootNamespace>
<VersionSuffix>preview</VersionSuffix>
<!-- RT0002: Microsoft.Agents.AI.Hosting.A2A is intentionally referenced as a transitive dependency
so that consumers of this package automatically get the AddA2AServer registration extensions. -->
<NoWarn>$(NoWarn);RT0002</NoWarn>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
@@ -13,7 +16,7 @@
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="A2A.AspNetCore" />
</ItemGroup>
@@ -21,7 +24,7 @@
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible($(TargetFramework), 'net10.0'))">
<PackageReference Include="System.Linq.AsyncEnumerable" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
</ItemGroup>
@@ -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;
/// <summary>
/// Specifies which A2A protocol binding(s) to expose when mapping A2A endpoints.
/// </summary>
/// <remarks>
/// This is a flags enum. Combine values using the bitwise OR operator to enable multiple bindings
/// (e.g., <c>A2AProtocolBinding.HttpJson | A2AProtocolBinding.JsonRpc</c>).
/// </remarks>
[Flags]
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public enum A2AProtocolBinding
{
/// <summary>
/// Expose the agent via the HTTP+JSON/REST protocol binding.
/// </summary>
HttpJson = 1,
/// <summary>
/// Expose the agent via the JSON-RPC protocol binding.
/// </summary>
JsonRpc = 2,
}
@@ -7,21 +7,11 @@ using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// Options for configuring A2A endpoint hosting behavior.
/// Options for configuring A2A server registration.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public sealed class A2AHostingOptions
public sealed class A2AServerRegistrationOptions
{
/// <summary>
/// Gets or sets the A2A protocol binding(s) to expose.
/// </summary>
/// <remarks>
/// When <see langword="null"/>, defaults to <see cref="A2AProtocolBinding.HttpJson"/>.
/// Use the bitwise OR operator to enable multiple bindings
/// (e.g., <c>A2AProtocolBinding.HttpJson | A2AProtocolBinding.JsonRpc</c>).
/// </remarks>
public A2AProtocolBinding? ProtocolBindings { get; set; }
/// <summary>
/// Gets or sets the agent run mode that controls how the agent responds to A2A requests.
/// </summary>
@@ -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;
/// <summary>
/// Provides extension methods for registering A2A server instances in the dependency injection container.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public static class A2AServerServiceCollectionExtensions
{
/// <summary>
/// Registers an <see cref="A2AServer"/> in the dependency injection container, keyed by the agent name
/// specified in the <paramref name="agentBuilder"/>. This method only registers the server; to expose it
/// as an HTTP endpoint, call one of the <c>MapA2AHttpJson</c> or <c>MapA2AJsonRpc</c> endpoint mapping
/// methods during application startup.
/// </summary>
/// <param name="agentBuilder">The agent builder whose name identifies the agent.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
/// <returns>The <paramref name="agentBuilder"/> for chaining.</returns>
public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(agentBuilder);
agentBuilder.ServiceCollection.AddA2AServer(agentBuilder.Name, configureOptions);
return agentBuilder;
}
/// <summary>
/// Registers an <see cref="A2AServer"/> 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
/// <c>MapA2AHttpJson</c> or <c>MapA2AJsonRpc</c> endpoint mapping methods during application startup.
/// </summary>
/// <param name="builder">The host application builder to configure.</param>
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddA2AServer(agentName, configureOptions);
return builder;
}
/// <summary>
/// Registers an <see cref="A2AServer"/> in the dependency injection container for the specified
/// <see cref="AIAgent"/> instance, keyed by the agent's <see cref="AIAgent.Name"/>. This method only
/// registers the server; to expose it as an HTTP endpoint, call one of the <c>MapA2AHttpJson</c> or
/// <c>MapA2AJsonRpc</c> endpoint mapping methods during application startup.
/// </summary>
/// <param name="builder">The host application builder to configure.</param>
/// <param name="agent">The agent instance to create an A2A server for.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.AddA2AServer(agent, configureOptions);
return builder;
}
/// <summary>
/// Registers an <see cref="A2AServer"/> 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
/// <c>MapA2AHttpJson</c> or <c>MapA2AJsonRpc</c> endpoint mapping methods during application startup.
/// </summary>
/// <param name="services">The service collection to add the A2A server to.</param>
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
/// <returns>The <paramref name="services"/> for chaining.</returns>
public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action<A2AServerRegistrationOptions>? 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<AIAgent>(agentName);
return CreateA2AServer(sp, agent, options);
});
return services;
}
/// <summary>
/// Registers an <see cref="A2AServer"/> in the dependency injection container for the specified
/// <see cref="AIAgent"/> instance, keyed by the agent's <see cref="AIAgent.Name"/>. This method only
/// registers the server; to expose it as an HTTP endpoint, call one of the <c>MapA2AHttpJson</c> or
/// <c>MapA2AJsonRpc</c> endpoint mapping methods during application startup.
/// </summary>
/// <param name="services">The service collection to add the A2A server to.</param>
/// <param name="agent">The agent instance to create an A2A server for.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
/// <returns>The <paramref name="services"/> for chaining.</returns>
public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action<A2AServerRegistrationOptions>? 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<IAgentHandler>(agent.Name);
if (agentHandler is null)
{
var agentSessionStore = serviceProvider.GetKeyedService<AgentSessionStore>(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<ILoggerFactory>() ?? NullLoggerFactory.Instance;
var taskStore = serviceProvider.GetKeyedService<ITaskStore>(agent.Name) ?? new InMemoryTaskStore();
return new A2AServer(
agentHandler,
taskStore,
new ChannelEventNotifier(),
loggerFactory.CreateLogger<A2AServer>(),
options?.ServerOptions);
}
}
@@ -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;
/// <summary>
/// Provides extension methods for attaching A2A (Agent2Agent) messaging capabilities to an <see cref="AIAgent"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public static class AIAgentExtensions
{
/// <summary>
/// Creates an <see cref="IAgentHandler"/> that bridges the specified <see cref="AIAgent"/> to
/// the A2A (Agent2Agent) protocol.
/// </summary>
/// <param name="agent">Agent to attach A2A messaging processing capabilities to.</param>
/// <param name="agentSessionStore">The store to store session contents and metadata.</param>
/// <param name="runMode">Controls the response behavior of the agent run.</param>
/// <returns>An <see cref="IAgentHandler"/> that handles A2A message execution and cancellation.</returns>
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);
}
}
@@ -13,36 +13,10 @@ using Moq.Protected;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AIAgentExtensions"/> class.
/// Unit tests for the <see cref="A2AAgentHandler"/> class.
/// </summary>
public sealed class AIAgentExtensionsTests
public sealed class A2AAgentHandlerTests
{
/// <summary>
/// Verifies that MapA2A throws ArgumentNullException for null agent.
/// </summary>
[Fact]
public void MapA2A_NullAgent_ThrowsArgumentNullException()
{
// Arrange
AIAgent agent = null!;
// Act & Assert
Assert.Throws<ArgumentNullException>(() => agent.MapA2A());
}
/// <summary>
/// Verifies that MapA2A returns a non-null IAgentHandler.
/// </summary>
[Fact]
public void MapA2A_ValidAgent_ReturnsNonNullHandler()
{
// Arrange & Act
IAgentHandler handler = CreateAgentMock(_ => { }).Object.MapA2A();
// Assert
Assert.NotNull(handler);
}
/// <summary>
/// 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<AIAgent> 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<AIAgent> 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<NotSupportedException>(() =>
@@ -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
/// <summary>
/// Verifies that when no session store is provided, the handler uses InMemoryAgentSessionStore
/// and can execute successfully.
/// </summary>
[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);
}
/// <summary>
/// Verifies that when a custom session store is provided, it is used instead of the
/// default InMemoryAgentSessionStore.
/// </summary>
[Fact]
public async Task Handler_WithCustomSessionStore_UsesProvidedSessionStoreAsync()
{
// Arrange
var mockSessionStore = new Mock<AgentSessionStore>();
mockSessionStore
.Setup(x => x.GetSessionAsync(
It.IsAny<AIAgent>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new TestAgentSession());
mockSessionStore
.Setup(x => x.SaveSessionAsync(
It.IsAny<AIAgent>(),
It.IsAny<string>(),
It.IsAny<AgentSession>(),
It.IsAny<CancellationToken>()))
.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<AIAgent>(),
It.Is<string>(s => s == "ctx-1"),
It.IsAny<CancellationToken>()),
Times.Once);
mockSessionStore.Verify(
x => x.SaveSessionAsync(
It.IsAny<AIAgent>(),
It.Is<string>(s => s == "ctx-1"),
It.IsAny<AgentSession>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verifies that when no session store is provided, the default InMemoryAgentSessionStore
/// persists sessions across multiple calls with the same context ID.
/// </summary>
[Fact]
public async Task Handler_WithNullSessionStore_SessionIsPersistedAcrossCallsAsync()
{
// Arrange - track how many times CreateSessionCoreAsync is called
int createSessionCallCount = 0;
var sessionInstance = new TestAgentSession();
Mock<AIAgent> agentMock = new() { CallBase = true };
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
agentMock
.Protected()
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
.Callback(() => Interlocked.Increment(ref createSessionCallCount))
.ReturnsAsync(() => new TestAgentSession());
agentMock
.Protected()
.Setup<ValueTask<System.Text.Json.JsonElement>>("SerializeSessionCoreAsync",
ItExpr.IsAny<AgentSession>(),
ItExpr.IsAny<System.Text.Json.JsonSerializerOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(System.Text.Json.JsonDocument.Parse("{}").RootElement);
agentMock
.Protected()
.Setup<ValueTask<AgentSession>>("DeserializeSessionCoreAsync",
ItExpr.IsAny<System.Text.Json.JsonElement>(),
ItExpr.IsAny<System.Text.Json.JsonSerializerOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(sessionInstance);
agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.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);
}
/// <summary>
/// Verifies that when the AllowBackgroundWhen delegate throws, the exception propagates
/// and the agent is not invoked.
/// </summary>
[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<InvalidOperationException>(() =>
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);
}
/// <summary>
/// Verifies that the CancellationToken is propagated to the AllowBackgroundWhen delegate.
/// </summary>
[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);
}
/// <summary>
/// Verifies that the agent run mode is applied on the continuation/task-update path,
/// not just the new message path.
/// </summary>
[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<AIAgent> 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<AIAgent> CreateAgentMock(Action<AgentRunOptions?> optionsCallback)
{
Mock<AIAgent> 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<EventCollector> CollectEventsAsync(IAgentHandler handler, RequestContext context)
private static async Task<EventCollector> 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;
/// <summary>
/// Verifies that when no session store is provided, MapA2A uses InMemoryAgentSessionStore
/// and the handler can execute successfully.
/// </summary>
[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);
}
/// <summary>
/// Verifies that when a custom session store is provided, it is used instead of the
/// default InMemoryAgentSessionStore.
/// </summary>
[Fact]
public async Task MapA2A_WithCustomSessionStore_UsesProvidedSessionStoreAsync()
{
// Arrange
var mockSessionStore = new Mock<AgentSessionStore>();
mockSessionStore
.Setup(x => x.GetSessionAsync(
It.IsAny<AIAgent>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new TestAgentSession());
mockSessionStore
.Setup(x => x.SaveSessionAsync(
It.IsAny<AIAgent>(),
It.IsAny<string>(),
It.IsAny<AgentSession>(),
It.IsAny<CancellationToken>()))
.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<AIAgent>(),
It.Is<string>(s => s == "ctx-1"),
It.IsAny<CancellationToken>()),
Times.Once);
mockSessionStore.Verify(
x => x.SaveSessionAsync(
It.IsAny<AIAgent>(),
It.Is<string>(s => s == "ctx-1"),
It.IsAny<AgentSession>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
/// <summary>
/// Verifies that when no session store is provided, the default InMemoryAgentSessionStore
/// persists sessions across multiple calls with the same context ID.
/// </summary>
[Fact]
public async Task MapA2A_WithNullSessionStore_SessionIsPersistedAcrossCallsAsync()
{
// Arrange - track how many times CreateSessionCoreAsync is called
int createSessionCallCount = 0;
var sessionInstance = new TestAgentSession();
Mock<AIAgent> agentMock = new() { CallBase = true };
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
agentMock
.Protected()
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
.Callback(() => Interlocked.Increment(ref createSessionCallCount))
.ReturnsAsync(() => new TestAgentSession());
agentMock
.Protected()
.Setup<ValueTask<System.Text.Json.JsonElement>>("SerializeSessionCoreAsync",
ItExpr.IsAny<AgentSession>(),
ItExpr.IsAny<System.Text.Json.JsonSerializerOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(System.Text.Json.JsonDocument.Parse("{}").RootElement);
agentMock
.Protected()
.Setup<ValueTask<AgentSession>>("DeserializeSessionCoreAsync",
ItExpr.IsAny<System.Text.Json.JsonElement>(),
ItExpr.IsAny<System.Text.Json.JsonSerializerOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(sessionInstance);
agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.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);
}
/// <summary>
/// Verifies that when the AllowBackgroundWhen delegate throws, the exception propagates
/// and the agent is not invoked.
/// </summary>
[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<InvalidOperationException>(() =>
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);
}
/// <summary>
/// Verifies that the CancellationToken is propagated to the AllowBackgroundWhen delegate.
/// </summary>
[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);
}
/// <summary>
/// Verifies that the agent run mode is applied on the continuation/task-update path,
/// not just the new message path.
/// </summary>
[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);
}
}
@@ -9,15 +9,15 @@ using Microsoft.Extensions.DependencyInjection;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
/// <summary>
/// Tests for A2AEndpointRouteBuilderExtensions.MapA2A method.
/// Tests for A2AEndpointRouteBuilderExtensions and A2AServerServiceCollectionExtensions methods.
/// </summary>
public sealed class A2AEndpointRouteBuilderExtensionsTests
{
/// <summary>
/// Verifies that MapA2A throws ArgumentNullException for null endpoints.
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null endpoints.
/// </summary>
[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<ArgumentNullException>(() =>
endpoints.MapA2A(agentBuilder, "/a2a"));
endpoints.MapA2AHttpJson(agentBuilder, "/a2a"));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2A throws ArgumentNullException for null agentBuilder.
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null agentBuilder.
/// </summary>
[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<ArgumentNullException>(() =>
app.MapA2A(agentBuilder, "/a2a"));
app.MapA2AHttpJson(agentBuilder, "/a2a"));
Assert.Equal("agentBuilder", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2A with IHostedAgentBuilder correctly maps the agent with default configuration.
/// Verifies that MapA2AHttpJson with IHostedAgentBuilder correctly maps the agent with default configuration.
/// </summary>
[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);
}
/// <summary>
/// Verifies that MapA2A with IHostedAgentBuilder and custom A2AHostingOptions succeeds.
/// Verifies that MapA2AHttpJson with string agent name correctly maps the agent.
/// </summary>
[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);
}
/// <summary>
/// Verifies that MapA2AJsonRpc with IHostedAgentBuilder correctly maps the agent.
/// </summary>
[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);
}
/// <summary>
/// Verifies that MapA2A throws ArgumentNullException for null endpoints when using string agent name.
/// Verifies that MapA2AJsonRpc with string agent name correctly maps the agent.
/// </summary>
[Fact]
public void MapA2A_WithAgentName_NullEndpoints_ThrowsArgumentNullException()
{
// Arrange
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
endpoints.MapA2A("agent", "/a2a"));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2A with string agent name correctly maps the agent.
/// </summary>
[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);
}
/// <summary>
/// Verifies that MapA2A with string agent name and custom A2AHostingOptions succeeds.
/// Verifies that both MapA2AHttpJson and MapA2AJsonRpc can be called for the same agent.
/// </summary>
[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);
}
/// <summary>
/// Verifies that MapA2A throws ArgumentNullException for null endpoints when using AIAgent.
/// </summary>
[Fact]
public void MapA2A_WithAIAgent_NullEndpoints_ThrowsArgumentNullException()
{
// Arrange
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
endpoints.MapA2A((AIAgent)null!, "/a2a"));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2A with AIAgent correctly maps the agent.
/// </summary>
[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<AIAgent>("agent");
// Act & Assert - Should not throw
var result = app.MapA2A(agent, "/a2a");
Assert.NotNull(result);
}
/// <summary>
/// Verifies that MapA2A with AIAgent and custom A2AHostingOptions succeeds.
/// </summary>
[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<AIAgent>("agent");
// Act & Assert - Should not throw
var result = app.MapA2A(agent, "/a2a", options => { });
Assert.NotNull(result);
}
/// <summary>
/// Verifies that MapA2A with IHostedAgentBuilder and A2AHostingOptions with AgentRunMode succeeds.
/// </summary>
[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);
}
/// <summary>
/// Verifies that MapA2A with string agentName and A2AHostingOptions with AgentRunMode succeeds.
/// </summary>
[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);
}
/// <summary>
/// Verifies that multiple agents can be mapped to different paths.
/// </summary>
[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.
/// </summary>
[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);
}
/// <summary>
/// Verifies that A2AHostingOptions configuration callback is invoked correctly.
/// Verifies that AddA2AServer with custom A2AServerRegistrationOptions succeeds.
/// </summary>
[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);
}
/// <summary>
/// Verifies that MapA2A with JsonRpc protocolBindings succeeds.
/// </summary>
[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);
}
/// <summary>
/// Verifies that MapA2A with both protocols succeeds.
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null endpoints when using string agent name.
/// </summary>
[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<ArgumentNullException>(() =>
endpoints.MapA2AHttpJson("agent", "/a2a"));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2A with IHostedAgentBuilder and direct protocolBindings parameter succeeds.
/// Verifies that MapA2AJsonRpc throws ArgumentNullException for null endpoints when using string agent name.
/// </summary>
[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<ArgumentNullException>(() =>
endpoints.MapA2AJsonRpc("agent", "/a2a"));
Assert.Equal("endpoints", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2A with IHostedAgentBuilder and direct protocolBindings and run mode parameters succeeds.
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null agentName.
/// </summary>
[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);
}
/// <summary>
/// Verifies that MapA2A with IHostedAgentBuilder, null protocolBindings, and direct run mode parameter succeeds.
/// </summary>
[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);
}
/// <summary>
/// Verifies that MapA2A with string agent name and direct protocolBindings parameter succeeds.
/// </summary>
[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);
}
/// <summary>
/// Verifies that MapA2A with string agent name and direct protocolBindings and run mode parameters succeeds.
/// </summary>
[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);
}
/// <summary>
/// Verifies that MapA2A with AIAgent and direct protocolBindings parameter succeeds.
/// </summary>
[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<AIAgent>("agent");
// Act & Assert - Should not throw
var result = app.MapA2A(agent, "/a2a", A2AProtocolBinding.HttpJson);
Assert.NotNull(result);
}
/// <summary>
/// Verifies that MapA2A with AIAgent and direct protocolBindings and run mode parameters succeeds.
/// </summary>
[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<AIAgent>("agent");
// Act & Assert - Should not throw
var result = app.MapA2A(agent, "/a2a", A2AProtocolBinding.HttpJson, AgentRunMode.AllowBackgroundIfSupported);
Assert.NotNull(result);
}
/// <summary>
/// Verifies that MapA2A with AIAgent, null protocolBindings, and direct run mode defaults correctly.
/// </summary>
[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<AIAgent>("agent");
// Act & Assert - Should not throw
var result = app.MapA2A(agent, "/a2a", protocolBindings: null, agentRunMode: AgentRunMode.DisallowBackground);
Assert.NotNull(result);
}
/// <summary>
/// Verifies that MapA2A throws ArgumentNullException for null agentName (string overload with configureOptions).
/// </summary>
[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<ArgumentNullException>(() =>
app.MapA2A((string)null!, "/a2a"));
app.MapA2AHttpJson((string)null!, "/a2a"));
Assert.Equal("agentName", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2A throws ArgumentNullException for null agentName (string overload with protocolBindings).
/// Verifies that MapA2AHttpJson throws ArgumentException for empty agentName.
/// </summary>
[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<ArgumentNullException>(() =>
app.MapA2A((string)null!, "/a2a", A2AProtocolBinding.HttpJson));
Assert.Equal("agentName", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2A throws ArgumentException for empty agentName (string overload with configureOptions).
/// </summary>
[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<ArgumentException>(() =>
app.MapA2A(string.Empty, "/a2a"));
app.MapA2AHttpJson(string.Empty, "/a2a"));
Assert.Equal("agentName", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2A throws ArgumentException for empty agentName (string overload with protocolBindings).
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null path.
/// </summary>
[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<ArgumentException>(() =>
app.MapA2A(string.Empty, "/a2a", A2AProtocolBinding.HttpJson));
Assert.Equal("agentName", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2A throws ArgumentException for null path.
/// </summary>
[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<AIAgent>("agent");
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
app.MapA2A(agent, null!));
app.MapA2AHttpJson(agentBuilder, null!));
}
/// <summary>
/// Verifies that MapA2A throws ArgumentException for whitespace-only path.
/// Verifies that MapA2AHttpJson throws ArgumentException for whitespace-only path.
/// </summary>
[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<ArgumentException>(() =>
app.MapA2AHttpJson(agentBuilder, " "));
}
/// <summary>
/// Verifies that AddA2AServer throws ArgumentNullException for null services.
/// </summary>
[Fact]
public void AddA2AServer_NullServices_ThrowsArgumentNullException()
{
// Arrange
IServiceCollection services = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
services.AddA2AServer("agent"));
Assert.Equal("services", exception.ParamName);
}
/// <summary>
/// Verifies that AddA2AServer throws ArgumentNullException for null agentName.
/// </summary>
[Fact]
public void AddA2AServer_NullAgentName_ThrowsArgumentNullException()
{
// Arrange
IServiceCollection services = new ServiceCollection();
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
services.AddA2AServer((string)null!));
Assert.Equal("agentName", exception.ParamName);
}
/// <summary>
/// Verifies that AddA2AServer throws ArgumentException for empty agentName.
/// </summary>
[Fact]
public void AddA2AServer_EmptyAgentName_ThrowsArgumentException()
{
// Arrange
IServiceCollection services = new ServiceCollection();
// Act & Assert
ArgumentException exception = Assert.Throws<ArgumentException>(() =>
services.AddA2AServer(string.Empty));
Assert.Equal("agentName", exception.ParamName);
}
/// <summary>
/// Verifies that AddA2AServer on IHostedAgentBuilder throws ArgumentNullException for null builder.
/// </summary>
[Fact]
public void AddA2AServer_NullAgentBuilder_ThrowsArgumentNullException()
{
// Arrange
IHostedAgentBuilder agentBuilder = null!;
// Act & Assert
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
agentBuilder.AddA2AServer());
Assert.Equal("agentBuilder", exception.ParamName);
}
/// <summary>
/// Verifies that MapA2AHttpJson throws InvalidOperationException when no A2AServer has been
/// registered for the specified agent via AddA2AServer.
/// </summary>
[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<AIAgent>("agent");
// Act & Assert
Assert.Throws<ArgumentException>(() =>
app.MapA2A(agent, " "));
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(() =>
app.MapA2AHttpJson("agent", "/a2a"));
Assert.Contains("agent", exception.Message);
Assert.Contains("AddA2AServer", exception.Message);
}
/// <summary>
/// Verifies that MapA2AJsonRpc throws InvalidOperationException when no A2AServer has been
/// registered for the specified agent via AddA2AServer.
/// </summary>
[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<InvalidOperationException>(() =>
app.MapA2AJsonRpc("agent", "/a2a"));
Assert.Contains("agent", exception.Message);
Assert.Contains("AddA2AServer", exception.Message);
}
}