diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs
index 943b6e4a5c..af3ff093ee 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs
@@ -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.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.AspNetCore.Builder;
///
/// Provides extension methods for configuring A2A (Agent2Agent) communication in a host application builder.
///
+[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
{
///
@@ -33,6 +36,20 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path)
=> endpoints.MapA2A(agentBuilder, path, _ => { });
+ ///
+ /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
+ ///
+ /// The to add the A2A endpoints to.
+ /// The configuration builder for .
+ /// The route group to use for A2A endpoints.
+ /// Controls the response behavior of the agent run.
+ /// Configured for A2A integration.
+ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentRunMode agentRunMode)
+ {
+ ArgumentNullException.ThrowIfNull(agentBuilder);
+ return endpoints.MapA2A(agentBuilder.Name, path, agentRunMode);
+ }
+
///
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
///
@@ -43,6 +60,21 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path)
=> endpoints.MapA2A(agentName, path, _ => { });
+ ///
+ /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
+ ///
+ /// The to add the A2A endpoints to.
+ /// The name of the agent to use for A2A protocol integration.
+ /// The route group to use for A2A endpoints.
+ /// Controls the response behavior of the agent run.
+ /// Configured for A2A integration.
+ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentRunMode agentRunMode)
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+ var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName);
+ return endpoints.MapA2A(agent, path, _ => { }, agentRunMode);
+ }
+
///
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
///
@@ -109,6 +141,37 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard)
=> endpoints.MapA2A(agentName, path, agentCard, _ => { });
+ ///
+ /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
+ ///
+ /// The to add the A2A endpoints to.
+ /// The configuration builder for .
+ /// The route group to use for A2A endpoints.
+ /// Agent card info to return on query.
+ /// Controls the response behavior of the agent run.
+ /// Configured for A2A integration.
+ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, AgentRunMode agentRunMode)
+ {
+ ArgumentNullException.ThrowIfNull(agentBuilder);
+ return endpoints.MapA2A(agentBuilder.Name, path, agentCard, agentRunMode);
+ }
+
+ ///
+ /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
+ ///
+ /// The to add the A2A endpoints to.
+ /// The name of the agent to use for A2A protocol integration.
+ /// The route group to use for A2A endpoints.
+ /// Agent card info to return on query.
+ /// Controls the response behavior of the agent run.
+ /// Configured for A2A integration.
+ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, AgentRunMode agentRunMode)
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+ var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName);
+ return endpoints.MapA2A(agent, path, agentCard, agentRunMode);
+ }
+
///
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
///
@@ -144,10 +207,28 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo
/// discovery mechanism.
///
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action configureTaskManager)
+ => endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground);
+
+ ///
+ /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
+ ///
+ /// The to add the A2A endpoints to.
+ /// The name of the agent to use for A2A protocol integration.
+ /// The route group to use for A2A endpoints.
+ /// Agent card info to return on query.
+ /// The callback to configure .
+ /// Controls the response behavior of the agent run.
+ /// Configured for A2A integration.
+ ///
+ /// This method can be used to access A2A agents that support the
+ /// Curated Registries (Catalog-Based Discovery)
+ /// discovery mechanism.
+ ///
+ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action configureTaskManager, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName);
- return endpoints.MapA2A(agent, path, agentCard, configureTaskManager);
+ return endpoints.MapA2A(agent, path, agentCard, configureTaskManager, agentRunMode);
}
///
@@ -160,6 +241,17 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path)
=> endpoints.MapA2A(agent, path, _ => { });
+ ///
+ /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
+ ///
+ /// The to add the A2A endpoints to.
+ /// The agent to use for A2A protocol integration.
+ /// The route group to use for A2A endpoints.
+ /// Controls the response behavior of the agent run.
+ /// Configured for A2A integration.
+ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentRunMode agentRunMode)
+ => endpoints.MapA2A(agent, path, _ => { }, agentRunMode);
+
///
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
///
@@ -169,13 +261,25 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo
/// The callback to configure .
/// Configured for A2A integration.
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager)
+ => endpoints.MapA2A(agent, path, configureTaskManager, AgentRunMode.DisallowBackground);
+
+ ///
+ /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
+ ///
+ /// The to add the A2A endpoints to.
+ /// The agent to use for A2A protocol integration.
+ /// The route group to use for A2A endpoints.
+ /// The callback to configure .
+ /// Controls the response behavior of the agent run.
+ /// Configured for A2A integration.
+ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agent);
var loggerFactory = endpoints.ServiceProvider.GetRequiredService();
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(agent.Name);
- var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore);
+ var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore, runMode: agentRunMode);
var endpointConventionBuilder = endpoints.MapA2A(taskManager, path);
configureTaskManager(taskManager);
@@ -198,6 +302,23 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard)
=> endpoints.MapA2A(agent, path, agentCard, _ => { });
+ ///
+ /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
+ ///
+ /// The to add the A2A endpoints to.
+ /// The agent to use for A2A protocol integration.
+ /// The route group to use for A2A endpoints.
+ /// Agent card info to return on query.
+ /// Controls the response behavior of the agent run.
+ /// Configured for A2A integration.
+ ///
+ /// This method can be used to access A2A agents that support the
+ /// Curated Registries (Catalog-Based Discovery)
+ /// discovery mechanism.
+ ///
+ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, AgentRunMode agentRunMode)
+ => endpoints.MapA2A(agent, path, agentCard, _ => { }, agentRunMode);
+
///
/// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
///
@@ -213,13 +334,31 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo
/// discovery mechanism.
///
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager)
+ => endpoints.MapA2A(agent, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground);
+
+ ///
+ /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application.
+ ///
+ /// The to add the A2A endpoints to.
+ /// The agent to use for A2A protocol integration.
+ /// The route group to use for A2A endpoints.
+ /// Agent card info to return on query.
+ /// The callback to configure .
+ /// Controls the response behavior of the agent run.
+ /// Configured for A2A integration.
+ ///
+ /// This method can be used to access A2A agents that support the
+ /// Curated Registries (Catalog-Based Discovery)
+ /// discovery mechanism.
+ ///
+ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager, AgentRunMode agentRunMode)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agent);
var loggerFactory = endpoints.ServiceProvider.GetRequiredService();
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(agent.Name);
- var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory);
+ var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory, runMode: agentRunMode);
var endpointConventionBuilder = endpoints.MapA2A(taskManager, path);
configureTaskManager(taskManager);
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj
index 093c5e0cfb..4829b56b9e 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj
@@ -8,6 +8,12 @@
+
+ true
+ true
+ true
+
+
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AHostingJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AHostingJsonUtilities.cs
new file mode 100644
index 0000000000..0a4bd98c65
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AHostingJsonUtilities.cs
@@ -0,0 +1,31 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text.Json;
+
+namespace Microsoft.Agents.AI.Hosting.A2A;
+
+///
+/// Provides JSON serialization options for A2A Hosting APIs to support AOT and trimming.
+///
+public static class A2AHostingJsonUtilities
+{
+ ///
+ /// Gets the default instance used for A2A Hosting serialization.
+ ///
+ 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;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs
new file mode 100644
index 0000000000..6ff49f6ecb
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using A2A;
+
+namespace Microsoft.Agents.AI.Hosting.A2A;
+
+///
+/// Provides context for a custom A2A run mode decision.
+///
+public sealed class A2ARunDecisionContext
+{
+ internal A2ARunDecisionContext(MessageSendParams messageSendParams)
+ {
+ this.MessageSendParams = messageSendParams;
+ }
+
+ ///
+ /// Gets the parameters of the incoming A2A message that triggered this run.
+ ///
+ public MessageSendParams MessageSendParams { get; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs
index da3fd782de..31c520755f 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs
@@ -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;
///
/// Provides extension methods for attaching A2A (Agent2Agent) messaging capabilities to an .
///
+[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
public static class AIAgentExtensions
{
+ // Metadata key used to store continuation tokens for long-running background operations
+ // in the AgentTask.Metadata dictionary, persisted by the task store.
+ private const string ContinuationTokenMetadataKey = "__a2a__continuationToken";
+
///
/// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified .
///
@@ -21,49 +31,45 @@ public static class AIAgentExtensions
/// Instance of to configure for A2A messaging. New instance will be created if not passed.
/// The logger factory to use for creating instances.
/// The store to store session contents and metadata.
+ /// Controls the response behavior of the agent run.
+ /// Optional for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to if not provided.
/// The configured .
public static ITaskManager MapA2A(
this AIAgent agent,
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;
- return taskManager;
- async Task 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() };
+ // Resolve the JSON serializer options for continuation token serialization. May be custom for the user's agent.
+ JsonSerializerOptions continuationTokenJsonOptions = jsonSerializerOptions ?? A2AHostingJsonUtilities.DefaultOptions;
- var response = await hostAgent.RunAsync(
- messageSendParams.ToChatMessages(),
- session: session,
- options: options,
- cancellationToken: cancellationToken).ConfigureAwait(false);
+ // 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);
- 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()
- };
- }
+ // Task flow for subsequent updates and cancellations
+ taskManager.OnTaskUpdated += (t, ct) => OnTaskUpdatedAsync(t, hostAgent, taskManager, continuationTokenJsonOptions, ct);
+ taskManager.OnTaskCancelled += OnTaskCancelledAsync;
+
+ return taskManager;
}
///
@@ -74,15 +80,19 @@ async Task OnMessageReceivedAsync(MessageSendParams messageSendPara
/// Instance of to configure for A2A messaging. New instance will be created if not passed.
/// The logger factory to use for creating instances.
/// The store to store session contents and metadata.
+ /// Controls the response behavior of the agent run.
+ /// Optional for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to if not provided.
/// The configured .
public static ITaskManager MapA2A(
this AIAgent agent,
AgentCard agentCard,
ITaskManager? taskManager = null,
ILoggerFactory? loggerFactory = null,
- AgentSessionStore? agentSessionStore = null)
+ 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 ITaskManager MapA2A(
};
return taskManager;
}
+
+ private static async Task OnMessageReceivedAsync(
+ MessageSendParams messageSendParams,
+ AIHostAgent hostAgent,
+ AgentRunMode runMode,
+ ITaskManager taskManager,
+ JsonSerializerOptions continuationTokenJsonOptions,
+ CancellationToken cancellationToken)
+ {
+ // AIAgent does not support resuming from arbitrary prior tasks.
+ // Throw explicitly so the client gets a clear error rather than a response
+ // that silently ignores the referenced task context.
+ // Follow-ups on the *same* task are handled via OnTaskUpdated instead.
+ if (messageSendParams.Message.ReferenceTaskIds is { Count: > 0 })
+ {
+ throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context. Use OnTaskUpdated for follow-ups on the same task.");
+ }
+
+ var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N");
+ var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
+
+ // Decide whether to run in background based on user preferences and agent capabilities
+ var decisionContext = new A2ARunDecisionContext(messageSendParams);
+ var allowBackgroundResponses = await runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
+
+ var options = messageSendParams.Metadata is not { Count: > 0 }
+ ? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
+ : new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() };
+
+ var response = await hostAgent.RunAsync(
+ messageSendParams.ToChatMessages(),
+ session: session,
+ options: options,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
+
+ if (response.ContinuationToken is null)
+ {
+ return CreateMessageFromResponse(contextId, response);
+ }
+
+ var agentTask = await InitializeTaskAsync(contextId, messageSendParams.Message, taskManager, cancellationToken).ConfigureAwait(false);
+ StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions);
+ await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false);
+ return agentTask;
+ }
+
+ private static async Task OnTaskUpdatedAsync(
+ AgentTask agentTask,
+ AIHostAgent hostAgent,
+ ITaskManager taskManager,
+ JsonSerializerOptions continuationTokenJsonOptions,
+ CancellationToken cancellationToken)
+ {
+ var contextId = agentTask.ContextId ?? Guid.NewGuid().ToString("N");
+ var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ // Discard any stale continuation token — the incoming user message supersedes
+ // any previous background operation. AF agents don't support updating existing
+ // background responses (long-running operations); we start a fresh run from the
+ // existing session using the full chat history (which includes the new message).
+ agentTask.Metadata?.Remove(ContinuationTokenMetadataKey);
+
+ await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ var response = await hostAgent.RunAsync(
+ ExtractChatMessagesFromTaskHistory(agentTask),
+ session: session,
+ options: new AgentRunOptions { AllowBackgroundResponses = true },
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
+
+ if (response.ContinuationToken is not null)
+ {
+ StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions);
+ await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ await CompleteWithArtifactAsync(agentTask.Id, response, taskManager, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception)
+ {
+ await taskManager.UpdateStatusAsync(
+ agentTask.Id,
+ TaskState.Failed,
+ final: true,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+ throw;
+ }
+ }
+
+ private static Task OnTaskCancelledAsync(AgentTask agentTask, CancellationToken cancellationToken)
+ {
+ // Remove the continuation token from metadata if present.
+ // The task has already been marked as cancelled by the TaskManager.
+ agentTask.Metadata?.Remove(ContinuationTokenMetadataKey);
+ return Task.CompletedTask;
+ }
+
+ private static AgentMessage CreateMessageFromResponse(string contextId, AgentResponse response) =>
+ new()
+ {
+ MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
+ ContextId = contextId,
+ Role = MessageRole.Agent,
+ Parts = response.Messages.ToParts(),
+ Metadata = response.AdditionalProperties?.ToA2AMetadata()
+ };
+
+ // Task outputs should be returned as artifacts rather than messages:
+ // https://a2a-protocol.org/latest/specification/#37-messages-and-artifacts
+ private static Artifact CreateArtifactFromResponse(AgentResponse response) =>
+ new()
+ {
+ ArtifactId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
+ Parts = response.Messages.ToParts(),
+ Metadata = response.AdditionalProperties?.ToA2AMetadata()
+ };
+
+ private static async Task InitializeTaskAsync(
+ string contextId,
+ AgentMessage originalMessage,
+ ITaskManager taskManager,
+ CancellationToken cancellationToken)
+ {
+ AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ // Add the original user message to the task history.
+ // The A2A SDK does this internally when it creates tasks via OnTaskCreated.
+ agentTask.History ??= [];
+ agentTask.History.Add(originalMessage);
+
+ // Notify subscribers of the Submitted state per the A2A spec: https://a2a-protocol.org/latest/specification/#413-taskstate
+ await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Submitted, cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ return agentTask;
+ }
+
+ private static void StoreContinuationToken(
+ AgentTask agentTask,
+ ResponseContinuationToken token,
+ JsonSerializerOptions continuationTokenJsonOptions)
+ {
+ // Serialize the continuation token into the task's metadata so it survives
+ // across requests and is cleaned up with the task itself.
+ agentTask.Metadata ??= [];
+ agentTask.Metadata[ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement(
+ token,
+ continuationTokenJsonOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
+ }
+
+ private static async Task TransitionToWorkingAsync(
+ string taskId,
+ string contextId,
+ AgentResponse response,
+ ITaskManager taskManager,
+ CancellationToken cancellationToken)
+ {
+ // Include any intermediate progress messages from the response as a status message.
+ AgentMessage? progressMessage = response.Messages.Count > 0 ? CreateMessageFromResponse(contextId, response) : null;
+ await taskManager.UpdateStatusAsync(taskId, TaskState.Working, message: progressMessage, cancellationToken: cancellationToken).ConfigureAwait(false);
+ }
+
+ private static async Task CompleteWithArtifactAsync(
+ string taskId,
+ AgentResponse response,
+ ITaskManager taskManager,
+ CancellationToken cancellationToken)
+ {
+ var artifact = CreateArtifactFromResponse(response);
+ await taskManager.ReturnArtifactAsync(taskId, artifact, cancellationToken).ConfigureAwait(false);
+ await taskManager.UpdateStatusAsync(taskId, TaskState.Completed, final: true, cancellationToken: cancellationToken).ConfigureAwait(false);
+ }
+
+ private static List ExtractChatMessagesFromTaskHistory(AgentTask agentTask)
+ {
+ if (agentTask.History is not { Count: > 0 })
+ {
+ return [];
+ }
+
+ var chatMessages = new List(agentTask.History.Count);
+ foreach (var message in agentTask.History)
+ {
+ chatMessages.Add(message.ToChatMessage());
+ }
+
+ return chatMessages;
+ }
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs
new file mode 100644
index 0000000000..087df96aae
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs
@@ -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;
+
+///
+/// Specifies how the A2A hosting layer determines whether to run in background or not.
+///
+[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
+public sealed class AgentRunMode : IEquatable
+{
+ private const string MessageValue = "message";
+ private const string TaskValue = "task";
+ private const string DynamicValue = "dynamic";
+
+ private readonly string _value;
+ private readonly Func>? _runInBackground;
+
+ private AgentRunMode(string value, Func>? runInBackground = null)
+ {
+ this._value = value;
+ this._runInBackground = runInBackground;
+ }
+
+ ///
+ /// Dissallows the background responses from the agent. Is equivalent to configuring as false.
+ /// In the A2A protocol terminology will make responses be returned as AgentMessage.
+ ///
+ public static AgentRunMode DisallowBackground => new(MessageValue);
+
+ ///
+ /// Allows the background responses from the agent. Is equivalent to configuring as true.
+ /// In the A2A protocol terminology will make responses be returned as AgentTask if the agent supports background responses, and as AgentMessage otherwise.
+ ///
+ public static AgentRunMode AllowBackgroundIfSupported => new(TaskValue);
+
+ ///
+ /// The agent run mode is decided by the supplied delegate.
+ /// The delegate receives an with the incoming
+ /// message and returns a boolean specifying whether to run the agent in background mode.
+ /// indicates that the agent should run in background mode and return an
+ /// AgentTask if the agent supports background mode; otherwise, it returns an AgentMessage
+ /// if the mode is not supported. indicates that the agent should run in
+ /// non-background mode and return an AgentMessage.
+ ///
+ ///
+ /// An async delegate that decides whether the response should be wrapped in an AgentTask.
+ ///
+ public static AgentRunMode AllowBackgroundWhen(Func> runInBackground)
+ {
+ ArgumentNullException.ThrowIfNull(runInBackground);
+ return new(DynamicValue, runInBackground);
+ }
+
+ ///
+ /// Determines whether the agent response should be returned as an AgentTask.
+ ///
+ internal ValueTask 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);
+ }
+
+ ///
+ public bool Equals(AgentRunMode? other) =>
+ other is not null && string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase);
+
+ ///
+ public override bool Equals(object? obj) => this.Equals(obj as AgentRunMode);
+
+ ///
+ public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(this._value);
+
+ ///
+ public override string ToString() => this._value;
+
+ /// Determines whether two instances are equal.
+ public static bool operator ==(AgentRunMode? left, AgentRunMode? right) =>
+ left?.Equals(right) ?? right is null;
+
+ /// Determines whether two instances are not equal.
+ public static bool operator !=(AgentRunMode? left, AgentRunMode? right) =>
+ !(left == right);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs
index d46ef72d1f..e557ff4e07 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/AdditionalPropertiesDictionaryExtensions.cs
@@ -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;
diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj
index a0d66cc1d5..3c805ee7a4 100644
--- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj
@@ -10,6 +10,8 @@
true
+ true
+ true
diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs
index 15a83ccd50..87de6e52cd 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs
@@ -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
{
///
- /// 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.
///
[Fact]
- public async Task MapA2A_WhenMetadataIsNull_PassesNullOptionsToRunAsync()
+ public async Task MapA2A_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
@@ -35,7 +37,9 @@ public async Task MapA2A_WhenMetadataIsNull_PassesNullOptionsToRunAsync()
});
// Assert
- Assert.Null(capturedOptions);
+ Assert.NotNull(capturedOptions);
+ Assert.False(capturedOptions.AllowBackgroundResponses);
+ Assert.Null(capturedOptions.AdditionalProperties);
}
///
@@ -68,11 +72,11 @@ public async Task MapA2A_WhenMetadataHasValues_PassesOptionsWithAdditionalProper
}
///
- /// 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.
///
[Fact]
- public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesNullOptionsToRunAsync()
+ public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
@@ -86,7 +90,9 @@ public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesNullOptionsToRunAsy
});
// Assert
- Assert.Null(capturedOptions);
+ Assert.NotNull(capturedOptions);
+ Assert.False(capturedOptions.AllowBackgroundResponses);
+ Assert.Null(capturedOptions.AdditionalProperties);
}
///
@@ -171,6 +177,590 @@ public async Task MapA2A_WhenResponseHasEmptyAdditionalProperties_ReturnsAgentMe
Assert.Null(agentMessage.Metadata);
}
+ ///
+ /// Verifies that when runMode is Message, the result is always an AgentMessage even when
+ /// the agent would otherwise support background responses.
+ ///
+ [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(a2aResponse);
+ Assert.NotNull(capturedOptions);
+ Assert.False(capturedOptions.AllowBackgroundResponses);
+ }
+
+ ///
+ /// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken),
+ /// the result is an AgentMessage because the response type is determined solely by ContinuationToken presence.
+ ///
+ [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(a2aResponse);
+ Assert.NotNull(capturedOptions);
+ Assert.True(capturedOptions.AllowBackgroundResponses);
+ }
+
+ ///
+ /// Verifies that a custom Dynamic delegate returning false produces an AgentMessage
+ /// even when the agent completes immediately (no ContinuationToken).
+ ///
+ [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(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.
+
+ ///
+ /// Verifies that when the agent returns a ContinuationToken, an AgentTask in Working state is returned.
+ ///
+ [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(a2aResponse);
+ Assert.Equal(TaskState.Working, agentTask.Status.State);
+ }
+
+ ///
+ /// Verifies that when the agent returns a ContinuationToken, the returned task includes
+ /// intermediate messages from the initial response in its status message.
+ ///
+ [Fact]
+ public async Task MapA2A_WhenResponseHasContinuationToken_TaskStatusHasIntermediateMessageAsync()
+ {
+ // Arrange
+ AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
+ {
+ ContinuationToken = CreateTestContinuationToken()
+ };
+ ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
+
+ // Act
+ A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ {
+ Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
+ });
+
+ // Assert
+ AgentTask agentTask = Assert.IsType(a2aResponse);
+ Assert.NotNull(agentTask.Status.Message);
+ TextPart textPart = Assert.IsType(Assert.Single(agentTask.Status.Message.Parts));
+ Assert.Equal("Starting work...", textPart.Text);
+ }
+
+ ///
+ /// Verifies that when the agent returns a ContinuationToken, the continuation token
+ /// is serialized into the AgentTask.Metadata for persistence.
+ ///
+ [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(a2aResponse);
+ Assert.NotNull(agentTask.Metadata);
+ Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
+ }
+
+ ///
+ /// 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.
+ ///
+ [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(a2aResponse);
+ Assert.NotNull(agentTask.History);
+ Assert.Contains(agentTask.History, m => m.MessageId == "user-msg-1" && m.Role == MessageRole.User);
+ }
+
+ ///
+ /// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken),
+ /// the returned AgentMessage preserves the original context ID.
+ ///
+ [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(a2aResponse);
+ Assert.Equal("ctx-123", agentMessage.ContextId);
+ }
+
+ ///
+ /// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token
+ /// and the agent returns a completed response (null ContinuationToken), the task is updated to Completed.
+ ///
+ [Fact]
+ public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationCompletes_TaskIsCompletedAsync()
+ {
+ // Arrange
+ int callCount = 0;
+ Mock agentMock = CreateAgentMockWithSequentialResponses(
+ // First call: return response with ContinuationToken (long-running)
+ new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
+ {
+ ContinuationToken = CreateTestContinuationToken()
+ },
+ // Second call (via OnTaskUpdated): return completed response
+ new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]),
+ ref callCount);
+ ITaskManager taskManager = agentMock.Object.MapA2A();
+
+ // Act — trigger OnMessageReceived to create the task
+ A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ {
+ Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
+ });
+ AgentTask agentTask = Assert.IsType(a2aResponse);
+ Assert.Equal(TaskState.Working, agentTask.Status.State);
+
+ // Act — invoke OnTaskUpdated to check on the background operation
+ await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
+
+ // Assert — task should now be completed
+ AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
+ Assert.NotNull(updatedTask);
+ Assert.Equal(TaskState.Completed, updatedTask.Status.State);
+ Assert.NotNull(updatedTask.Artifacts);
+ Artifact artifact = Assert.Single(updatedTask.Artifacts);
+ TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts));
+ Assert.Equal("Done!", textPart.Text);
+ }
+
+ ///
+ /// 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.
+ ///
+ [Fact]
+ public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationStillWorking_TaskRemainsWorkingAsync()
+ {
+ // Arrange
+ int callCount = 0;
+ Mock agentMock = CreateAgentMockWithSequentialResponses(
+ // First call: return response with ContinuationToken
+ new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
+ {
+ ContinuationToken = CreateTestContinuationToken()
+ },
+ // Second call (via OnTaskUpdated): still working, return another token
+ new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")])
+ {
+ ContinuationToken = CreateTestContinuationToken()
+ },
+ ref callCount);
+ ITaskManager taskManager = agentMock.Object.MapA2A();
+
+ // Act — trigger OnMessageReceived to create the task
+ A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ {
+ Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
+ });
+ AgentTask agentTask = Assert.IsType(a2aResponse);
+
+ // 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);
+ }
+
+ ///
+ /// Verifies the full lifecycle: agent starts background work, first poll returns still working,
+ /// second poll returns completed.
+ ///
+ [Fact]
+ public async Task MapA2A_OnTaskUpdated_MultiplePolls_EventuallyCompletesAsync()
+ {
+ // Arrange
+ int callCount = 0;
+ Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
+ {
+ return invocation switch
+ {
+ // First call: start background work
+ 1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
+ {
+ ContinuationToken = CreateTestContinuationToken()
+ },
+ // Second call: still working
+ 2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")])
+ {
+ 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(a2aResponse);
+ Assert.Equal(TaskState.Working, agentTask.Status.State);
+
+ // Act — first poll: still working
+ AgentTask? currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
+ Assert.NotNull(currentTask);
+ await InvokeOnTaskUpdatedAsync(taskManager, currentTask);
+ currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
+ Assert.NotNull(currentTask);
+ Assert.Equal(TaskState.Working, currentTask.Status.State);
+
+ // Act — second poll: completed
+ await InvokeOnTaskUpdatedAsync(taskManager, currentTask);
+ currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
+ Assert.NotNull(currentTask);
+ Assert.Equal(TaskState.Completed, currentTask.Status.State);
+
+ // Assert — final output as artifact
+ Assert.NotNull(currentTask.Artifacts);
+ Artifact artifact = Assert.Single(currentTask.Artifacts);
+ TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts));
+ Assert.Equal("All done!", textPart.Text);
+ }
+
+ ///
+ /// Verifies that when the agent throws during a background operation poll,
+ /// the task is updated to Failed state.
+ ///
+ [Fact]
+ public async Task MapA2A_OnTaskUpdated_WhenAgentThrows_TaskIsFailedAsync()
+ {
+ // Arrange
+ int callCount = 0;
+ Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
+ {
+ if (invocation == 1)
+ {
+ return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
+ {
+ ContinuationToken = CreateTestContinuationToken()
+ };
+ }
+
+ throw new InvalidOperationException("Agent failed");
+ });
+ ITaskManager taskManager = agentMock.Object.MapA2A();
+
+ // Act — create the task
+ A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ {
+ Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
+ });
+ AgentTask agentTask = Assert.IsType(a2aResponse);
+
+ // Act — poll the task; agent throws
+ await Assert.ThrowsAsync(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask));
+
+ // Assert — task should be Failed
+ AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
+ Assert.NotNull(updatedTask);
+ Assert.Equal(TaskState.Failed, updatedTask.Status.State);
+ }
+
+ ///
+ /// Verifies that in Task mode with a ContinuationToken, the result is an AgentTask in Working state.
+ ///
+ [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(a2aResponse);
+ Assert.Equal(TaskState.Working, agentTask.Status.State);
+ Assert.NotNull(agentTask.Metadata);
+ Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
+ }
+
+ ///
+ /// Verifies that when the agent returns a ContinuationToken with no progress messages,
+ /// the task transitions to Working state with a null status message.
+ ///
+ [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(a2aResponse);
+ Assert.Equal(TaskState.Working, agentTask.Status.State);
+ Assert.Null(agentTask.Status.Message);
+ }
+
+ ///
+ /// 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.
+ ///
+ [Fact]
+ public async Task MapA2A_OnTaskUpdated_WhenNoContinuationToken_ProcessesHistoryAndCompletesAsync()
+ {
+ // Arrange
+ int callCount = 0;
+ Mock 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(a2aResponse);
+
+ // Act — first OnTaskUpdated: completes the background operation
+ await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
+ agentTask = (await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None))!;
+ Assert.Equal(TaskState.Completed, agentTask.Status.State);
+
+ // Simulate a follow-up message by adding it to history and re-submitting via OnTaskUpdated
+ agentTask.History ??= [];
+ agentTask.History.Add(new AgentMessage { MessageId = "follow-up", Role = MessageRole.User, Parts = [new TextPart { Text = "Follow up" }] });
+
+ // Act — invoke OnTaskUpdated without a continuation token in metadata
+ await InvokeOnTaskUpdatedAsync(taskManager, agentTask);
+
+ // Assert
+ AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None);
+ Assert.NotNull(updatedTask);
+ Assert.Equal(TaskState.Completed, updatedTask.Status.State);
+ Assert.NotNull(updatedTask.Artifacts);
+ Assert.Equal(2, updatedTask.Artifacts.Count);
+ Artifact artifact = updatedTask.Artifacts[1];
+ TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts));
+ Assert.Equal("Follow-up done!", textPart.Text);
+ }
+
+ ///
+ /// Verifies that when a task is cancelled, the continuation token is removed from metadata.
+ ///
+ [Fact]
+ public async Task MapA2A_OnTaskCancelled_RemovesContinuationTokenFromMetadataAsync()
+ {
+ // Arrange
+ AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting...")])
+ {
+ ContinuationToken = CreateTestContinuationToken()
+ };
+ ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
+
+ // Act — create a working task with a continuation token
+ A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ {
+ Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
+ });
+ AgentTask agentTask = Assert.IsType(a2aResponse);
+ Assert.NotNull(agentTask.Metadata);
+ Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
+
+ // Act — cancel the task
+ await taskManager.CancelTaskAsync(new TaskIdParams { Id = agentTask.Id }, CancellationToken.None);
+
+ // Assert — continuation token should be removed from metadata
+ Assert.False(agentTask.Metadata.ContainsKey("__a2a__continuationToken"));
+ }
+
+ ///
+ /// Verifies that when the agent throws an OperationCanceledException during a poll,
+ /// it is re-thrown without marking the task as Failed.
+ ///
+ [Fact]
+ public async Task MapA2A_OnTaskUpdated_WhenOperationCancelled_DoesNotMarkFailedAsync()
+ {
+ // Arrange
+ int callCount = 0;
+ Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation =>
+ {
+ if (invocation == 1)
+ {
+ return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")])
+ {
+ ContinuationToken = CreateTestContinuationToken()
+ };
+ }
+
+ throw new OperationCanceledException("Cancelled");
+ });
+ ITaskManager taskManager = agentMock.Object.MapA2A();
+
+ // Act — create the task
+ A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
+ {
+ Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
+ });
+ AgentTask agentTask = Assert.IsType(a2aResponse);
+
+ // Act — poll the task; agent throws OperationCanceledException
+ await Assert.ThrowsAsync(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask));
+
+ // 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);
+ }
+
+ ///
+ /// Verifies that when the incoming message has a ContextId, it is used for the task
+ /// rather than generating a new one.
+ ///
+ [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(a2aResponse);
+ Assert.Equal("my-context-123", agentMessage.ContextId);
+ }
+
+#pragma warning restore MEAI001
+
private static Mock CreateAgentMock(Action optionsCallback)
{
Mock agentMock = new() { CallBase = true };
@@ -220,5 +810,57 @@ private static async Task InvokeOnMessageReceivedAsync(ITaskManager
return await handler.Invoke(messageSendParams, CancellationToken.None);
}
+ private static async Task InvokeOnTaskUpdatedAsync(ITaskManager taskManager, AgentTask agentTask)
+ {
+ Func? 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 CreateAgentMockWithSequentialResponses(
+ AgentResponse firstResponse,
+ AgentResponse secondResponse,
+ ref int callCount)
+ {
+ return CreateAgentMockWithCallCount(ref callCount, invocation =>
+ invocation == 1 ? firstResponse : secondResponse);
+ }
+
+ private static Mock CreateAgentMockWithCallCount(
+ ref int callCount,
+ Func responseFactory)
+ {
+ // Use a StrongBox to allow the lambda to capture a mutable reference
+ StrongBox callCountBox = new(callCount);
+
+ Mock agentMock = new() { CallBase = true };
+ agentMock.SetupGet(x => x.Name).Returns("TestAgent");
+ agentMock
+ .Protected()
+ .Setup>("CreateSessionCoreAsync", ItExpr.IsAny())
+ .ReturnsAsync(new TestAgentSession());
+ agentMock
+ .Protected()
+ .Setup>("RunCoreAsync",
+ ItExpr.IsAny>(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny(),
+ ItExpr.IsAny())
+ .ReturnsAsync(() =>
+ {
+ int currentCall = Interlocked.Increment(ref callCountBox.Value);
+ return responseFactory(currentCall);
+ });
+
+ return agentMock;
+ }
+
private sealed class TestAgentSession : AgentSession;
}