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; }