diff --git a/.devcontainer/dotnet/devcontainer.json b/.devcontainer/dotnet/devcontainer.json
index 6ec44f9004..57bf3b4a11 100644
--- a/.devcontainer/dotnet/devcontainer.json
+++ b/.devcontainer/dotnet/devcontainer.json
@@ -1,10 +1,6 @@
{
"name": "C# (.NET)",
- //"image": "mcr.microsoft.com/devcontainers/dotnet",
- // Workaround for https://github.com/devcontainers/images/issues/1752
- "build": {
- "dockerfile": "dotnet.Dockerfile"
- },
+ "image": "mcr.microsoft.com/devcontainers/dotnet",
"features": {
"ghcr.io/devcontainers/features/azure-cli:1.2.9": {},
"ghcr.io/devcontainers/features/github-cli:1": {
diff --git a/.devcontainer/dotnet/dotnet.Dockerfile b/.devcontainer/dotnet/dotnet.Dockerfile
deleted file mode 100644
index 344e4ee813..0000000000
--- a/.devcontainer/dotnet/dotnet.Dockerfile
+++ /dev/null
@@ -1,5 +0,0 @@
-FROM mcr.microsoft.com/devcontainers/universal:latest
-
-# Remove Yarn repository with expired GPG key to prevent apt-get update failures
-# Tracking issue: https://github.com/devcontainers/images/issues/1752
-RUN rm -f /etc/apt/sources.list.d/yarn.list
\ No newline at end of file
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 2db09a8b98..e0423ab908 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -183,6 +183,7 @@
+
diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step21_BingCustomSearch/FoundryAgents_Step21_BingCustomSearch.csproj b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step21_BingCustomSearch/FoundryAgents_Step21_BingCustomSearch.csproj
new file mode 100644
index 0000000000..730d284bd9
--- /dev/null
+++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step21_BingCustomSearch/FoundryAgents_Step21_BingCustomSearch.csproj
@@ -0,0 +1,22 @@
+
+
+
+ Exe
+ net10.0
+
+ enable
+ enable
+ $(NoWarn);CA1812;CS8321
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step21_BingCustomSearch/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step21_BingCustomSearch/Program.cs
new file mode 100644
index 0000000000..33ee4ec511
--- /dev/null
+++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step21_BingCustomSearch/Program.cs
@@ -0,0 +1,76 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample shows how to use Bing Custom Search Tool with AI Agents.
+
+using Azure.AI.Projects;
+using Azure.AI.Projects.OpenAI;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using OpenAI.Responses;
+
+string endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
+string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
+string connectionId = Environment.GetEnvironmentVariable("BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID") ?? throw new InvalidOperationException("BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID is not set.");
+string instanceName = Environment.GetEnvironmentVariable("BING_CUSTOM_SEARCH_INSTANCE_NAME") ?? throw new InvalidOperationException("BING_CUSTOM_SEARCH_INSTANCE_NAME is not set.");
+
+const string AgentInstructions = """
+ You are a helpful agent that can use Bing Custom Search tools to assist users.
+ Use the available Bing Custom Search tools to answer questions and perform tasks.
+ """;
+
+// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
+// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
+// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
+// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
+AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
+
+// Bing Custom Search tool parameters shared by both options
+BingCustomSearchToolParameters bingCustomSearchToolParameters = new([
+ new BingCustomSearchConfiguration(connectionId, instanceName)
+]);
+
+AIAgent agent = await CreateAgentWithMEAIAsync();
+// AIAgent agent = await CreateAgentWithNativeSDKAsync();
+
+Console.WriteLine($"Created agent: {agent.Name}");
+
+// Run the agent with a search query
+AgentResponse response = await agent.RunAsync("Search for the latest news about Microsoft AI");
+
+Console.WriteLine("\n=== Agent Response ===");
+foreach (var message in response.Messages)
+{
+ Console.WriteLine(message.Text);
+}
+
+// Cleanup by deleting the agent
+await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
+Console.WriteLine($"\nDeleted agent: {agent.Name}");
+
+// --- Agent Creation Options ---
+
+// Option 1 - Using AsAITool wrapping for the ResponseTool returned by AgentTool.CreateBingCustomSearchTool (MEAI + AgentFramework)
+async Task CreateAgentWithMEAIAsync()
+{
+ return await aiProjectClient.CreateAIAgentAsync(
+ model: deploymentName,
+ name: "BingCustomSearchAgent-MEAI",
+ instructions: AgentInstructions,
+ tools: [((ResponseTool)AgentTool.CreateBingCustomSearchTool(bingCustomSearchToolParameters)).AsAITool()]);
+}
+
+// Option 2 - Using PromptAgentDefinition with AgentTool.CreateBingCustomSearchTool (Native SDK)
+async Task CreateAgentWithNativeSDKAsync()
+{
+ return await aiProjectClient.CreateAIAgentAsync(
+ name: "BingCustomSearchAgent-NATIVE",
+ creationOptions: new AgentVersionCreationOptions(
+ new PromptAgentDefinition(model: deploymentName)
+ {
+ Instructions = AgentInstructions,
+ Tools = {
+ (ResponseTool)AgentTool.CreateBingCustomSearchTool(bingCustomSearchToolParameters),
+ }
+ })
+ );
+}
diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step21_BingCustomSearch/README.md b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step21_BingCustomSearch/README.md
new file mode 100644
index 0000000000..86f12c71bd
--- /dev/null
+++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step21_BingCustomSearch/README.md
@@ -0,0 +1,63 @@
+# Using Bing Custom Search with AI Agents
+
+This sample demonstrates how to use the Bing Custom Search tool with AI agents to perform customized web searches.
+
+## What this sample demonstrates
+
+- Creating agents with Bing Custom Search capabilities
+- Configuring custom search instances via connection ID and instance name
+- Two agent creation approaches: MEAI abstraction (Option 1) and Native SDK (Option 2)
+- Running search queries through the agent
+- Managing agent lifecycle (creation and deletion)
+
+## Agent creation options
+
+This sample provides two approaches for creating agents with Bing Custom Search:
+
+- **Option 1 - MEAI + AgentFramework**: Uses the Agent Framework `ResponseTool` wrapped with `AsAITool()` to call the `CreateAIAgentAsync` overload that accepts `tools:[]`, while still relying on the same underlying Azure AI Projects SDK types as Option 2.
+- **Option 2 - Native SDK**: Uses `PromptAgentDefinition` with `AgentVersionCreationOptions` to create the agent directly with the Azure AI Projects SDK types.
+
+Both options produce the same result. Toggle between them by commenting/uncommenting the corresponding `CreateAgentWith*Async` call in `Program.cs`.
+
+## Prerequisites
+
+Before you begin, ensure you have the following prerequisites:
+
+- .NET 10 SDK or later
+- Azure Foundry service endpoint and deployment configured
+- Azure CLI installed and authenticated (for Azure credential authentication)
+- A Bing Custom Search resource configured in Azure and connected to your Foundry project
+
+**Note**: This demo uses Azure Default credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure Foundry resource.
+
+Set the following environment variables:
+
+```powershell
+$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
+$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
+$env:BING_CUSTOM_SEARCH_PROJECT_CONNECTION_ID="/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects//connections/"
+$env:BING_CUSTOM_SEARCH_INSTANCE_NAME="your-configuration-name"
+```
+
+### Finding the connection ID and instance name
+
+- **Connection ID**: The full ARM resource path including the `/projects//connections/` segment. Find the connection name in your Foundry project under **Management center** → **Connected resources**.
+- **Instance Name**: The **configuration name** from the Bing Custom Search resource (Azure portal → your Bing Custom Search resource → **Configurations**). This is _not_ the Azure resource name.
+
+## Run the sample
+
+Navigate to the FoundryAgents sample directory and run:
+
+```powershell
+cd dotnet/samples/GettingStarted/FoundryAgents
+dotnet run --project .\FoundryAgents_Step21_BingCustomSearch
+```
+
+## Expected behavior
+
+The sample will:
+
+1. Create an agent with Bing Custom Search tool capabilities
+2. Run the agent with a search query about Microsoft AI
+3. Display the search results returned by the agent
+4. Clean up resources by deleting the agent
diff --git a/dotnet/samples/GettingStarted/FoundryAgents/README.md b/dotnet/samples/GettingStarted/FoundryAgents/README.md
index 702749b331..d8677ea58b 100644
--- a/dotnet/samples/GettingStarted/FoundryAgents/README.md
+++ b/dotnet/samples/GettingStarted/FoundryAgents/README.md
@@ -58,6 +58,7 @@ Before you begin, ensure you have the following prerequisites:
|[Using plugins](./FoundryAgents_Step13_Plugins/)|This sample demonstrates how to use plugins with a Foundry agent|
|[Code interpreter](./FoundryAgents_Step14_CodeInterpreter/)|This sample demonstrates how to use the code interpreter tool with a Foundry agent|
|[Computer use](./FoundryAgents_Step15_ComputerUse/)|This sample demonstrates how to use computer use capabilities with a Foundry agent|
+|[Bing Custom Search](./FoundryAgents_Step21_BingCustomSearch/)|This sample demonstrates how to use Bing Custom Search tool with a Foundry agent|
|[Web search](./FoundryAgents_Step25_WebSearch/)|This sample demonstrates how to use the Responses API web search tool with a Foundry agent|
|[Memory search](./FoundryAgents_Step26_MemorySearch/)|This sample demonstrates how to use memory search tool with a Foundry agent|
|[File search](./FoundryAgents_Step18_FileSearch/)|This sample demonstrates how to use the file search tool with a Foundry agent|
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.Builder;
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 class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
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 class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
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 class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
/// 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 class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
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 class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
/// 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 class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
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 class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions
/// 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;
+
+ // 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 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()
- };
- }
}
///
@@ -74,15 +80,19 @@ 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,
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 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/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs
index 9d3c5e335d..e2b038bbd7 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/JsonDocumentExtensions.cs
@@ -111,7 +111,9 @@ internal static class JsonDocumentExtensions
VariableType? currentType =
element.ValueKind switch
{
- JsonValueKind.Object => VariableType.Record(targetType.Schema?.Select(kvp => (kvp.Key, kvp.Value)) ?? []),
+ JsonValueKind.Object => targetType.HasSchema
+ ? VariableType.Record(targetType.Schema!.Select(kvp => (kvp.Key, kvp.Value)))
+ : VariableType.RecordType,
JsonValueKind.String => typeof(string),
JsonValueKind.True => typeof(bool),
JsonValueKind.False => typeof(bool),
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 sealed class AIAgentExtensionsTests
});
// Assert
- Assert.Null(capturedOptions);
+ Assert.NotNull(capturedOptions);
+ Assert.False(capturedOptions.AllowBackgroundResponses);
+ Assert.Null(capturedOptions.AdditionalProperties);
}
///
@@ -68,11 +72,11 @@ public sealed class AIAgentExtensionsTests
}
///
- /// 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 sealed class AIAgentExtensionsTests
});
// Assert
- Assert.Null(capturedOptions);
+ Assert.NotNull(capturedOptions);
+ Assert.False(capturedOptions.AllowBackgroundResponses);
+ Assert.Null(capturedOptions.AdditionalProperties);
}
///
@@ -171,6 +177,590 @@ public sealed class AIAgentExtensionsTests
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 @@ public sealed class AIAgentExtensionsTests
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;
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs
index bd86aa1958..fc03d17142 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/JsonDocumentExtensionsTests.cs
@@ -384,4 +384,79 @@ public sealed class JsonDocumentExtensionsTests
// Act / Assert
Assert.Throws(() => document.ParseList(typeof(int[])));
}
+
+ ///
+ /// Regression test for #4195: When a JSON object contains an array of objects
+ /// and is parsed with VariableType.RecordType (no schema), the nested
+ /// object properties must be preserved. Before the fix, DetermineElementType()
+ /// created an empty-schema VariableType, causing ParseRecord to take the
+ /// ParseSchema path (zero fields) and return empty dictionaries.
+ ///
+ [Fact]
+ public void ParseRecord_ObjectWithArrayOfObjects_NoSchema_PreservesNestedProperties()
+ {
+ // Arrange
+ JsonDocument document = JsonDocument.Parse(
+ """
+ {
+ "items": [
+ { "name": "Alice", "role": "Engineer" },
+ { "name": "Bob", "role": "Designer" },
+ { "name": "Carol", "role": "PM" }
+ ]
+ }
+ """);
+
+ // Act
+ Dictionary result = document.ParseRecord(VariableType.RecordType);
+
+ // Assert
+ Assert.True(result.ContainsKey("items"));
+ List