.NET: Implement Task support for A2A Hosting package (#3732)

* implement task support?

* some metadata + session store impl

* address PR comments x1

* API reivew

* llast changes

* More test

* remove unsued import

* fix moq override

* refactoring

* ontaskupdated

* adjust to delegate

* fix encoding

* address PR comments: rework

* init 1

* renaming

* fix tests

* fix comment

* runmode rename

* rename

* rename

* use exxperimental api, allow experimental on project level

* throw on refereceTaskIds
This commit is contained in:
Korolev Dmitry
2026-02-25 12:20:43 +01:00
committed by GitHub
Unverified
parent 4530504a3d
commit 2ba7ee9ce5
9 changed files with 1196 additions and 42 deletions
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using A2A;
using A2A.AspNetCore;
using Microsoft.Agents.AI;
@@ -10,12 +11,14 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.AspNetCore.Builder;
/// <summary>
/// Provides extension methods for configuring A2A (Agent2Agent) communication in a host application builder.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
{
/// <summary>
@@ -33,6 +36,20 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path)
=> endpoints.MapA2A(agentBuilder, path, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </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="path">The route group to use for A2A endpoints.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(agentBuilder);
return endpoints.MapA2A(agentBuilder.Name, path, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
@@ -43,6 +60,21 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path)
=> endpoints.MapA2A(agentName, path, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </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 group to use for A2A endpoints.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapA2A(agent, path, _ => { }, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
@@ -109,6 +141,37 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard)
=> endpoints.MapA2A(agentName, path, agentCard, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </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="path">The route group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(agentBuilder);
return endpoints.MapA2A(agentBuilder.Name, path, agentCard, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </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 group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapA2A(agent, path, agentCard, agentRunMode);
}
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
@@ -144,10 +207,28 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager)
=> endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </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 group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapA2A(agent, path, agentCard, configureTaskManager);
return endpoints.MapA2A(agent, path, agentCard, configureTaskManager, agentRunMode);
}
/// <summary>
@@ -160,6 +241,17 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path)
=> endpoints.MapA2A(agent, path, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </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 group to use for A2A endpoints.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentRunMode agentRunMode)
=> endpoints.MapA2A(agent, path, _ => { }, agentRunMode);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
@@ -169,13 +261,25 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action<ITaskManager> configureTaskManager)
=> endpoints.MapA2A(agent, path, configureTaskManager, AgentRunMode.DisallowBackground);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </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 group to use for A2A endpoints.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agent);
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore);
var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore, runMode: agentRunMode);
var endpointConventionBuilder = endpoints.MapA2A(taskManager, path);
configureTaskManager(taskManager);
@@ -198,6 +302,23 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard)
=> endpoints.MapA2A(agent, path, agentCard, _ => { });
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </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 group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, AgentRunMode agentRunMode)
=> endpoints.MapA2A(agent, path, agentCard, _ => { }, agentRunMode);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </summary>
@@ -213,13 +334,31 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager)
=> endpoints.MapA2A(agent, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground);
/// <summary>
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
/// </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 group to use for A2A endpoints.</param>
/// <param name="agentCard">Agent card info to return on query.</param>
/// <param name="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</param>
/// <param name="agentRunMode">Controls the response behavior of the agent run.</param>
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
/// <remarks>
/// This method can be used to access A2A agents that support the
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#2-curated-registries-catalog-based-discovery">Curated Registries (Catalog-Based Discovery)</see>
/// discovery mechanism.
/// </remarks>
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agent);
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory);
var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory, runMode: agentRunMode);
var endpointConventionBuilder = endpoints.MapA2A(taskManager, path);
configureTaskManager(taskManager);
@@ -8,6 +8,12 @@
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="A2A.AspNetCore" />
</ItemGroup>
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// Provides JSON serialization options for A2A Hosting APIs to support AOT and trimming.
/// </summary>
public static class A2AHostingJsonUtilities
{
/// <summary>
/// Gets the default <see cref="JsonSerializerOptions"/> instance used for A2A Hosting serialization.
/// </summary>
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
private static JsonSerializerOptions CreateDefaultOptions()
{
JsonSerializerOptions options = new(global::A2A.A2AJsonUtilities.DefaultOptions);
// Chain in the resolvers from both AgentAbstractionsJsonUtilities and the A2A SDK context.
// AgentAbstractionsJsonUtilities is first to ensure M.E.AI types (e.g. ResponseContinuationToken)
// are handled via its resolver, followed by the A2A SDK resolver for protocol types.
options.TypeInfoResolverChain.Clear();
options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.TypeInfoResolverChain.Add(global::A2A.A2AJsonUtilities.DefaultOptions.TypeInfoResolver!);
options.MakeReadOnly();
return options;
}
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using A2A;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// Provides context for a custom A2A run mode decision.
/// </summary>
public sealed class A2ARunDecisionContext
{
internal A2ARunDecisionContext(MessageSendParams messageSendParams)
{
this.MessageSendParams = messageSendParams;
}
/// <summary>
/// Gets the parameters of the incoming A2A message that triggered this run.
/// </summary>
public MessageSendParams MessageSendParams { get; }
}
@@ -1,19 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using A2A;
using Microsoft.Agents.AI.Hosting.A2A.Converters;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// Provides extension methods for attaching A2A (Agent2Agent) messaging capabilities to an <see cref="AIAgent"/>.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public static class AIAgentExtensions
{
// Metadata key used to store continuation tokens for long-running background operations
// in the AgentTask.Metadata dictionary, persisted by the task store.
private const string ContinuationTokenMetadataKey = "__a2a__continuationToken";
/// <summary>
/// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified <see cref="AIAgent"/>.
/// </summary>
@@ -21,49 +31,45 @@ public static class AIAgentExtensions
/// <param name="taskManager">Instance of <see cref="TaskManager"/> to configure for A2A messaging. New instance will be created if not passed.</param>
/// <param name="loggerFactory">The logger factory to use for creating <see cref="ILogger"/> instances.</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>
/// <param name="jsonSerializerOptions">Optional <see cref="JsonSerializerOptions"/> for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to <see cref="A2AHostingJsonUtilities.DefaultOptions"/> if not provided.</param>
/// <returns>The configured <see cref="TaskManager"/>.</returns>
public static ITaskManager MapA2A(
this AIAgent agent,
ITaskManager? taskManager = null,
ILoggerFactory? loggerFactory = null,
AgentSessionStore? agentSessionStore = null)
AgentSessionStore? agentSessionStore = null,
AgentRunMode? runMode = null,
JsonSerializerOptions? jsonSerializerOptions = null)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentNullException.ThrowIfNull(agent.Name);
runMode ??= AgentRunMode.DisallowBackground;
var hostAgent = new AIHostAgent(
innerAgent: agent,
sessionStore: agentSessionStore ?? new NoopAgentSessionStore());
taskManager ??= new TaskManager();
taskManager.OnMessageReceived += OnMessageReceivedAsync;
// Resolve the JSON serializer options for continuation token serialization. May be custom for the user's agent.
JsonSerializerOptions continuationTokenJsonOptions = jsonSerializerOptions ?? A2AHostingJsonUtilities.DefaultOptions;
// OnMessageReceived handles both message-only and task-based flows.
// The A2A SDK prioritizes OnMessageReceived over OnTaskCreated when both are set,
// so we consolidate all initial message handling here and return either
// an AgentMessage or AgentTask depending on the agent response.
// When the agent returns a ContinuationToken (long-running operation), a task is
// created for stateful tracking. Otherwise a lightweight AgentMessage is returned.
// See https://github.com/a2aproject/a2a-dotnet/issues/275
taskManager.OnMessageReceived += (p, ct) => OnMessageReceivedAsync(p, hostAgent, runMode, taskManager, continuationTokenJsonOptions, ct);
// Task flow for subsequent updates and cancellations
taskManager.OnTaskUpdated += (t, ct) => OnTaskUpdatedAsync(t, hostAgent, taskManager, continuationTokenJsonOptions, ct);
taskManager.OnTaskCancelled += OnTaskCancelledAsync;
return taskManager;
async Task<A2AResponse> OnMessageReceivedAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken)
{
var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N");
var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
var options = messageSendParams.Metadata is not { Count: > 0 }
? null
: new AgentRunOptions { AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() };
var response = await hostAgent.RunAsync(
messageSendParams.ToChatMessages(),
session: session,
options: options,
cancellationToken: cancellationToken).ConfigureAwait(false);
await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
var parts = response.Messages.ToParts();
return new AgentMessage
{
MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
ContextId = contextId,
Role = MessageRole.Agent,
Parts = parts,
Metadata = response.AdditionalProperties?.ToA2AMetadata()
};
}
}
/// <summary>
@@ -74,15 +80,19 @@ public static class AIAgentExtensions
/// <param name="taskManager">Instance of <see cref="TaskManager"/> to configure for A2A messaging. New instance will be created if not passed.</param>
/// <param name="loggerFactory">The logger factory to use for creating <see cref="ILogger"/> instances.</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>
/// <param name="jsonSerializerOptions">Optional <see cref="JsonSerializerOptions"/> for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to <see cref="A2AHostingJsonUtilities.DefaultOptions"/> if not provided.</param>
/// <returns>The configured <see cref="TaskManager"/>.</returns>
public static ITaskManager MapA2A(
this AIAgent agent,
AgentCard agentCard,
ITaskManager? taskManager = null,
ILoggerFactory? loggerFactory = null,
AgentSessionStore? agentSessionStore = null)
AgentSessionStore? agentSessionStore = null,
AgentRunMode? runMode = null,
JsonSerializerOptions? jsonSerializerOptions = null)
{
taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore);
taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore, runMode, jsonSerializerOptions);
taskManager.OnAgentCardQuery += (context, query) =>
{
@@ -97,4 +107,203 @@ public static class AIAgentExtensions
};
return taskManager;
}
private static async Task<A2AResponse> OnMessageReceivedAsync(
MessageSendParams messageSendParams,
AIHostAgent hostAgent,
AgentRunMode runMode,
ITaskManager taskManager,
JsonSerializerOptions continuationTokenJsonOptions,
CancellationToken cancellationToken)
{
// AIAgent does not support resuming from arbitrary prior tasks.
// Throw explicitly so the client gets a clear error rather than a response
// that silently ignores the referenced task context.
// Follow-ups on the *same* task are handled via OnTaskUpdated instead.
if (messageSendParams.Message.ReferenceTaskIds is { Count: > 0 })
{
throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context. Use OnTaskUpdated for follow-ups on the same task.");
}
var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N");
var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
// Decide whether to run in background based on user preferences and agent capabilities
var decisionContext = new A2ARunDecisionContext(messageSendParams);
var allowBackgroundResponses = await runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
var options = messageSendParams.Metadata is not { Count: > 0 }
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() };
var response = await hostAgent.RunAsync(
messageSendParams.ToChatMessages(),
session: session,
options: options,
cancellationToken: cancellationToken).ConfigureAwait(false);
await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
if (response.ContinuationToken is null)
{
return CreateMessageFromResponse(contextId, response);
}
var agentTask = await InitializeTaskAsync(contextId, messageSendParams.Message, taskManager, cancellationToken).ConfigureAwait(false);
StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions);
await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false);
return agentTask;
}
private static async Task OnTaskUpdatedAsync(
AgentTask agentTask,
AIHostAgent hostAgent,
ITaskManager taskManager,
JsonSerializerOptions continuationTokenJsonOptions,
CancellationToken cancellationToken)
{
var contextId = agentTask.ContextId ?? Guid.NewGuid().ToString("N");
var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
try
{
// Discard any stale continuation token — the incoming user message supersedes
// any previous background operation. AF agents don't support updating existing
// background responses (long-running operations); we start a fresh run from the
// existing session using the full chat history (which includes the new message).
agentTask.Metadata?.Remove(ContinuationTokenMetadataKey);
await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false);
var response = await hostAgent.RunAsync(
ExtractChatMessagesFromTaskHistory(agentTask),
session: session,
options: new AgentRunOptions { AllowBackgroundResponses = true },
cancellationToken: cancellationToken).ConfigureAwait(false);
await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
if (response.ContinuationToken is not null)
{
StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions);
await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false);
}
else
{
await CompleteWithArtifactAsync(agentTask.Id, response, taskManager, cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception)
{
await taskManager.UpdateStatusAsync(
agentTask.Id,
TaskState.Failed,
final: true,
cancellationToken: cancellationToken).ConfigureAwait(false);
throw;
}
}
private static Task OnTaskCancelledAsync(AgentTask agentTask, CancellationToken cancellationToken)
{
// Remove the continuation token from metadata if present.
// The task has already been marked as cancelled by the TaskManager.
agentTask.Metadata?.Remove(ContinuationTokenMetadataKey);
return Task.CompletedTask;
}
private static AgentMessage CreateMessageFromResponse(string contextId, AgentResponse response) =>
new()
{
MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
ContextId = contextId,
Role = MessageRole.Agent,
Parts = response.Messages.ToParts(),
Metadata = response.AdditionalProperties?.ToA2AMetadata()
};
// Task outputs should be returned as artifacts rather than messages:
// https://a2a-protocol.org/latest/specification/#37-messages-and-artifacts
private static Artifact CreateArtifactFromResponse(AgentResponse response) =>
new()
{
ArtifactId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
Parts = response.Messages.ToParts(),
Metadata = response.AdditionalProperties?.ToA2AMetadata()
};
private static async Task<AgentTask> InitializeTaskAsync(
string contextId,
AgentMessage originalMessage,
ITaskManager taskManager,
CancellationToken cancellationToken)
{
AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false);
// Add the original user message to the task history.
// The A2A SDK does this internally when it creates tasks via OnTaskCreated.
agentTask.History ??= [];
agentTask.History.Add(originalMessage);
// Notify subscribers of the Submitted state per the A2A spec: https://a2a-protocol.org/latest/specification/#413-taskstate
await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Submitted, cancellationToken: cancellationToken).ConfigureAwait(false);
return agentTask;
}
private static void StoreContinuationToken(
AgentTask agentTask,
ResponseContinuationToken token,
JsonSerializerOptions continuationTokenJsonOptions)
{
// Serialize the continuation token into the task's metadata so it survives
// across requests and is cleaned up with the task itself.
agentTask.Metadata ??= [];
agentTask.Metadata[ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement(
token,
continuationTokenJsonOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
}
private static async Task TransitionToWorkingAsync(
string taskId,
string contextId,
AgentResponse response,
ITaskManager taskManager,
CancellationToken cancellationToken)
{
// Include any intermediate progress messages from the response as a status message.
AgentMessage? progressMessage = response.Messages.Count > 0 ? CreateMessageFromResponse(contextId, response) : null;
await taskManager.UpdateStatusAsync(taskId, TaskState.Working, message: progressMessage, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static async Task CompleteWithArtifactAsync(
string taskId,
AgentResponse response,
ITaskManager taskManager,
CancellationToken cancellationToken)
{
var artifact = CreateArtifactFromResponse(response);
await taskManager.ReturnArtifactAsync(taskId, artifact, cancellationToken).ConfigureAwait(false);
await taskManager.UpdateStatusAsync(taskId, TaskState.Completed, final: true, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private static List<ChatMessage> ExtractChatMessagesFromTaskHistory(AgentTask agentTask)
{
if (agentTask.History is not { Count: > 0 })
{
return [];
}
var chatMessages = new List<ChatMessage>(agentTask.History.Count);
foreach (var message in agentTask.History)
{
chatMessages.Add(message.ToChatMessage());
}
return chatMessages;
}
}
@@ -0,0 +1,105 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Hosting.A2A;
/// <summary>
/// Specifies how the A2A hosting layer determines whether to run <see cref="AIAgent"/> in background or not.
/// </summary>
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public sealed class AgentRunMode : IEquatable<AgentRunMode>
{
private const string MessageValue = "message";
private const string TaskValue = "task";
private const string DynamicValue = "dynamic";
private readonly string _value;
private readonly Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>>? _runInBackground;
private AgentRunMode(string value, Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>>? runInBackground = null)
{
this._value = value;
this._runInBackground = runInBackground;
}
/// <summary>
/// Dissallows the background responses from the agent. Is equivalent to configuring <see cref="AgentRunOptions.AllowBackgroundResponses"/> as <c>false</c>.
/// In the A2A protocol terminology will make responses be returned as <c>AgentMessage</c>.
/// </summary>
public static AgentRunMode DisallowBackground => new(MessageValue);
/// <summary>
/// Allows the background responses from the agent. Is equivalent to configuring <see cref="AgentRunOptions.AllowBackgroundResponses"/> as <c>true</c>.
/// In the A2A protocol terminology will make responses be returned as <c>AgentTask</c> if the agent supports background responses, and as <c>AgentMessage</c> otherwise.
/// </summary>
public static AgentRunMode AllowBackgroundIfSupported => new(TaskValue);
/// <summary>
/// The agent run mode is decided by the supplied <paramref name="runInBackground"/> delegate.
/// The delegate receives an <see cref="A2ARunDecisionContext"/> with the incoming
/// message and returns a boolean specifying whether to run the agent in background mode.
/// <see langword="true"/> indicates that the agent should run in background mode and return an
/// <c>AgentTask</c> if the agent supports background mode; otherwise, it returns an <c>AgentMessage</c>
/// if the mode is not supported. <see langword="false"/> indicates that the agent should run in
/// non-background mode and return an <c>AgentMessage</c>.
/// </summary>
/// <param name="runInBackground">
/// An async delegate that decides whether the response should be wrapped in an <c>AgentTask</c>.
/// </param>
public static AgentRunMode AllowBackgroundWhen(Func<A2ARunDecisionContext, CancellationToken, ValueTask<bool>> runInBackground)
{
ArgumentNullException.ThrowIfNull(runInBackground);
return new(DynamicValue, runInBackground);
}
/// <summary>
/// Determines whether the agent response should be returned as an <c>AgentTask</c>.
/// </summary>
internal ValueTask<bool> ShouldRunInBackgroundAsync(A2ARunDecisionContext context, CancellationToken cancellationToken)
{
if (string.Equals(this._value, MessageValue, StringComparison.OrdinalIgnoreCase))
{
return ValueTask.FromResult(false);
}
if (string.Equals(this._value, TaskValue, StringComparison.OrdinalIgnoreCase))
{
return ValueTask.FromResult(true);
}
// Dynamic: delegate to custom callback.
if (this._runInBackground is not null)
{
return this._runInBackground(context, cancellationToken);
}
// No delegate provided — fall back to "message" behavior.
return ValueTask.FromResult(true);
}
/// <inheritdoc/>
public bool Equals(AgentRunMode? other) =>
other is not null && string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase);
/// <inheritdoc/>
public override bool Equals(object? obj) => this.Equals(obj as AgentRunMode);
/// <inheritdoc/>
public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(this._value);
/// <inheritdoc/>
public override string ToString() => this._value;
/// <summary>Determines whether two <see cref="AgentRunMode"/> instances are equal.</summary>
public static bool operator ==(AgentRunMode? left, AgentRunMode? right) =>
left?.Equals(right) ?? right is null;
/// <summary>Determines whether two <see cref="AgentRunMode"/> instances are not equal.</summary>
public static bool operator !=(AgentRunMode? left, AgentRunMode? right) =>
!(left == right);
}
@@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.Text.Json;
using A2A;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
@@ -37,7 +36,7 @@ internal static class AdditionalPropertiesDictionaryExtensions
continue;
}
metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
}
return metadata;
@@ -10,6 +10,8 @@
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -18,10 +19,11 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
public sealed class AIAgentExtensionsTests
{
/// <summary>
/// Verifies that when messageSendParams.Metadata is null, the options passed to RunAsync are null.
/// Verifies that when messageSendParams.Metadata is null, the options passed to RunAsync have
/// AllowBackgroundResponses enabled and no AdditionalProperties.
/// </summary>
[Fact]
public async Task MapA2A_WhenMetadataIsNull_PassesNullOptionsToRunAsync()
public async Task MapA2A_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
@@ -35,7 +37,9 @@ public sealed class AIAgentExtensionsTests
});
// Assert
Assert.Null(capturedOptions);
Assert.NotNull(capturedOptions);
Assert.False(capturedOptions.AllowBackgroundResponses);
Assert.Null(capturedOptions.AdditionalProperties);
}
/// <summary>
@@ -68,11 +72,11 @@ public sealed class AIAgentExtensionsTests
}
/// <summary>
/// Verifies that when messageSendParams.Metadata is an empty dictionary, the options passed to RunAsync is null
/// because the ToAdditionalProperties extension method returns null for empty dictionaries.
/// Verifies that when messageSendParams.Metadata is an empty dictionary, the options passed to RunAsync have
/// AllowBackgroundResponses enabled and no AdditionalProperties.
/// </summary>
[Fact]
public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesNullOptionsToRunAsync()
public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
@@ -86,7 +90,9 @@ public sealed class AIAgentExtensionsTests
});
// Assert
Assert.Null(capturedOptions);
Assert.NotNull(capturedOptions);
Assert.False(capturedOptions.AllowBackgroundResponses);
Assert.Null(capturedOptions.AdditionalProperties);
}
/// <summary>
@@ -171,6 +177,590 @@ public sealed class AIAgentExtensionsTests
Assert.Null(agentMessage.Metadata);
}
/// <summary>
/// Verifies that when runMode is Message, the result is always an AgentMessage even when
/// the agent would otherwise support background responses.
/// </summary>
[Fact]
public async Task MapA2A_MessageMode_AlwaysReturnsAgentMessageAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options)
.Object.MapA2A(runMode: AgentRunMode.DisallowBackground);
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
Assert.IsType<AgentMessage>(a2aResponse);
Assert.NotNull(capturedOptions);
Assert.False(capturedOptions.AllowBackgroundResponses);
}
/// <summary>
/// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken),
/// the result is an AgentMessage because the response type is determined solely by ContinuationToken presence.
/// </summary>
[Fact]
public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options)
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported);
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
Assert.IsType<AgentMessage>(a2aResponse);
Assert.NotNull(capturedOptions);
Assert.True(capturedOptions.AllowBackgroundResponses);
}
/// <summary>
/// Verifies that a custom Dynamic delegate returning false produces an AgentMessage
/// even when the agent completes immediately (no ContinuationToken).
/// </summary>
[Fact]
public async Task MapA2A_DynamicMode_WithFalseCallback_ReturnsAgentMessageAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Quick reply")]);
ITaskManager taskManager = CreateAgentMockWithResponse(response)
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false)));
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
Assert.IsType<AgentMessage>(a2aResponse);
}
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
/// <summary>
/// Verifies that when the agent returns a ContinuationToken, an AgentTask in Working state is returned.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasContinuationToken_ReturnsAgentTaskInWorkingStateAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.Equal(TaskState.Working, agentTask.Status.State);
}
/// <summary>
/// Verifies that when the agent returns a ContinuationToken, the returned task includes
/// intermediate messages from the initial response in its status message.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasContinuationToken_TaskStatusHasIntermediateMessageAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.NotNull(agentTask.Status.Message);
TextPart textPart = Assert.IsType<TextPart>(Assert.Single(agentTask.Status.Message.Parts));
Assert.Equal("Starting work...", textPart.Text);
}
/// <summary>
/// Verifies that when the agent returns a ContinuationToken, the continuation token
/// is serialized into the AgentTask.Metadata for persistence.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasContinuationToken_StoresTokenInTaskMetadataAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.NotNull(agentTask.Metadata);
Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
}
/// <summary>
/// Verifies that when a task is created (Working or Completed), the original user message
/// is added to the task history, matching the A2A SDK's behavior when it creates tasks internally.
/// </summary>
[Fact]
public async Task MapA2A_WhenTaskIsCreated_OriginalMessageIsInHistoryAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
AgentMessage originalMessage = new() { MessageId = "user-msg-1", Role = MessageRole.User, Parts = [new TextPart { Text = "Do something" }] };
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = originalMessage
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.NotNull(agentTask.History);
Assert.Contains(agentTask.History, m => m.MessageId == "user-msg-1" && m.Role == MessageRole.User);
}
/// <summary>
/// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken),
/// the returned AgentMessage preserves the original context ID.
/// </summary>
[Fact]
public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageWithContextIdAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]);
ITaskManager taskManager = CreateAgentMockWithResponse(response)
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported);
AgentMessage originalMessage = new() { MessageId = "user-msg-2", ContextId = "ctx-123", Role = MessageRole.User, Parts = [new TextPart { Text = "Quick task" }] };
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = originalMessage
});
// Assert
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
Assert.Equal("ctx-123", agentMessage.ContextId);
}
/// <summary>
/// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token
/// and the agent returns a completed response (null ContinuationToken), the task is updated to Completed.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationCompletes_TaskIsCompletedAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithSequentialResponses(
// First call: return response with ContinuationToken (long-running)
new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
},
// Second call (via OnTaskUpdated): return completed response
new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]),
ref callCount);
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — trigger OnMessageReceived to create the task
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.Equal(TaskState.Working, agentTask.Status.State);
// Act — invoke OnTaskUpdated to check on the background operation
await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
// Assert — task should now be completed
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(updatedTask);
Assert.Equal(TaskState.Completed, updatedTask.Status.State);
Assert.NotNull(updatedTask.Artifacts);
Artifact artifact = Assert.Single(updatedTask.Artifacts);
TextPart textPart = Assert.IsType<TextPart>(Assert.Single(artifact.Parts));
Assert.Equal("Done!", textPart.Text);
}
/// <summary>
/// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token
/// and the agent returns another ContinuationToken, the task stays in Working state.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationStillWorking_TaskRemainsWorkingAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithSequentialResponses(
// First call: return response with ContinuationToken
new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
},
// Second call (via OnTaskUpdated): still working, return another token
new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")])
{
ContinuationToken = CreateTestContinuationToken()
},
ref callCount);
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — trigger OnMessageReceived to create the task
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
// Act — invoke OnTaskUpdated; agent still working
await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
// Assert — task should still be in Working state
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(updatedTask);
Assert.Equal(TaskState.Working, updatedTask.Status.State);
}
/// <summary>
/// Verifies the full lifecycle: agent starts background work, first poll returns still working,
/// second poll returns completed.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_MultiplePolls_EventuallyCompletesAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
{
return invocation switch
{
// First call: start background work
1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
},
// Second call: still working
2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")])
{
ContinuationToken = CreateTestContinuationToken()
},
// Third call: done
_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "All done!")])
};
});
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — create the task
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Do work" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.Equal(TaskState.Working, agentTask.Status.State);
// Act — first poll: still working
AgentTask? currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(currentTask);
await InvokeOnTaskUpdatedAsync(taskManager, currentTask);
currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(currentTask);
Assert.Equal(TaskState.Working, currentTask.Status.State);
// Act — second poll: completed
await InvokeOnTaskUpdatedAsync(taskManager, currentTask);
currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(currentTask);
Assert.Equal(TaskState.Completed, currentTask.Status.State);
// Assert — final output as artifact
Assert.NotNull(currentTask.Artifacts);
Artifact artifact = Assert.Single(currentTask.Artifacts);
TextPart textPart = Assert.IsType<TextPart>(Assert.Single(artifact.Parts));
Assert.Equal("All done!", textPart.Text);
}
/// <summary>
/// Verifies that when the agent throws during a background operation poll,
/// the task is updated to Failed state.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_WhenAgentThrows_TaskIsFailedAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
{
if (invocation == 1)
{
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
};
}
throw new InvalidOperationException("Agent failed");
});
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — create the task
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
// Act — poll the task; agent throws
await Assert.ThrowsAsync<InvalidOperationException>(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask));
// Assert — task should be Failed
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(updatedTask);
Assert.Equal(TaskState.Failed, updatedTask.Status.State);
}
/// <summary>
/// Verifies that in Task mode with a ContinuationToken, the result is an AgentTask in Working state.
/// </summary>
[Fact]
public async Task MapA2A_TaskMode_WhenContinuationToken_ReturnsWorkingAgentTaskAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Working on it...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response)
.Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported);
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.Equal(TaskState.Working, agentTask.Status.State);
Assert.NotNull(agentTask.Metadata);
Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
}
/// <summary>
/// Verifies that when the agent returns a ContinuationToken with no progress messages,
/// the task transitions to Working state with a null status message.
/// </summary>
[Fact]
public async Task MapA2A_WhenContinuationTokenWithNoMessages_TaskStatusHasNullMessageAsync()
{
// Arrange
AgentResponse response = new([])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.Equal(TaskState.Working, agentTask.Status.State);
Assert.Null(agentTask.Status.Message);
}
/// <summary>
/// Verifies that when OnTaskUpdated is invoked on a completed task with a follow-up message
/// and no continuation token in metadata, the task processes history and completes with a new artifact.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_WhenNoContinuationToken_ProcessesHistoryAndCompletesAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
{
return invocation switch
{
// First call: create a task with ContinuationToken
1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
},
// Second call (via OnTaskUpdated): complete the background operation
2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]),
// Third call (follow-up via OnTaskUpdated): complete follow-up
_ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Follow-up done!")])
};
});
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — create a working task (with continuation token)
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
// Act — first OnTaskUpdated: completes the background operation
await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
agentTask = (await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None))!;
Assert.Equal(TaskState.Completed, agentTask.Status.State);
// Simulate a follow-up message by adding it to history and re-submitting via OnTaskUpdated
agentTask.History ??= [];
agentTask.History.Add(new AgentMessage { MessageId = "follow-up", Role = MessageRole.User, Parts = [new TextPart { Text = "Follow up" }] });
// Act — invoke OnTaskUpdated without a continuation token in metadata
await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
// Assert
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(updatedTask);
Assert.Equal(TaskState.Completed, updatedTask.Status.State);
Assert.NotNull(updatedTask.Artifacts);
Assert.Equal(2, updatedTask.Artifacts.Count);
Artifact artifact = updatedTask.Artifacts[1];
TextPart textPart = Assert.IsType<TextPart>(Assert.Single(artifact.Parts));
Assert.Equal("Follow-up done!", textPart.Text);
}
/// <summary>
/// Verifies that when a task is cancelled, the continuation token is removed from metadata.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskCancelled_RemovesContinuationTokenFromMetadataAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act — create a working task with a continuation token
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
Assert.NotNull(agentTask.Metadata);
Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
// Act — cancel the task
await taskManager.CancelTaskAsync(new TaskIdParams { Id = agentTask.Id }, CancellationToken.None);
// Assert — continuation token should be removed from metadata
Assert.False(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
}
/// <summary>
/// Verifies that when the agent throws an OperationCanceledException during a poll,
/// it is re-thrown without marking the task as Failed.
/// </summary>
[Fact]
public async Task MapA2A_OnTaskUpdated_WhenOperationCancelled_DoesNotMarkFailedAsync()
{
// Arrange
int callCount = 0;
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
{
if (invocation == 1)
{
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
{
ContinuationToken = CreateTestContinuationToken()
};
}
throw new OperationCanceledException("Cancelled");
});
ITaskManager taskManager = agentMock.Object.MapA2A();
// Act — create the task
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
AgentTask agentTask = Assert.IsType<AgentTask>(a2aResponse);
// Act — poll the task; agent throws OperationCanceledException
await Assert.ThrowsAsync<OperationCanceledException>(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask));
// Assert — task should still be Working, not Failed
AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
Assert.NotNull(updatedTask);
Assert.Equal(TaskState.Working, updatedTask.Status.State);
}
/// <summary>
/// Verifies that when the incoming message has a ContextId, it is used for the task
/// rather than generating a new one.
/// </summary>
[Fact]
public async Task MapA2A_WhenMessageHasContextId_UsesProvidedContextIdAsync()
{
// Arrange
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage
{
MessageId = "test-id",
ContextId = "my-context-123",
Role = MessageRole.User,
Parts = [new TextPart { Text = "Hello" }]
}
});
// Assert
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
Assert.Equal("my-context-123", agentMessage.ContextId);
}
#pragma warning restore MEAI001
private static Mock<AIAgent> CreateAgentMock(Action<AgentRunOptions?> optionsCallback)
{
Mock<AIAgent> agentMock = new() { CallBase = true };
@@ -220,5 +810,57 @@ public sealed class AIAgentExtensionsTests
return await handler.Invoke(messageSendParams, CancellationToken.None);
}
private static async Task InvokeOnTaskUpdatedAsync(ITaskManager taskManager, AgentTask agentTask)
{
Func<AgentTask, CancellationToken, Task>? handler = taskManager.OnTaskUpdated;
Assert.NotNull(handler);
await handler.Invoke(agentTask, CancellationToken.None);
}
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
private static ResponseContinuationToken CreateTestContinuationToken()
{
return ResponseContinuationToken.FromBytes(new byte[] { 0x01, 0x02, 0x03 });
}
#pragma warning restore MEAI001
private static Mock<AIAgent> CreateAgentMockWithSequentialResponses(
AgentResponse firstResponse,
AgentResponse secondResponse,
ref int callCount)
{
return CreateAgentMockWithCallCount(ref callCount, invocation =>
invocation == 1 ? firstResponse : secondResponse);
}
private static Mock<AIAgent> CreateAgentMockWithCallCount(
ref int callCount,
Func<int, AgentResponse> responseFactory)
{
// Use a StrongBox to allow the lambda to capture a mutable reference
StrongBox<int> callCountBox = new(callCount);
Mock<AIAgent> agentMock = new() { CallBase = true };
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
agentMock
.Protected()
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new TestAgentSession());
agentMock
.Protected()
.Setup<Task<AgentResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentSession?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(() =>
{
int currentCall = Interlocked.Increment(ref callCountBox.Value);
return responseFactory(currentCall);
});
return agentMock;
}
private sealed class TestAgentSession : AgentSession;
}