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