mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
547316b523 | ||
|
|
424d66ad74 | ||
|
|
cd1f4c2a93 | ||
|
|
8d208f3bb3 | ||
|
|
373482427c | ||
|
|
c84203a4a8 | ||
|
|
8f878dcd58 | ||
|
|
c54483f81e | ||
|
|
275363d15e | ||
|
|
7417eeb7e6 | ||
|
|
6ad9279f0f | ||
|
|
c1bbaeb31d | ||
|
|
6173e63f0b |
@@ -42,15 +42,15 @@
|
||||
<!-- System.* -->
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
||||
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.10.0" />
|
||||
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.6" />
|
||||
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.4" />
|
||||
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.5" />
|
||||
<PackageVersion Include="System.Net.Http.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.4" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.5" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.6" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.6" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
||||
@@ -104,8 +104,8 @@
|
||||
<PackageVersion Include="Microsoft.Agents.Authentication.Msal" Version="1.3.171-beta" />
|
||||
<PackageVersion Include="Microsoft.Agents.Hosting.AspNetCore" Version="1.3.171-beta" />
|
||||
<!-- A2A -->
|
||||
<PackageVersion Include="A2A" Version="0.3.4-preview" />
|
||||
<PackageVersion Include="A2A.AspNetCore" Version="0.3.4-preview" />
|
||||
<PackageVersion Include="A2A" Version="1.0.0-preview2" />
|
||||
<PackageVersion Include="A2A.AspNetCore" Version="1.0.0-preview2" />
|
||||
<!-- MCP -->
|
||||
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
|
||||
<!-- Inference SDKs -->
|
||||
|
||||
@@ -344,11 +344,13 @@
|
||||
<Project Path="samples/04-hosting/DurableAgents/ConsoleApps/06_LongRunningTools/06_LongRunningTools.csproj" />
|
||||
<Project Path="samples/04-hosting/DurableAgents/ConsoleApps/07_ReliableStreaming/07_ReliableStreaming.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/A2A/">
|
||||
<File Path="samples/04-hosting/A2A/README.md" />
|
||||
<Project Path="samples/04-hosting/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj" />
|
||||
<Project Path="samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/A2A/">
|
||||
<File Path="samples/02-agents/A2A/README.md" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj" />
|
||||
<Project Path="samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/05-end-to-end/">
|
||||
<Project Path="samples/05-end-to-end/AgentWithPurview/AgentWithPurview.csproj" />
|
||||
<Project Path="samples/05-end-to-end/M365Agent/M365Agent.csproj" />
|
||||
|
||||
+1
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -13,7 +13,6 @@
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="System.Net.ServerSentEvents" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
+5
-1
@@ -18,8 +18,12 @@ AIAgent agent = agentCard.AsAIAgent();
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// AllowBackgroundResponses must be true so the server returns immediately with a continuation token
|
||||
// instead of blocking until the task is complete.
|
||||
AgentRunOptions options = new() { AllowBackgroundResponses = true };
|
||||
|
||||
// Start the initial run with a long-running task.
|
||||
AgentResponse response = await agent.RunAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session);
|
||||
AgentResponse response = await agent.RunAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session, options: options);
|
||||
|
||||
// Poll until the response is complete.
|
||||
while (response.ContinuationToken is { } token)
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="A2A" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.A2A\Microsoft.Agents.AI.A2A.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to select the A2A protocol binding (HTTP+JSON vs JSON-RPC) when
|
||||
// creating an AIAgent from an A2A agent card using A2AClientOptions.PreferredBindings.
|
||||
|
||||
using A2A;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
var a2aAgentHost = Environment.GetEnvironmentVariable("A2A_AGENT_HOST") ?? throw new InvalidOperationException("A2A_AGENT_HOST is not set.");
|
||||
|
||||
// Initialize an A2ACardResolver to get an A2A agent card.
|
||||
A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost));
|
||||
|
||||
// Get the agent card
|
||||
AgentCard agentCard = await agentCardResolver.GetAgentCardAsync();
|
||||
|
||||
// Use A2AClientOptions to explicitly select the HTTP+JSON protocol binding.
|
||||
// This tells the A2A client factory to prefer the HTTP+JSON interface when the agent card
|
||||
// advertises multiple supported interfaces.
|
||||
A2AClientOptions options = new()
|
||||
{
|
||||
PreferredBindings = [ProtocolBindingNames.HttpJson]
|
||||
};
|
||||
|
||||
// To prefer JSON-RPC instead, use:
|
||||
// A2AClientOptions options = new()
|
||||
// {
|
||||
// PreferredBindings = [ProtocolBindingNames.JsonRpc]
|
||||
// };
|
||||
|
||||
// Create an instance of the AIAgent for an existing A2A agent, using the specified protocol binding.
|
||||
AIAgent agent = agentCard.AsAIAgent(options: options);
|
||||
|
||||
// Invoke the agent and output the text result.
|
||||
AgentResponse response = await agent.RunAsync("Tell me a joke about a pirate.");
|
||||
Console.WriteLine(response);
|
||||
@@ -0,0 +1,27 @@
|
||||
# A2A Agent Protocol Selection
|
||||
|
||||
This sample demonstrates how to select the A2A protocol binding when creating an `AIAgent` from an A2A agent card.
|
||||
|
||||
A2A agents can expose multiple interfaces with different protocol bindings (e.g., HTTP+JSON, JSON-RPC). By default, `AsAIAgent()` prefers HTTP+JSON with JSON-RPC as a fallback. This sample shows how to use `A2AClientOptions.PreferredBindings` to explicitly control which protocol binding is used.
|
||||
|
||||
The sample:
|
||||
|
||||
- Connects to an A2A agent server specified in the `A2A_AGENT_HOST` environment variable
|
||||
- Configures `A2AClientOptions` to prefer the HTTP+JSON protocol binding
|
||||
- Creates an `AIAgent` from the resolved agent card using the specified binding
|
||||
- Sends a message to the agent and displays the response
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 10.0 SDK or later
|
||||
- An A2A agent server running and accessible via HTTP
|
||||
|
||||
**Note**: These samples need to be run against a valid A2A server. If no A2A server is available, they can be run against the echo-agent that can be spun up locally by following the guidelines at: https://github.com/a2aproject/a2a-dotnet/blob/main/samples/AgentServer/README.md
|
||||
|
||||
Set the following environment variable:
|
||||
|
||||
```powershell
|
||||
$env:A2A_AGENT_HOST="http://localhost:5000" # Replace with your A2A agent server host
|
||||
```
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="A2A" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.A2A\Microsoft.Agents.AI.A2A.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens,
|
||||
// allowing recovery from stream interruptions without losing progress.
|
||||
|
||||
using A2A;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var a2aAgentHost = Environment.GetEnvironmentVariable("A2A_AGENT_HOST") ?? throw new InvalidOperationException("A2A_AGENT_HOST is not set.");
|
||||
|
||||
// Initialize an A2ACardResolver to get an A2A agent card.
|
||||
A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost));
|
||||
|
||||
// Get the agent card
|
||||
AgentCard agentCard = await agentCardResolver.GetAgentCardAsync();
|
||||
|
||||
// Create an instance of the AIAgent for an existing A2A agent specified by the agent card.
|
||||
AIAgent agent = agentCard.AsAIAgent();
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
ResponseContinuationToken? continuationToken = null;
|
||||
|
||||
await foreach (var update in agent.RunStreamingAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session))
|
||||
{
|
||||
// Saving the continuation token to be able to reconnect to the same response stream later.
|
||||
// Note: Continuation tokens are only returned for long-running tasks. If the underlying A2A agent
|
||||
// returns a message instead of a task, the continuation token will not be initialized.
|
||||
// A2A agents do not support stream resumption from a specific point in the stream,
|
||||
// but only reconnection to obtain the same response stream from the beginning.
|
||||
// So, A2A agents will return an initialized continuation token in the first update
|
||||
// representing the beginning of the stream, and it will be null in all subsequent updates.
|
||||
if (update.ContinuationToken is { } token)
|
||||
{
|
||||
continuationToken = token;
|
||||
}
|
||||
|
||||
// Imitating stream interruption
|
||||
break;
|
||||
}
|
||||
|
||||
// Reconnect to the same response stream using the continuation token obtained from the previous run.
|
||||
// As a first update, the agent will return an update representing the current state of the response at the moment of calling
|
||||
// RunStreamingAsync with the same continuation token, followed by other updates until the end of the stream is reached.
|
||||
if (continuationToken is not null)
|
||||
{
|
||||
await foreach (var update in agent.RunStreamingAsync(session, options: new() { ContinuationToken = continuationToken }))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
Console.WriteLine(update.Text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# A2A Agent Stream Reconnection
|
||||
|
||||
This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens, allowing recovery from stream interruptions without losing progress.
|
||||
|
||||
The sample:
|
||||
|
||||
- Connects to an A2A agent server specified in the `A2A_AGENT_HOST` environment variable
|
||||
- Sends a request to the agent and begins streaming the response
|
||||
- Captures a continuation token from the stream for later reconnection
|
||||
- Simulates a stream interruption by breaking out of the streaming loop
|
||||
- Reconnects to the same response stream using the captured continuation token
|
||||
- Displays the response received after reconnection
|
||||
|
||||
This pattern is useful when network interruptions or other failures may disrupt an ongoing streaming response, and you need to recover and continue processing.
|
||||
|
||||
> **Note:** Continuation tokens are only available when the underlying A2A agent returns a task. If the agent returns a message instead, the continuation token will not be initialized and stream reconnection is not applicable.
|
||||
|
||||
# Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 10.0 SDK or later
|
||||
- An A2A agent server running and accessible via HTTP
|
||||
|
||||
Set the following environment variable:
|
||||
|
||||
```powershell
|
||||
$env:A2A_AGENT_HOST="http://localhost:5000" # Replace with your A2A agent server host
|
||||
```
|
||||
@@ -3,7 +3,7 @@
|
||||
These samples demonstrate how to work with Agent-to-Agent (A2A) specific features in the Agent Framework.
|
||||
|
||||
For other samples that demonstrate how to use AIAgent instances,
|
||||
see the [Getting Started With Agents](../../02-agents/Agents/README.md) samples.
|
||||
see the [Getting Started With Agents](../Agents/README.md) samples.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -15,6 +15,8 @@ See the README.md for each sample for the prerequisites for that sample.
|
||||
|---|---|
|
||||
|[A2A Agent As Function Tools](./A2AAgent_AsFunctionTools/)|This sample demonstrates how to represent an A2A agent as a set of function tools, where each function tool corresponds to a skill of the A2A agent, and register these function tools with another AI agent so it can leverage the A2A agent's skills.|
|
||||
|[A2A Agent Polling For Task Completion](./A2AAgent_PollingForTaskCompletion/)|This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A agent.|
|
||||
|[A2A Agent Stream Reconnection](./A2AAgent_StreamReconnection/)|This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens, allowing recovery from stream interruptions.|
|
||||
|[A2A Agent Protocol Selection](./A2AAgent_ProtocolSelection/)|This sample demonstrates how to select the A2A protocol binding (HTTP+JSON vs JSON-RPC) when creating an AIAgent from an A2A agent card using A2AClientOptions.|
|
||||
|
||||
## Running the samples from the console
|
||||
|
||||
@@ -19,3 +19,4 @@ The getting started samples demonstrate the fundamental concepts and functionali
|
||||
| [Declarative Agents](./DeclarativeAgents) | Loading and executing AI agents from YAML configuration files |
|
||||
| [AG-UI](./AGUI/README.md) | Getting started with AG-UI (Agent UI Protocol) servers and clients |
|
||||
| [Dev UI](./DevUI/README.md) | Interactive web interface for testing and debugging AI agents during development |
|
||||
| [A2A Agents](./A2A/README.md) | Working with Agent-to-Agent (A2A) specific features |
|
||||
|
||||
@@ -62,12 +62,10 @@ public static class Program
|
||||
}
|
||||
|
||||
var agentResponse = await hostAgent.Agent!.RunAsync(message, session, cancellationToken: cancellationToken);
|
||||
foreach (var chatMessage in agentResponse.Messages)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine($"\nAgent: {chatMessage.Text}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine($"\nAgent: {agentResponse.Text}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace A2AServer;
|
||||
|
||||
internal static class HostAgentFactory
|
||||
{
|
||||
internal static async Task<(AIAgent, AgentCard)> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string agentName, IList<AITool>? tools = null)
|
||||
internal static async Task<(AIAgent, AgentCard)> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string agentName, string[] agentUrls, IList<AITool>? tools = null)
|
||||
{
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
@@ -26,16 +26,16 @@ internal static class HostAgentFactory
|
||||
|
||||
AgentCard agentCard = agentType.ToUpperInvariant() switch
|
||||
{
|
||||
"INVOICE" => GetInvoiceAgentCard(),
|
||||
"POLICY" => GetPolicyAgentCard(),
|
||||
"LOGISTICS" => GetLogisticsAgentCard(),
|
||||
"INVOICE" => GetInvoiceAgentCard(agentUrls),
|
||||
"POLICY" => GetPolicyAgentCard(agentUrls),
|
||||
"LOGISTICS" => GetLogisticsAgentCard(agentUrls),
|
||||
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
|
||||
};
|
||||
|
||||
return new(agent, agentCard);
|
||||
}
|
||||
|
||||
internal static async Task<(AIAgent, AgentCard)> CreateChatCompletionHostAgentAsync(string agentType, string model, string apiKey, string name, string instructions, IList<AITool>? tools = null)
|
||||
internal static async Task<(AIAgent, AgentCard)> CreateChatCompletionHostAgentAsync(string agentType, string model, string apiKey, string name, string instructions, string[] agentUrls, IList<AITool>? tools = null)
|
||||
{
|
||||
AIAgent agent = new OpenAIClient(apiKey)
|
||||
.GetChatClient(model)
|
||||
@@ -43,9 +43,9 @@ internal static class HostAgentFactory
|
||||
|
||||
AgentCard agentCard = agentType.ToUpperInvariant() switch
|
||||
{
|
||||
"INVOICE" => GetInvoiceAgentCard(),
|
||||
"POLICY" => GetPolicyAgentCard(),
|
||||
"LOGISTICS" => GetLogisticsAgentCard(),
|
||||
"INVOICE" => GetInvoiceAgentCard(agentUrls),
|
||||
"POLICY" => GetPolicyAgentCard(agentUrls),
|
||||
"LOGISTICS" => GetLogisticsAgentCard(agentUrls),
|
||||
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
|
||||
};
|
||||
|
||||
@@ -53,7 +53,7 @@ internal static class HostAgentFactory
|
||||
}
|
||||
|
||||
#region private
|
||||
private static AgentCard GetInvoiceAgentCard()
|
||||
private static AgentCard GetInvoiceAgentCard(string[] agentUrls)
|
||||
{
|
||||
var capabilities = new AgentCapabilities()
|
||||
{
|
||||
@@ -82,10 +82,11 @@ internal static class HostAgentFactory
|
||||
DefaultOutputModes = ["text"],
|
||||
Capabilities = capabilities,
|
||||
Skills = [invoiceQuery],
|
||||
SupportedInterfaces = CreateAgentInterfaces(agentUrls)
|
||||
};
|
||||
}
|
||||
|
||||
private static AgentCard GetPolicyAgentCard()
|
||||
private static AgentCard GetPolicyAgentCard(string[] agentUrls)
|
||||
{
|
||||
var capabilities = new AgentCapabilities()
|
||||
{
|
||||
@@ -114,10 +115,11 @@ internal static class HostAgentFactory
|
||||
DefaultOutputModes = ["text"],
|
||||
Capabilities = capabilities,
|
||||
Skills = [policyQuery],
|
||||
SupportedInterfaces = CreateAgentInterfaces(agentUrls)
|
||||
};
|
||||
}
|
||||
|
||||
private static AgentCard GetLogisticsAgentCard()
|
||||
private static AgentCard GetLogisticsAgentCard(string[] agentUrls)
|
||||
{
|
||||
var capabilities = new AgentCapabilities()
|
||||
{
|
||||
@@ -146,7 +148,29 @@ internal static class HostAgentFactory
|
||||
DefaultOutputModes = ["text"],
|
||||
Capabilities = capabilities,
|
||||
Skills = [logisticsQuery],
|
||||
SupportedInterfaces = CreateAgentInterfaces(agentUrls)
|
||||
};
|
||||
}
|
||||
|
||||
private static List<AgentInterface> CreateAgentInterfaces(string[] agentUrls)
|
||||
{
|
||||
List<AgentInterface> agentInterfaces = [];
|
||||
|
||||
agentInterfaces.AddRange(agentUrls.Select(url => new AgentInterface
|
||||
{
|
||||
Url = url,
|
||||
ProtocolBinding = ProtocolBindingNames.JsonRpc,
|
||||
ProtocolVersion = "1.0",
|
||||
}));
|
||||
|
||||
agentInterfaces.AddRange(agentUrls.Select(url => new AgentInterface
|
||||
{
|
||||
Url = url,
|
||||
ProtocolBinding = ProtocolBindingNames.HttpJson,
|
||||
ProtocolVersion = "1.0",
|
||||
}));
|
||||
|
||||
return agentInterfaces;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -25,10 +25,6 @@ for (var i = 0; i < args.Length; i++)
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
var app = builder.Build();
|
||||
|
||||
var httpClient = app.Services.GetRequiredService<IHttpClientFactory>().CreateClient();
|
||||
var logger = app.Logger;
|
||||
|
||||
IConfigurationRoot configuration = new ConfigurationBuilder()
|
||||
.AddEnvironmentVariables()
|
||||
@@ -38,14 +34,15 @@ IConfigurationRoot configuration = new ConfigurationBuilder()
|
||||
string? apiKey = configuration["OPENAI_API_KEY"];
|
||||
string model = configuration["OPENAI_CHAT_MODEL_NAME"] ?? "gpt-5.4-mini";
|
||||
string? endpoint = configuration["AZURE_AI_PROJECT_ENDPOINT"];
|
||||
string[] agentUrls = (builder.Configuration["urls"] ?? "http://localhost:5000").Split(';');
|
||||
|
||||
var invoiceQueryPlugin = new InvoiceQuery();
|
||||
IList<AITool> tools =
|
||||
[
|
||||
[
|
||||
AIFunctionFactory.Create(invoiceQueryPlugin.QueryInvoices),
|
||||
AIFunctionFactory.Create(invoiceQueryPlugin.QueryByTransactionId),
|
||||
AIFunctionFactory.Create(invoiceQueryPlugin.QueryByInvoiceId)
|
||||
];
|
||||
];
|
||||
|
||||
AIAgent hostA2AAgent;
|
||||
AgentCard hostA2AAgentCard;
|
||||
@@ -54,9 +51,9 @@ if (!string.IsNullOrEmpty(endpoint) && !string.IsNullOrEmpty(agentName))
|
||||
{
|
||||
(hostA2AAgent, hostA2AAgentCard) = agentType.ToUpperInvariant() switch
|
||||
{
|
||||
"INVOICE" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, tools),
|
||||
"POLICY" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName),
|
||||
"LOGISTICS" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName),
|
||||
"INVOICE" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls, tools),
|
||||
"POLICY" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls),
|
||||
"LOGISTICS" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls),
|
||||
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
|
||||
};
|
||||
}
|
||||
@@ -68,7 +65,7 @@ else if (!string.IsNullOrEmpty(apiKey))
|
||||
agentType, model, apiKey, "InvoiceAgent",
|
||||
"""
|
||||
You specialize in handling queries related to invoices.
|
||||
""", tools),
|
||||
""", agentUrls, tools),
|
||||
"POLICY" => await HostAgentFactory.CreateChatCompletionHostAgentAsync(
|
||||
agentType, model, apiKey, "PolicyAgent",
|
||||
"""
|
||||
@@ -84,7 +81,7 @@ else if (!string.IsNullOrEmpty(apiKey))
|
||||
resolution in SAP CRM and notify the customer via email within 2 business days, referencing the
|
||||
original invoice and the credit memo number. Use the 'Formal Credit Notification' email
|
||||
template."
|
||||
"""),
|
||||
""", agentUrls),
|
||||
"LOGISTICS" => await HostAgentFactory.CreateChatCompletionHostAgentAsync(
|
||||
agentType, model, apiKey, "LogisticsAgent",
|
||||
"""
|
||||
@@ -95,7 +92,7 @@ else if (!string.IsNullOrEmpty(apiKey))
|
||||
Shipment number: SHPMT-SAP-001
|
||||
Item: TSHIRT-RED-L
|
||||
Quantity: 900
|
||||
"""),
|
||||
""", agentUrls),
|
||||
_ => throw new ArgumentException($"Unsupported agent type: {agentType}"),
|
||||
};
|
||||
}
|
||||
@@ -104,10 +101,12 @@ else
|
||||
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
|
||||
}
|
||||
|
||||
var a2aTaskManager = app.MapA2A(
|
||||
hostA2AAgent,
|
||||
path: "/",
|
||||
agentCard: hostA2AAgentCard,
|
||||
taskManager => app.MapWellKnownAgentCard(taskManager, "/"));
|
||||
builder.AddA2AServer(hostA2AAgent);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapA2AHttpJson(hostA2AAgent, "/");
|
||||
app.MapA2AJsonRpc(hostA2AAgent, "/");
|
||||
|
||||
app.MapWellKnownAgentCard(hostA2AAgentCard);
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using A2A.AspNetCore;
|
||||
using AgentWebChat.AgentHost;
|
||||
using AgentWebChat.AgentHost.Custom;
|
||||
using AgentWebChat.AgentHost.Utilities;
|
||||
@@ -146,6 +145,9 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
|
||||
instructions: "you are a dependency inject agent. Tell me all about dependency injection.");
|
||||
});
|
||||
|
||||
pirateAgentBuilder.AddA2AServer();
|
||||
knightsKnavesAgentBuilder.AddA2AServer();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapOpenApi();
|
||||
@@ -154,17 +156,9 @@ app.UseSwaggerUI(options => options.SwaggerEndpoint("/openapi/v1.json", "Agents
|
||||
// Configure the HTTP request pipeline.
|
||||
app.UseExceptionHandler();
|
||||
|
||||
// attach a2a with simple message communication
|
||||
app.MapA2A(pirateAgentBuilder, path: "/a2a/pirate");
|
||||
app.MapA2A(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves", agentCard: new()
|
||||
{
|
||||
Name = "Knights and Knaves",
|
||||
Description = "An agent that helps you solve the knights and knaves puzzle.",
|
||||
Version = "1.0",
|
||||
|
||||
// Url can be not set, and SDK will help assign it.
|
||||
// Url = "http://localhost:5390/a2a/knights-and-knaves"
|
||||
});
|
||||
// Expose A2A servers over HTTP with JSON payloads
|
||||
app.MapA2AHttpJson(pirateAgentBuilder, path: "/a2a/pirate");
|
||||
app.MapA2AHttpJson(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves");
|
||||
|
||||
app.MapDevUI();
|
||||
|
||||
|
||||
@@ -43,20 +43,21 @@ internal sealed class A2AAgentClient : AgentClientBase
|
||||
{
|
||||
// Convert all messages to A2A parts and create a single message
|
||||
var parts = messages.ToParts();
|
||||
var a2aMessage = new AgentMessage
|
||||
var a2aMessage = new Message
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
ContextId = contextId,
|
||||
Role = MessageRole.User,
|
||||
Role = Role.User,
|
||||
Parts = parts
|
||||
};
|
||||
|
||||
var messageSendParams = new MessageSendParams { Message = a2aMessage };
|
||||
var messageSendParams = new SendMessageRequest { Message = a2aMessage };
|
||||
var a2aResponse = await a2aClient.SendMessageAsync(messageSendParams, cancellationToken);
|
||||
|
||||
// Handle different response types
|
||||
if (a2aResponse is AgentMessage message)
|
||||
if (a2aResponse.PayloadCase == SendMessageResponseCase.Message)
|
||||
{
|
||||
var message = a2aResponse.Message!;
|
||||
var responseMessage = message.ToChatMessage();
|
||||
if (responseMessage is { Contents.Count: > 0 })
|
||||
{
|
||||
@@ -67,9 +68,10 @@ internal sealed class A2AAgentClient : AgentClientBase
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (a2aResponse is AgentTask agentTask)
|
||||
else if (a2aResponse.PayloadCase == SendMessageResponseCase.Task)
|
||||
{
|
||||
// Manually convert AgentTask artifacts to ChatMessages since the extension method is internal
|
||||
var agentTask = a2aResponse.Task!;
|
||||
if (agentTask.Artifacts is not null)
|
||||
{
|
||||
foreach (var artifact in agentTask.Artifacts)
|
||||
|
||||
@@ -16,7 +16,7 @@ were local agents. These are supported using various `AIAgent` subclasses.
|
||||
| [`01-get-started/`](./01-get-started/) | Progressive tutorial: hello agent → hosting |
|
||||
| [`02-agents/`](./02-agents/) | Deep-dive by concept: tools, middleware, providers, orchestrations |
|
||||
| [`03-workflows/`](./03-workflows/) | Workflow patterns: sequential, concurrent, state, declarative |
|
||||
| [`04-hosting/`](./04-hosting/) | Deployment: Azure Functions, Durable Tasks, A2A |
|
||||
| [`04-hosting/`](./04-hosting/) | Deployment: Azure Functions, Durable Tasks |
|
||||
| [`05-end-to-end/`](./05-end-to-end/) | Full applications, evaluation, demos |
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.ServerSentEvents;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
@@ -28,7 +27,7 @@ public sealed class A2AAgent : AIAgent
|
||||
{
|
||||
private static readonly AIAgentMetadata s_agentMetadata = new("a2a");
|
||||
|
||||
private readonly A2AClient _a2aClient;
|
||||
private readonly IA2AClient _a2aClient;
|
||||
private readonly string? _id;
|
||||
private readonly string? _name;
|
||||
private readonly string? _description;
|
||||
@@ -42,7 +41,7 @@ public sealed class A2AAgent : AIAgent
|
||||
/// <param name="name">The the name of the agent.</param>
|
||||
/// <param name="description">The description of the agent.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory to use for logging.</param>
|
||||
public A2AAgent(A2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null)
|
||||
public A2AAgent(IA2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_ = Throw.IfNull(a2aClient);
|
||||
|
||||
@@ -100,64 +99,47 @@ public sealed class A2AAgent : AIAgent
|
||||
|
||||
this._logger.LogA2AAgentInvokingAgent(nameof(RunAsync), this.Id, this.Name);
|
||||
|
||||
A2AResponse? a2aResponse = null;
|
||||
|
||||
if (GetContinuationToken(messages, options) is { } token)
|
||||
{
|
||||
a2aResponse = await this._a2aClient.GetTaskAsync(token.TaskId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
MessageSendParams sendParams = new()
|
||||
{
|
||||
Message = CreateA2AMessage(typedSession, messages),
|
||||
Metadata = options?.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
AgentTask agentTask = await this._a2aClient.GetTaskAsync(new GetTaskRequest { Id = token.TaskId }, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
a2aResponse = await this._a2aClient.SendMessageAsync(sendParams, cancellationToken).ConfigureAwait(false);
|
||||
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name);
|
||||
|
||||
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id);
|
||||
|
||||
return this.ConvertToAgentResponse(agentTask);
|
||||
}
|
||||
|
||||
SendMessageRequest sendParams = new()
|
||||
{
|
||||
Message = CreateA2AMessage(typedSession, messages),
|
||||
Metadata = options?.AdditionalProperties?.ToA2AMetadata(),
|
||||
Configuration = new SendMessageConfiguration { ReturnImmediately = options?.AllowBackgroundResponses is true }
|
||||
};
|
||||
|
||||
SendMessageResponse a2aResponse = await this._a2aClient.SendMessageAsync(sendParams, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name);
|
||||
|
||||
if (a2aResponse is AgentMessage message)
|
||||
if (a2aResponse.PayloadCase == SendMessageResponseCase.Message)
|
||||
{
|
||||
var message = a2aResponse.Message!;
|
||||
|
||||
UpdateSession(typedSession, message.ContextId);
|
||||
|
||||
return new AgentResponse
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ResponseId = message.MessageId,
|
||||
FinishReason = ChatFinishReason.Stop,
|
||||
RawRepresentation = message,
|
||||
Messages = [message.ToChatMessage()],
|
||||
AdditionalProperties = message.Metadata?.ToAdditionalProperties(),
|
||||
};
|
||||
return this.ConvertToAgentResponse(message);
|
||||
}
|
||||
|
||||
if (a2aResponse is AgentTask agentTask)
|
||||
if (a2aResponse.PayloadCase == SendMessageResponseCase.Task)
|
||||
{
|
||||
var agentTask = a2aResponse.Task!;
|
||||
|
||||
UpdateSession(typedSession, agentTask.ContextId, agentTask.Id);
|
||||
|
||||
var response = new AgentResponse
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ResponseId = agentTask.Id,
|
||||
FinishReason = MapTaskStateToFinishReason(agentTask.Status.State),
|
||||
RawRepresentation = agentTask,
|
||||
Messages = agentTask.ToChatMessages() ?? [],
|
||||
ContinuationToken = CreateContinuationToken(agentTask.Id, agentTask.Status.State),
|
||||
AdditionalProperties = agentTask.Metadata?.ToAdditionalProperties(),
|
||||
};
|
||||
|
||||
if (agentTask.ToChatMessages() is { Count: > 0 } taskMessages)
|
||||
{
|
||||
response.Messages = taskMessages;
|
||||
}
|
||||
|
||||
return response;
|
||||
return this.ConvertToAgentResponse(agentTask);
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"Only Message and AgentTask responses are supported from A2A agents. Received: {a2aResponse.GetType().FullName ?? "null"}");
|
||||
throw new NotSupportedException($"Only Message and AgentTask responses are supported from A2A agents. Received: {a2aResponse.PayloadCase}");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -169,59 +151,61 @@ public sealed class A2AAgent : AIAgent
|
||||
|
||||
this._logger.LogA2AAgentInvokingAgent(nameof(RunStreamingAsync), this.Id, this.Name);
|
||||
|
||||
ConfiguredCancelableAsyncEnumerable<SseItem<A2AEvent>> a2aSseEvents;
|
||||
ConfiguredCancelableAsyncEnumerable<StreamResponse> streamEvents;
|
||||
|
||||
if (options?.ContinuationToken is not null)
|
||||
if (GetContinuationToken(messages, options) is { } token)
|
||||
{
|
||||
// Task stream resumption is not well defined in the A2A v2.* specification, leaving it to the agent implementations.
|
||||
// The v3.0 specification improves this by defining task stream reconnection that allows obtaining the same stream
|
||||
// from the beginning, but it does not define stream resumption from a specific point in the stream.
|
||||
// Therefore, the code should be updated once the A2A .NET library supports the A2A v3.0 specification,
|
||||
// and AF has the necessary model to allow consumers to know whether they need to resume the stream and add new updates to
|
||||
// the existing ones or reconnect the stream and obtain all updates again.
|
||||
// For more details, see the following issue: https://github.com/microsoft/agent-framework/issues/1764
|
||||
throw new InvalidOperationException("Reconnecting to task streams using continuation tokens is not supported yet.");
|
||||
// a2aSseEvents = this._a2aClient.SubscribeToTaskAsync(token.TaskId, cancellationToken).ConfigureAwait(false);
|
||||
streamEvents = this.SubscribeToTaskWithFallbackAsync(token.TaskId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
MessageSendParams sendParams = new()
|
||||
else
|
||||
{
|
||||
Message = CreateA2AMessage(typedSession, messages),
|
||||
Metadata = options?.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
SendMessageRequest sendParams = new()
|
||||
{
|
||||
Message = CreateA2AMessage(typedSession, messages),
|
||||
Metadata = options?.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
|
||||
a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(sendParams, cancellationToken).ConfigureAwait(false);
|
||||
streamEvents = this._a2aClient.SendStreamingMessageAsync(sendParams, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
this._logger.LogAgentChatClientInvokedAgent(nameof(RunStreamingAsync), this.Id, this.Name);
|
||||
|
||||
string? contextId = null;
|
||||
string? taskId = null;
|
||||
|
||||
await foreach (var sseEvent in a2aSseEvents)
|
||||
await foreach (var streamResponse in streamEvents)
|
||||
{
|
||||
if (sseEvent.Data is AgentMessage message)
|
||||
switch (streamResponse.PayloadCase)
|
||||
{
|
||||
contextId = message.ContextId;
|
||||
case StreamResponseCase.Message:
|
||||
var message = streamResponse.Message!;
|
||||
contextId = message.ContextId;
|
||||
yield return this.ConvertToAgentResponseUpdate(message);
|
||||
break;
|
||||
|
||||
yield return this.ConvertToAgentResponseUpdate(message);
|
||||
}
|
||||
else if (sseEvent.Data is AgentTask task)
|
||||
{
|
||||
contextId = task.ContextId;
|
||||
taskId = task.Id;
|
||||
case StreamResponseCase.Task:
|
||||
var task = streamResponse.Task!;
|
||||
contextId = task.ContextId;
|
||||
taskId = task.Id;
|
||||
yield return this.ConvertToAgentResponseUpdate(task);
|
||||
break;
|
||||
|
||||
yield return this.ConvertToAgentResponseUpdate(task);
|
||||
}
|
||||
else if (sseEvent.Data is TaskUpdateEvent taskUpdateEvent)
|
||||
{
|
||||
contextId = taskUpdateEvent.ContextId;
|
||||
taskId = taskUpdateEvent.TaskId;
|
||||
case StreamResponseCase.StatusUpdate:
|
||||
var statusUpdate = streamResponse.StatusUpdate!;
|
||||
contextId = statusUpdate.ContextId;
|
||||
taskId = statusUpdate.TaskId;
|
||||
yield return this.ConvertToAgentResponseUpdate(statusUpdate);
|
||||
break;
|
||||
|
||||
yield return this.ConvertToAgentResponseUpdate(taskUpdateEvent);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotSupportedException($"Only message, task, task update events are supported from A2A agents. Received: {sseEvent.Data.GetType().FullName ?? "null"}");
|
||||
case StreamResponseCase.ArtifactUpdate:
|
||||
var artifactUpdate = streamResponse.ArtifactUpdate!;
|
||||
contextId = artifactUpdate.ContextId;
|
||||
taskId = artifactUpdate.TaskId;
|
||||
yield return this.ConvertToAgentResponseUpdate(artifactUpdate);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new NotSupportedException($"Only message, task, task update events are supported from A2A agents. Received: {streamResponse.PayloadCase}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +224,7 @@ public sealed class A2AAgent : AIAgent
|
||||
/// <inheritdoc/>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
=> base.GetService(serviceType, serviceKey)
|
||||
?? (serviceType == typeof(A2AClient) ? this._a2aClient
|
||||
?? (serviceType == typeof(IA2AClient) ? this._a2aClient
|
||||
: serviceType == typeof(AIAgentMetadata) ? s_agentMetadata
|
||||
: null);
|
||||
|
||||
@@ -264,6 +248,75 @@ public sealed class A2AAgent : AIAgent
|
||||
return typedSession;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to task updates, falling back to <see cref="A2AClient.GetTaskAsync"/>
|
||||
/// when the task has already reached a terminal state and the server responds with
|
||||
/// <see cref="A2AErrorCode.UnsupportedOperation"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per A2A spec §3.1.6, subscribing to a task in a terminal state (completed, failed,
|
||||
/// canceled, or rejected) results in an <c>UnsupportedOperationError</c>.
|
||||
/// See: <see href="https://a2a-protocol.org/latest/specification/#332-error-handling"/>.
|
||||
/// </remarks>
|
||||
private async IAsyncEnumerable<StreamResponse> SubscribeToTaskWithFallbackAsync(
|
||||
string taskId,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var subscribeStream = this._a2aClient.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = taskId }, cancellationToken);
|
||||
|
||||
var enumerator = subscribeStream.GetAsyncEnumerator(cancellationToken);
|
||||
|
||||
// yield return cannot appear inside a try block that has catch clauses,
|
||||
// so we manually advance the enumerator within try/catch and yield outside it.
|
||||
// The outer try/finally (no catch) is allowed to contain yield return in C#.
|
||||
StreamResponse? fallbackResponse = null;
|
||||
bool disposed = false;
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
bool hasNext;
|
||||
try
|
||||
{
|
||||
hasNext = await enumerator.MoveNextAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (A2AException ex) when (ex.ErrorCode == A2AErrorCode.UnsupportedOperation)
|
||||
{
|
||||
this._logger.LogA2ASubscribeToTaskFallback(this.Id, this.Name, taskId, ex.Message);
|
||||
|
||||
// Dispose the enumerator before the fallback call to release the HTTP/SSE connection.
|
||||
await enumerator.DisposeAsync().ConfigureAwait(false);
|
||||
disposed = true;
|
||||
|
||||
AgentTask agentTask = await this._a2aClient.GetTaskAsync(new GetTaskRequest { Id = taskId }, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
fallbackResponse = new StreamResponse { Task = agentTask };
|
||||
break;
|
||||
}
|
||||
|
||||
if (!hasNext)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
yield return enumerator.Current;
|
||||
}
|
||||
|
||||
if (fallbackResponse is not null)
|
||||
{
|
||||
yield return fallbackResponse;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!disposed)
|
||||
{
|
||||
await enumerator.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateSession(A2AAgentSession? session, string? contextId, string? taskId = null)
|
||||
{
|
||||
if (session is null)
|
||||
@@ -284,7 +337,7 @@ public sealed class A2AAgent : AIAgent
|
||||
session.TaskId = taskId;
|
||||
}
|
||||
|
||||
private static AgentMessage CreateA2AMessage(A2AAgentSession typedSession, IEnumerable<ChatMessage> messages)
|
||||
private static Message CreateA2AMessage(A2AAgentSession typedSession, IEnumerable<ChatMessage> messages)
|
||||
{
|
||||
var a2aMessage = messages.ToA2AMessage();
|
||||
|
||||
@@ -324,7 +377,34 @@ public sealed class A2AAgent : AIAgent
|
||||
return null;
|
||||
}
|
||||
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(AgentMessage message)
|
||||
private AgentResponse ConvertToAgentResponse(Message message)
|
||||
{
|
||||
return new AgentResponse
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ResponseId = message.MessageId,
|
||||
FinishReason = ChatFinishReason.Stop,
|
||||
RawRepresentation = message,
|
||||
Messages = [message.ToChatMessage()],
|
||||
AdditionalProperties = message.Metadata?.ToAdditionalProperties(),
|
||||
};
|
||||
}
|
||||
|
||||
private AgentResponse ConvertToAgentResponse(AgentTask task)
|
||||
{
|
||||
return new AgentResponse
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ResponseId = task.Id,
|
||||
FinishReason = MapTaskStateToFinishReason(task.Status.State),
|
||||
RawRepresentation = task,
|
||||
Messages = task.ToChatMessages() ?? [],
|
||||
ContinuationToken = CreateContinuationToken(task.Id, task.Status.State),
|
||||
AdditionalProperties = task.Metadata?.ToAdditionalProperties(),
|
||||
};
|
||||
}
|
||||
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(Message message)
|
||||
{
|
||||
return new AgentResponseUpdate
|
||||
{
|
||||
@@ -349,32 +429,35 @@ public sealed class A2AAgent : AIAgent
|
||||
RawRepresentation = task,
|
||||
Role = ChatRole.Assistant,
|
||||
Contents = task.ToAIContents(),
|
||||
ContinuationToken = CreateContinuationToken(task.Id, task.Status.State),
|
||||
AdditionalProperties = task.Metadata?.ToAdditionalProperties(),
|
||||
};
|
||||
}
|
||||
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskUpdateEvent taskUpdateEvent)
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskStatusUpdateEvent statusUpdateEvent)
|
||||
{
|
||||
AgentResponseUpdate responseUpdate = new()
|
||||
return new AgentResponseUpdate
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ResponseId = taskUpdateEvent.TaskId,
|
||||
RawRepresentation = taskUpdateEvent,
|
||||
ResponseId = statusUpdateEvent.TaskId,
|
||||
RawRepresentation = statusUpdateEvent,
|
||||
Role = ChatRole.Assistant,
|
||||
AdditionalProperties = taskUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
|
||||
FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State),
|
||||
AdditionalProperties = statusUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
if (taskUpdateEvent is TaskArtifactUpdateEvent artifactUpdateEvent)
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskArtifactUpdateEvent artifactUpdateEvent)
|
||||
{
|
||||
return new AgentResponseUpdate
|
||||
{
|
||||
responseUpdate.Contents = artifactUpdateEvent.Artifact.ToAIContents();
|
||||
responseUpdate.RawRepresentation = artifactUpdateEvent;
|
||||
}
|
||||
else if (taskUpdateEvent is TaskStatusUpdateEvent statusUpdateEvent)
|
||||
{
|
||||
responseUpdate.FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State);
|
||||
}
|
||||
|
||||
return responseUpdate;
|
||||
AgentId = this.Id,
|
||||
ResponseId = artifactUpdateEvent.TaskId,
|
||||
RawRepresentation = artifactUpdateEvent,
|
||||
Role = ChatRole.Assistant,
|
||||
Contents = artifactUpdateEvent.Artifact.ToAIContents(),
|
||||
AdditionalProperties = artifactUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
private static ChatFinishReason? MapTaskStateToFinishReason(TaskState state)
|
||||
|
||||
@@ -34,4 +34,17 @@ internal static partial class A2AAgentLogMessages
|
||||
string methodName,
|
||||
string agentId,
|
||||
string? agentName);
|
||||
|
||||
/// <summary>
|
||||
/// Logs <see cref="A2AAgent"/> falling back to GetTaskAsync after SubscribeToTaskAsync failed with UnsupportedOperation.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Warning,
|
||||
Message = "A2AAgent {AgentId}/{AgentName} SubscribeToTask for task '{TaskId}' failed with UnsupportedOperation: {ErrorMessage}. Falling back to GetTaskAsync.")]
|
||||
public static partial void LogA2ASubscribeToTaskFallback(
|
||||
this ILogger logger,
|
||||
string agentId,
|
||||
string? agentName,
|
||||
string taskId,
|
||||
string errorMessage);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ internal class A2AContinuationToken : ResponseContinuationToken
|
||||
{
|
||||
case "taskId":
|
||||
reader.Read();
|
||||
taskId = reader.GetString()!;
|
||||
taskId = reader.GetString() ?? throw new JsonException("The 'taskId' property must contain a non-null string value.");
|
||||
break;
|
||||
default:
|
||||
throw new JsonException($"Unrecognized property '{propertyName}'.");
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -25,12 +24,15 @@ public static class A2AAgentCardExtensions
|
||||
/// </remarks>
|
||||
/// <param name="card">The <see cref="AgentCard" /> to use for the agent creation.</param>
|
||||
/// <param name="httpClient">The <see cref="HttpClient"/> to use for HTTP requests.</param>
|
||||
/// <param name="options">
|
||||
/// Optional <see cref="A2AClientOptions"/> controlling protocol binding preference.
|
||||
/// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
|
||||
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
|
||||
public static AIAgent AsAIAgent(this AgentCard card, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null)
|
||||
public static AIAgent AsAIAgent(this AgentCard card, HttpClient? httpClient = null, A2AClientOptions? options = null, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
// Create the A2A client using the agent URL from the card.
|
||||
var a2aClient = new A2AClient(new Uri(card.Url), httpClient);
|
||||
var a2aClient = A2AClientFactory.Create(card, httpClient, options);
|
||||
|
||||
return a2aClient.AsAIAgent(name: card.Name, description: card.Description, loggerFactory: loggerFactory);
|
||||
}
|
||||
|
||||
@@ -34,14 +34,18 @@ public static class A2ACardResolverExtensions
|
||||
/// </remarks>
|
||||
/// <param name="resolver">The <see cref="A2ACardResolver" /> to use for the agent creation.</param>
|
||||
/// <param name="httpClient">The <see cref="HttpClient"/> to use for HTTP requests.</param>
|
||||
/// <param name="options">
|
||||
/// Optional <see cref="A2AClientOptions"/> controlling protocol binding preference.
|
||||
/// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback.
|
||||
/// </param>
|
||||
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
|
||||
public static async Task<AIAgent> GetAIAgentAsync(this A2ACardResolver resolver, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default)
|
||||
public static async Task<AIAgent> GetAIAgentAsync(this A2ACardResolver resolver, HttpClient? httpClient = null, A2AClientOptions? options = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Obtain the agent card from the resolver.
|
||||
var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return agentCard.AsAIAgent(httpClient, loggerFactory);
|
||||
return agentCard.AsAIAgent(httpClient, options, loggerFactory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging;
|
||||
namespace A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="A2AClient"/>
|
||||
/// Provides extension methods for <see cref="IA2AClient"/>
|
||||
/// to simplify the creation of A2A agents.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -29,12 +29,12 @@ public static class A2AClientExtensions
|
||||
/// <see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#3-direct-configuration--private-discovery">Direct Configuration / Private Discovery</see>
|
||||
/// discovery mechanism.
|
||||
/// </remarks>
|
||||
/// <param name="client">The <see cref="A2AClient" /> to use for the agent.</param>
|
||||
/// <param name="client">The <see cref="IA2AClient" /> to use for the agent.</param>
|
||||
/// <param name="id">The unique identifier for the agent.</param>
|
||||
/// <param name="name">The the name of the agent.</param>
|
||||
/// <param name="description">The description of the agent.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
|
||||
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
|
||||
public static AIAgent AsAIAgent(this A2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) =>
|
||||
public static AIAgent AsAIAgent(this IA2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) =>
|
||||
new A2AAgent(client, id, name, description, loggerFactory);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Microsoft.Extensions.AI;
|
||||
/// </summary>
|
||||
internal static class ChatMessageExtensions
|
||||
{
|
||||
internal static AgentMessage ToA2AMessage(this IEnumerable<ChatMessage> messages)
|
||||
internal static Message ToA2AMessage(this IEnumerable<ChatMessage> messages)
|
||||
{
|
||||
List<Part> allParts = [];
|
||||
|
||||
@@ -23,10 +23,10 @@ internal static class ChatMessageExtensions
|
||||
}
|
||||
}
|
||||
|
||||
return new AgentMessage
|
||||
return new Message
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
Role = MessageRole.User,
|
||||
Role = Role.User,
|
||||
Parts = allParts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<NoWarn>$(NoWarn);MEAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using A2A;
|
||||
using A2A.AspNetCore;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.AspNetCore.Builder;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for mapping A2A protocol endpoints for AI agents.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
|
||||
public static class A2AEndpointRouteBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps A2A HTTP+JSON endpoints for the specified agent to the given path.
|
||||
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
|
||||
/// <c>AddA2AServer</c> during service registration.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentBuilder">The configuration builder for the agent.</param>
|
||||
/// <param name="path">The route path prefix for A2A endpoints.</param>
|
||||
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
|
||||
return endpoints.MapA2AHttpJson(agentBuilder.Name, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps A2A HTTP+JSON endpoints for the specified agent to the given path.
|
||||
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
|
||||
/// <c>AddA2AServer</c> during service registration.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agent">The agent whose name identifies the registered A2A server.</param>
|
||||
/// <param name="path">The route path prefix for A2A endpoints.</param>
|
||||
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, AIAgent agent, string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name));
|
||||
|
||||
return endpoints.MapA2AHttpJson(agent.Name, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps A2A HTTP+JSON endpoints for the agent with the specified name to the given path.
|
||||
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
|
||||
/// <c>AddA2AServer</c> during service registration.
|
||||
/// </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 path prefix for A2A endpoints.</param>
|
||||
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, string agentName, string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(agentName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
var a2aServer = endpoints.ServiceProvider.GetKeyedService<A2AServer>(agentName)
|
||||
?? throw new InvalidOperationException(
|
||||
$"No A2AServer is registered for agent '{agentName}'. " +
|
||||
$"Call services.AddA2AServer(\"{agentName}\") or agentBuilder.AddA2AServer() during service registration to register one.");
|
||||
|
||||
// TODO: The stub AgentCard is temporary and will be removed once the A2A SDK either removes the
|
||||
// agentCard parameter of MapHttpA2A or makes it optional. MapHttpA2A exposes the agent card via a
|
||||
// GET {path}/card endpoint that is not part of the A2A spec, so it is not expected to be consumed
|
||||
// by any agent - returning a stub agent card here is safe.
|
||||
var stubAgentCard = new AgentCard { Name = "A2A Agent" };
|
||||
|
||||
return endpoints.MapHttpA2A(a2aServer, stubAgentCard, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps A2A JSON-RPC endpoints for the specified agent to the given path.
|
||||
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
|
||||
/// <c>AddA2AServer</c> during service registration.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agentBuilder">The configuration builder for the agent.</param>
|
||||
/// <param name="path">The route path prefix for A2A endpoints.</param>
|
||||
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
|
||||
return endpoints.MapA2AJsonRpc(agentBuilder.Name, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps A2A JSON-RPC endpoints for the specified agent to the given path.
|
||||
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
|
||||
/// <c>AddA2AServer</c> during service registration.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="agent">The agent whose name identifies the registered A2A server.</param>
|
||||
/// <param name="path">The route path prefix for A2A endpoints.</param>
|
||||
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, AIAgent agent, string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name));
|
||||
|
||||
return endpoints.MapA2AJsonRpc(agent.Name, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps A2A JSON-RPC endpoints for the agent with the specified name to the given path.
|
||||
/// An <see cref="A2AServer"/> for the agent must be registered first by calling
|
||||
/// <c>AddA2AServer</c> during service registration.
|
||||
/// </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 path prefix for A2A endpoints.</param>
|
||||
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further endpoint configuration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, string agentName, string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(agentName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
var a2aServer = endpoints.ServiceProvider.GetKeyedService<A2AServer>(agentName)
|
||||
?? throw new InvalidOperationException(
|
||||
$"No A2AServer is registered for agent '{agentName}'. " +
|
||||
$"Call services.AddA2AServer(\"{agentName}\") or agentBuilder.AddA2AServer() during service registration to register one.");
|
||||
|
||||
return endpoints.MapA2A(a2aServer, path);
|
||||
}
|
||||
}
|
||||
-385
@@ -1,385 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using A2A;
|
||||
using A2A.AspNetCore;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Agents.AI.Hosting.A2A;
|
||||
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>
|
||||
/// 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>
|
||||
/// <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, 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>
|
||||
/// <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>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
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>
|
||||
/// <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="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</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, IHostedAgentBuilder agentBuilder, string path, Action<ITaskManager> configureTaskManager)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
return endpoints.MapA2A(agentBuilder.Name, path, configureTaskManager);
|
||||
}
|
||||
|
||||
/// <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="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, string agentName, string path, Action<ITaskManager> configureTaskManager)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
return endpoints.MapA2A(agent, path, configureTaskManager);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <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, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard)
|
||||
=> endpoints.MapA2A(agentBuilder, 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="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>
|
||||
/// <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)
|
||||
=> 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>
|
||||
/// <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="configureTaskManager">The callback to configure <see cref="ITaskManager"/>.</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, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, Action<ITaskManager> configureTaskManager)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
return endpoints.MapA2A(agentBuilder.Name, path, agentCard, configureTaskManager);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <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)
|
||||
=> 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, 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="agent">The agent to use for A2A protocol integration.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
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>
|
||||
/// <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>
|
||||
/// <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, runMode: agentRunMode);
|
||||
var endpointConventionBuilder = endpoints.MapA2A(taskManager, path);
|
||||
|
||||
configureTaskManager(taskManager);
|
||||
return endpointConventionBuilder;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <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)
|
||||
=> 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>
|
||||
/// <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>
|
||||
/// <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)
|
||||
=> 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, runMode: agentRunMode);
|
||||
var endpointConventionBuilder = endpoints.MapA2A(taskManager, path);
|
||||
|
||||
configureTaskManager(taskManager);
|
||||
|
||||
return endpointConventionBuilder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps HTTP A2A communication endpoints to the specified path using the provided TaskManager.
|
||||
/// TaskManager should be preconfigured before calling this method.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the A2A endpoints to.</param>
|
||||
/// <param name="taskManager">Pre-configured A2A TaskManager to use for A2A endpoints handling.</param>
|
||||
/// <param name="path">The route group to use for A2A endpoints.</param>
|
||||
/// <returns>Configured <see cref="ITaskManager"/> for A2A integration.</returns>
|
||||
public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, ITaskManager taskManager, string path)
|
||||
{
|
||||
// note: current SDK version registers multiple `.well-known/agent.json` handlers here.
|
||||
// it makes app return HTTP 500, but will be fixed once new A2A SDK is released.
|
||||
// see https://github.com/microsoft/agent-framework/issues/476 for details
|
||||
A2ARouteBuilderExtensions.MapA2A(endpoints, taskManager, path);
|
||||
return endpoints.MapHttpA2A(taskManager, path);
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -1,9 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<RootNamespace>Microsoft.Agents.AI.Hosting.A2A.AspNetCore</RootNamespace>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<!-- RT0002: Microsoft.Agents.AI.Hosting.A2A is intentionally referenced as a transitive dependency
|
||||
so that consumers of this package automatically get the AddA2AServer registration extensions. -->
|
||||
<NoWarn>$(NoWarn);RT0002</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
@@ -13,7 +16,7 @@
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="A2A.AspNetCore" />
|
||||
</ItemGroup>
|
||||
@@ -21,7 +24,7 @@
|
||||
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible($(TargetFramework), 'net10.0'))">
|
||||
<PackageReference Include="System.Linq.AsyncEnumerable" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using A2A;
|
||||
using Microsoft.Agents.AI.Hosting.A2A.Converters;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IAgentHandler"/> implementation that bridges an <see cref="AIAgent"/> to the
|
||||
/// A2A (Agent2Agent) protocol. Handles message execution and cancellation by delegating to
|
||||
/// the underlying agent and translating responses into A2A events.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
|
||||
internal sealed class A2AAgentHandler : IAgentHandler
|
||||
{
|
||||
private readonly AIHostAgent _hostAgent;
|
||||
private readonly AgentRunMode _runMode;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="A2AAgentHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="hostAgent">The hosted agent that provides the execution logic.</param>
|
||||
/// <param name="runMode">Controls whether the agent runs in background mode.</param>
|
||||
public A2AAgentHandler(
|
||||
AIHostAgent hostAgent,
|
||||
AgentRunMode runMode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hostAgent);
|
||||
ArgumentNullException.ThrowIfNull(runMode);
|
||||
|
||||
this._hostAgent = hostAgent;
|
||||
this._runMode = runMode;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
|
||||
{
|
||||
if (context.IsContinuation)
|
||||
{
|
||||
return this.HandleTaskUpdateAsync(context, eventQueue, cancellationToken);
|
||||
}
|
||||
|
||||
return this.HandleNewMessageAsync(context, eventQueue, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
|
||||
{
|
||||
var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId);
|
||||
await taskUpdater.CancelAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
|
||||
{
|
||||
var contextId = context.ContextId ?? Guid.NewGuid().ToString("N");
|
||||
var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 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.
|
||||
if (context.Message?.ReferenceTaskIds is { Count: > 0 })
|
||||
{
|
||||
throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context.");
|
||||
}
|
||||
|
||||
List<ChatMessage> chatMessages = context.Message is not null ? [context.Message.ToChatMessage()] : [];
|
||||
|
||||
// Decide whether to run in background based on user preferences and agent capabilities
|
||||
var decisionContext = new A2ARunDecisionContext(context);
|
||||
var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var options = context.Metadata is not { Count: > 0 }
|
||||
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
|
||||
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() };
|
||||
|
||||
var response = await this._hostAgent.RunAsync(
|
||||
chatMessages,
|
||||
session: session,
|
||||
options: options,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.ContinuationToken is null)
|
||||
{
|
||||
// Return a lightweight message response (no task lifecycle needed).
|
||||
var message = CreateMessageFromResponse(contextId, response);
|
||||
await eventQueue.EnqueueMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Long-running operation: emit task lifecycle events.
|
||||
var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId);
|
||||
await taskUpdater.SubmitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Message? progressMessage = response.Messages.Count > 0
|
||||
? CreateMessageFromResponse(contextId, response)
|
||||
: null;
|
||||
|
||||
await taskUpdater.StartWorkAsync(progressMessage, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
|
||||
{
|
||||
var contextId = context.ContextId ?? Guid.NewGuid().ToString("N");
|
||||
var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
List<ChatMessage> chatMessages = ExtractChatMessagesFromTaskHistory(context.Task);
|
||||
|
||||
var decisionContext = new A2ARunDecisionContext(context);
|
||||
var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var options = context.Metadata is not { Count: > 0 }
|
||||
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
|
||||
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() };
|
||||
|
||||
AgentResponse response;
|
||||
try
|
||||
{
|
||||
response = await this._hostAgent.RunAsync(
|
||||
chatMessages,
|
||||
session: session,
|
||||
options: options,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
var failUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId);
|
||||
await failUpdater.FailAsync(message: null, CancellationToken.None).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.ContinuationToken is null)
|
||||
{
|
||||
// Complete the task with an artifact containing the response.
|
||||
var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId);
|
||||
await taskUpdater.AddArtifactAsync(response.Messages.ToParts(), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await taskUpdater.CompleteAsync(message: null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Still working: emit progress status.
|
||||
var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId);
|
||||
|
||||
Message? progressMessage = response.Messages.Count > 0
|
||||
? CreateMessageFromResponse(contextId, response)
|
||||
: null;
|
||||
|
||||
await taskUpdater.StartWorkAsync(progressMessage, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static Message CreateMessageFromResponse(string contextId, AgentResponse response) =>
|
||||
new()
|
||||
{
|
||||
MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
|
||||
ContextId = contextId,
|
||||
Role = Role.Agent,
|
||||
Parts = response.Messages.ToParts(),
|
||||
Metadata = response.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,13 @@ namespace Microsoft.Agents.AI.Hosting.A2A;
|
||||
/// </summary>
|
||||
public sealed class A2ARunDecisionContext
|
||||
{
|
||||
internal A2ARunDecisionContext(MessageSendParams messageSendParams)
|
||||
internal A2ARunDecisionContext(RequestContext requestContext)
|
||||
{
|
||||
this.MessageSendParams = messageSendParams;
|
||||
this.RequestContext = requestContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parameters of the incoming A2A message that triggered this run.
|
||||
/// Gets the request context of the incoming A2A request that triggered this run.
|
||||
/// </summary>
|
||||
public MessageSendParams MessageSendParams { get; }
|
||||
public RequestContext RequestContext { get; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using A2A;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring A2A server registration.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
|
||||
public sealed class A2AServerRegistrationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the agent run mode that controls how the agent responds to A2A requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/>, defaults to <see cref="AgentRunMode.DisallowBackground"/>.
|
||||
/// </remarks>
|
||||
public AgentRunMode? AgentRunMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the A2A server options used to configure the underlying <see cref="A2AServer"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <see langword="null"/>, no custom server options are applied.
|
||||
/// </remarks>
|
||||
public A2AServerOptions? ServerOptions { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using A2A;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting;
|
||||
using Microsoft.Agents.AI.Hosting.A2A;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for registering A2A server instances in the dependency injection container.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
|
||||
public static class A2AServerServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers an <see cref="A2AServer"/> in the dependency injection container, keyed by the agent name
|
||||
/// specified in the <paramref name="agentBuilder"/>. This method only registers the server; to expose it
|
||||
/// as an HTTP endpoint, call one of the <c>MapA2AHttpJson</c> or <c>MapA2AJsonRpc</c> endpoint mapping
|
||||
/// methods during application startup.
|
||||
/// </summary>
|
||||
/// <param name="agentBuilder">The agent builder whose name identifies the agent.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="agentBuilder"/> for chaining.</returns>
|
||||
public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentBuilder);
|
||||
|
||||
agentBuilder.ServiceCollection.AddA2AServer(agentBuilder.Name, configureOptions);
|
||||
|
||||
return agentBuilder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an <see cref="A2AServer"/> in the dependency injection container, keyed by the specified
|
||||
/// agent name. This method only registers the server; to expose it as an HTTP endpoint, call one of the
|
||||
/// <c>MapA2AHttpJson</c> or <c>MapA2AJsonRpc</c> endpoint mapping methods during application startup.
|
||||
/// </summary>
|
||||
/// <param name="builder">The host application builder to configure.</param>
|
||||
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.Services.AddA2AServer(agentName, configureOptions);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an <see cref="A2AServer"/> in the dependency injection container for the specified
|
||||
/// <see cref="AIAgent"/> instance, keyed by the agent's <see cref="AIAgent.Name"/>. This method only
|
||||
/// registers the server; to expose it as an HTTP endpoint, call one of the <c>MapA2AHttpJson</c> or
|
||||
/// <c>MapA2AJsonRpc</c> endpoint mapping methods during application startup.
|
||||
/// </summary>
|
||||
/// <param name="builder">The host application builder to configure.</param>
|
||||
/// <param name="agent">The agent instance to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="builder"/> for chaining.</returns>
|
||||
public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.Services.AddA2AServer(agent, configureOptions);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an <see cref="A2AServer"/> in the dependency injection container, keyed by the specified
|
||||
/// agent name. This method only registers the server; to expose it as an HTTP endpoint, call one of the
|
||||
/// <c>MapA2AHttpJson</c> or <c>MapA2AJsonRpc</c> endpoint mapping methods during application startup.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to add the A2A server to.</param>
|
||||
/// <param name="agentName">The name of the agent to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="services"/> for chaining.</returns>
|
||||
public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(agentName);
|
||||
|
||||
A2AServerRegistrationOptions? options = null;
|
||||
if (configureOptions is not null)
|
||||
{
|
||||
options = new A2AServerRegistrationOptions();
|
||||
configureOptions(options);
|
||||
}
|
||||
|
||||
services.AddKeyedSingleton(agentName, (sp, _) =>
|
||||
{
|
||||
var agent = sp.GetRequiredKeyedService<AIAgent>(agentName);
|
||||
return CreateA2AServer(sp, agent, options);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an <see cref="A2AServer"/> in the dependency injection container for the specified
|
||||
/// <see cref="AIAgent"/> instance, keyed by the agent's <see cref="AIAgent.Name"/>. This method only
|
||||
/// registers the server; to expose it as an HTTP endpoint, call one of the <c>MapA2AHttpJson</c> or
|
||||
/// <c>MapA2AJsonRpc</c> endpoint mapping methods during application startup.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to add the A2A server to.</param>
|
||||
/// <param name="agent">The agent instance to create an A2A server for.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="A2AServerRegistrationOptions"/>.</param>
|
||||
/// <returns>The <paramref name="services"/> for chaining.</returns>
|
||||
public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action<A2AServerRegistrationOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name));
|
||||
|
||||
A2AServerRegistrationOptions? options = null;
|
||||
if (configureOptions is not null)
|
||||
{
|
||||
options = new A2AServerRegistrationOptions();
|
||||
configureOptions(options);
|
||||
}
|
||||
|
||||
services.AddKeyedSingleton(agent.Name, (sp, _) => CreateA2AServer(sp, agent, options));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAgent agent, A2AServerRegistrationOptions? options)
|
||||
{
|
||||
var agentHandler = serviceProvider.GetKeyedService<IAgentHandler>(agent.Name);
|
||||
if (agentHandler is null)
|
||||
{
|
||||
var agentSessionStore = serviceProvider.GetKeyedService<AgentSessionStore>(agent.Name);
|
||||
var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground;
|
||||
|
||||
var hostAgent = new AIHostAgent(
|
||||
innerAgent: agent,
|
||||
sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore());
|
||||
|
||||
agentHandler = new A2AAgentHandler(hostAgent, runMode);
|
||||
}
|
||||
|
||||
var loggerFactory = serviceProvider.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance;
|
||||
var taskStore = serviceProvider.GetKeyedService<ITaskStore>(agent.Name) ?? new InMemoryTaskStore();
|
||||
|
||||
return new A2AServer(
|
||||
agentHandler,
|
||||
taskStore,
|
||||
new ChannelEventNotifier(),
|
||||
loggerFactory.CreateLogger<A2AServer>(),
|
||||
options?.ServerOptions);
|
||||
}
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
// 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>
|
||||
/// <param name="agent">Agent to attach A2A messaging processing capabilities to.</param>
|
||||
/// <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,
|
||||
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();
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified <see cref="AIAgent"/>.
|
||||
/// </summary>
|
||||
/// <param name="agent">Agent to attach A2A messaging processing capabilities to.</param>
|
||||
/// <param name="agentCard">The agent card to return on query.</param>
|
||||
/// <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,
|
||||
AgentRunMode? runMode = null,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore, runMode, jsonSerializerOptions);
|
||||
|
||||
taskManager.OnAgentCardQuery += (context, query) =>
|
||||
{
|
||||
// A2A SDK assigns the url on its own
|
||||
// we can help user if they did not set Url explicitly.
|
||||
if (string.IsNullOrEmpty(agentCard.Url))
|
||||
{
|
||||
agentCard.Url = context.TrimEnd('/');
|
||||
}
|
||||
|
||||
return Task.FromResult(agentCard);
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
@@ -28,7 +29,7 @@ public sealed class AgentRunMode : IEquatable<AgentRunMode>
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dissallows the background responses from the agent. Is equivalent to configuring <see cref="AgentRunOptions.AllowBackgroundResponses"/> as <c>false</c>.
|
||||
/// Disallows 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);
|
||||
@@ -79,18 +80,22 @@ public sealed class AgentRunMode : IEquatable<AgentRunMode>
|
||||
}
|
||||
|
||||
// No delegate provided — fall back to "message" behavior.
|
||||
return ValueTask.FromResult(true);
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(AgentRunMode? other) =>
|
||||
other is not null && string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase);
|
||||
other is not null
|
||||
&& string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase)
|
||||
&& ReferenceEquals(this._runInBackground, other._runInBackground);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool Equals(object? obj) => this.Equals(obj as AgentRunMode);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(this._value);
|
||||
public override int GetHashCode() => HashCode.Combine(
|
||||
StringComparer.OrdinalIgnoreCase.GetHashCode(this._value),
|
||||
RuntimeHelpers.GetHashCode(this._runInBackground));
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString() => this._value;
|
||||
|
||||
@@ -31,21 +31,21 @@ internal static class MessageConverter
|
||||
return parts;
|
||||
}
|
||||
/// <summary>
|
||||
/// Converts A2A MessageSendParams to a collection of Microsoft.Extensions.AI ChatMessage objects.
|
||||
/// Converts A2A SendMessageRequest to a collection of Microsoft.Extensions.AI ChatMessage objects.
|
||||
/// </summary>
|
||||
/// <param name="messageSendParams">The A2A message send parameters to convert.</param>
|
||||
/// <param name="sendMessageRequest">The A2A send message request to convert.</param>
|
||||
/// <returns>A read-only collection of ChatMessage objects.</returns>
|
||||
public static List<ChatMessage> ToChatMessages(this MessageSendParams messageSendParams)
|
||||
public static List<ChatMessage> ToChatMessages(this SendMessageRequest sendMessageRequest)
|
||||
{
|
||||
if (messageSendParams is null)
|
||||
if (sendMessageRequest is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new List<ChatMessage>();
|
||||
if (messageSendParams.Message?.Parts is not null)
|
||||
if (sendMessageRequest.Message?.Parts is not null)
|
||||
{
|
||||
result.Add(messageSendParams.Message.ToChatMessage());
|
||||
result.Add(sendMessageRequest.Message.ToChatMessage());
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,18 @@ public sealed class A2AContinuationTokenTests
|
||||
Assert.Throws<ArgumentException>(() => A2AContinuationToken.FromToken(emptyToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromToken_WithNullTaskIdValue_ThrowsJsonException()
|
||||
{
|
||||
// Arrange
|
||||
var jsonWithNullTaskId = System.Text.Encoding.UTF8.GetBytes("{ \"taskId\": null }").AsMemory();
|
||||
var mockToken = new MockResponseContinuationToken(jsonWithNullTaskId);
|
||||
|
||||
// Act & Assert
|
||||
var ex = Assert.Throws<JsonException>(() => A2AContinuationToken.FromToken(mockToken));
|
||||
Assert.Contains("taskId", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromToken_WithMissingTaskIdProperty_ThrowsException()
|
||||
{
|
||||
|
||||
+12
-12
@@ -42,14 +42,14 @@ public sealed class A2AAIContentExtensionsTests
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.Count);
|
||||
|
||||
var firstTextPart = Assert.IsType<TextPart>(result[0]);
|
||||
Assert.Equal("First text", firstTextPart.Text);
|
||||
Assert.Equal(PartContentCase.Text, result[0].ContentCase);
|
||||
Assert.Equal("First text", result[0].Text);
|
||||
|
||||
var filePart = Assert.IsType<FilePart>(result[1]);
|
||||
Assert.Equal("https://example.com/file1.txt", filePart.File.Uri?.ToString());
|
||||
Assert.Equal(PartContentCase.Url, result[1].ContentCase);
|
||||
Assert.Equal("https://example.com/file1.txt", result[1].Url);
|
||||
|
||||
var secondTextPart = Assert.IsType<TextPart>(result[2]);
|
||||
Assert.Equal("Second text", secondTextPart.Text);
|
||||
Assert.Equal(PartContentCase.Text, result[2].ContentCase);
|
||||
Assert.Equal("Second text", result[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -72,14 +72,14 @@ public sealed class A2AAIContentExtensionsTests
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.Count);
|
||||
|
||||
var firstTextPart = Assert.IsType<TextPart>(result[0]);
|
||||
Assert.Equal("First text", firstTextPart.Text);
|
||||
Assert.Equal(PartContentCase.Text, result[0].ContentCase);
|
||||
Assert.Equal("First text", result[0].Text);
|
||||
|
||||
var filePart = Assert.IsType<FilePart>(result[1]);
|
||||
Assert.Equal("https://example.com/file.txt", filePart.File.Uri?.ToString());
|
||||
Assert.Equal(PartContentCase.Url, result[1].ContentCase);
|
||||
Assert.Equal("https://example.com/file.txt", result[1].Url);
|
||||
|
||||
var secondTextPart = Assert.IsType<TextPart>(result[2]);
|
||||
Assert.Equal("Second text", secondTextPart.Text);
|
||||
Assert.Equal(PartContentCase.Text, result[2].ContentCase);
|
||||
Assert.Equal("Second text", result[2].Text);
|
||||
}
|
||||
|
||||
// Mock class for testing unsupported scenarios
|
||||
|
||||
+112
-8
@@ -26,7 +26,7 @@ public sealed class A2AAgentCardExtensionsTests
|
||||
{
|
||||
Name = "Test Agent",
|
||||
Description = "A test agent for unit testing",
|
||||
Url = "http://test-endpoint/agent"
|
||||
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,13 +50,13 @@ public sealed class A2AAgentCardExtensionsTests
|
||||
using var handler = new HttpMessageHandlerStub();
|
||||
using var httpClient = new HttpClient(handler, false);
|
||||
|
||||
handler.ResponsesToReturn.Enqueue(new AgentMessage
|
||||
handler.ResponsesToReturn.Enqueue(new Message
|
||||
{
|
||||
Role = MessageRole.Agent,
|
||||
Parts = [new TextPart { Text = "Response" }],
|
||||
Role = Role.Agent,
|
||||
Parts = [Part.FromText("Response")],
|
||||
});
|
||||
|
||||
var agent = this._agentCard.AsAIAgent(httpClient);
|
||||
var agent = this._agentCard.AsAIAgent(httpClient: httpClient);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync("Test input");
|
||||
@@ -66,6 +66,105 @@ public sealed class A2AAgentCardExtensionsTests
|
||||
Assert.Equal(new Uri("http://test-endpoint/agent"), handler.CapturedUris[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AsAIAgent_WithPreferredBindings_UsesMatchingInterfaceAsync()
|
||||
{
|
||||
// Arrange
|
||||
var card = new AgentCard
|
||||
{
|
||||
Name = "Multi-Interface Agent",
|
||||
Description = "An agent with multiple interfaces",
|
||||
SupportedInterfaces =
|
||||
[
|
||||
new AgentInterface { Url = "http://first/agent", ProtocolBinding = ProtocolBindingNames.HttpJson },
|
||||
new AgentInterface { Url = "http://second/agent", ProtocolBinding = ProtocolBindingNames.JsonRpc },
|
||||
]
|
||||
};
|
||||
|
||||
using var handler = new HttpMessageHandlerStub();
|
||||
using var httpClient = new HttpClient(handler, false);
|
||||
|
||||
handler.ResponsesToReturn.Enqueue(new Message
|
||||
{
|
||||
Role = Role.Agent,
|
||||
Parts = [Part.FromText("Response")],
|
||||
});
|
||||
|
||||
var options = new A2AClientOptions
|
||||
{
|
||||
PreferredBindings = [ProtocolBindingNames.JsonRpc]
|
||||
};
|
||||
|
||||
var agent = card.AsAIAgent(httpClient, options: options);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync("Test input");
|
||||
|
||||
// Assert
|
||||
Assert.Single(handler.CapturedUris);
|
||||
Assert.Equal(new Uri("http://second/agent"), handler.CapturedUris[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAIAgent_WithNullOptions_UsesDefaultBindingPreference()
|
||||
{
|
||||
// Arrange
|
||||
var card = new AgentCard
|
||||
{
|
||||
Name = "Default Options Agent",
|
||||
Description = "Tests default A2AClientOptions behavior",
|
||||
SupportedInterfaces =
|
||||
[
|
||||
new AgentInterface { Url = "http://default/agent" },
|
||||
]
|
||||
};
|
||||
|
||||
// Act - null options should use defaults (HTTP+JSON first, JSON-RPC as fallback)
|
||||
var agent = card.AsAIAgent(options: null);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<A2AAgent>(agent);
|
||||
Assert.Equal("Default Options Agent", agent.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAIAgent_WithNoMatchingBinding_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var card = new AgentCard
|
||||
{
|
||||
Name = "Unmatched Binding Agent",
|
||||
Description = "Agent with unsupported binding only",
|
||||
SupportedInterfaces =
|
||||
[
|
||||
new AgentInterface { Url = "http://grpc/agent", ProtocolBinding = "GRPC" },
|
||||
]
|
||||
};
|
||||
|
||||
var options = new A2AClientOptions
|
||||
{
|
||||
PreferredBindings = [ProtocolBindingNames.JsonRpc]
|
||||
};
|
||||
|
||||
// Act & Assert - factory should throw when no matching binding exists
|
||||
Assert.ThrowsAny<Exception>(() => card.AsAIAgent(options: options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsAIAgent_WithNoSupportedInterfaces_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var card = new AgentCard
|
||||
{
|
||||
Name = "No Interfaces Agent",
|
||||
Description = "Agent with no supported interfaces",
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsAny<Exception>(() => card.AsAIAgent());
|
||||
}
|
||||
|
||||
internal sealed class HttpMessageHandlerStub : HttpMessageHandler
|
||||
{
|
||||
public Queue ResponsesToReturn { get; } = new();
|
||||
@@ -86,13 +185,18 @@ public sealed class A2AAgentCardExtensionsTests
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
else if (response is AgentMessage message)
|
||||
else if (response is Message message)
|
||||
{
|
||||
var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse<A2AEvent>("response-id", message);
|
||||
var sendMessageResponse = new SendMessageResponse { Message = message };
|
||||
var jsonRpcResponse = new JsonRpcResponse
|
||||
{
|
||||
Id = "response-id",
|
||||
Result = JsonSerializer.SerializeToNode(sendMessageResponse, A2AJsonUtilities.DefaultOptions)
|
||||
};
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
|
||||
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions), Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+10
-10
@@ -40,7 +40,7 @@ public sealed class A2AAgentTaskExtensionsTests
|
||||
{
|
||||
Id = "task1",
|
||||
Artifacts = [],
|
||||
Status = new AgentTaskStatus { State = TaskState.Completed },
|
||||
Status = new TaskStatus { State = TaskState.Completed },
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -58,7 +58,7 @@ public sealed class A2AAgentTaskExtensionsTests
|
||||
{
|
||||
Id = "task1",
|
||||
Artifacts = null,
|
||||
Status = new AgentTaskStatus { State = TaskState.Completed },
|
||||
Status = new TaskStatus { State = TaskState.Completed },
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -76,7 +76,7 @@ public sealed class A2AAgentTaskExtensionsTests
|
||||
{
|
||||
Id = "task1",
|
||||
Artifacts = [],
|
||||
Status = new AgentTaskStatus { State = TaskState.Completed },
|
||||
Status = new TaskStatus { State = TaskState.Completed },
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -94,7 +94,7 @@ public sealed class A2AAgentTaskExtensionsTests
|
||||
{
|
||||
Id = "task1",
|
||||
Artifacts = null,
|
||||
Status = new AgentTaskStatus { State = TaskState.Completed },
|
||||
Status = new TaskStatus { State = TaskState.Completed },
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -110,14 +110,14 @@ public sealed class A2AAgentTaskExtensionsTests
|
||||
// Arrange
|
||||
var artifact = new Artifact
|
||||
{
|
||||
Parts = [new TextPart { Text = "response" }],
|
||||
Parts = [Part.FromText("response")],
|
||||
};
|
||||
|
||||
var agentTask = new AgentTask
|
||||
{
|
||||
Id = "task1",
|
||||
Artifacts = [artifact],
|
||||
Status = new AgentTaskStatus { State = TaskState.Completed },
|
||||
Status = new TaskStatus { State = TaskState.Completed },
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -136,15 +136,15 @@ public sealed class A2AAgentTaskExtensionsTests
|
||||
// Arrange
|
||||
var artifact1 = new Artifact
|
||||
{
|
||||
Parts = [new TextPart { Text = "content1" }],
|
||||
Parts = [Part.FromText("content1")],
|
||||
};
|
||||
|
||||
var artifact2 = new Artifact
|
||||
{
|
||||
Parts =
|
||||
[
|
||||
new TextPart { Text = "content2" },
|
||||
new TextPart { Text = "content3" }
|
||||
Part.FromText("content2"),
|
||||
Part.FromText("content3")
|
||||
],
|
||||
};
|
||||
|
||||
@@ -152,7 +152,7 @@ public sealed class A2AAgentTaskExtensionsTests
|
||||
{
|
||||
Id = "task1",
|
||||
Artifacts = [artifact1, artifact2],
|
||||
Status = new AgentTaskStatus { State = TaskState.Completed },
|
||||
Status = new TaskStatus { State = TaskState.Completed },
|
||||
};
|
||||
|
||||
// Act
|
||||
|
||||
+6
-6
@@ -22,9 +22,9 @@ public sealed class A2AArtifactExtensionsTests
|
||||
Name = "comprehensive-artifact",
|
||||
Parts =
|
||||
[
|
||||
new TextPart { Text = "First part" },
|
||||
new TextPart { Text = "Second part" },
|
||||
new TextPart { Text = "Third part" }
|
||||
Part.FromText("First part"),
|
||||
Part.FromText("Second part"),
|
||||
Part.FromText("Third part")
|
||||
],
|
||||
Metadata = new Dictionary<string, JsonElement>
|
||||
{
|
||||
@@ -66,9 +66,9 @@ public sealed class A2AArtifactExtensionsTests
|
||||
Name = "test",
|
||||
Parts =
|
||||
[
|
||||
new TextPart { Text = "Part 1" },
|
||||
new TextPart { Text = "Part 2" },
|
||||
new TextPart { Text = "Part 3" }
|
||||
Part.FromText("Part 1"),
|
||||
Part.FromText("Part 2"),
|
||||
Part.FromText("Part 3")
|
||||
],
|
||||
Metadata = null
|
||||
};
|
||||
|
||||
+49
-9
@@ -37,7 +37,7 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable
|
||||
{
|
||||
Name = "Test Agent",
|
||||
Description = "A test agent for unit testing",
|
||||
Url = "http://test-endpoint/agent"
|
||||
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
|
||||
});
|
||||
|
||||
// Act
|
||||
@@ -60,15 +60,15 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable
|
||||
// Arrange
|
||||
this._handler.ResponsesToReturn.Enqueue(new AgentCard
|
||||
{
|
||||
Url = "http://test-endpoint/agent"
|
||||
SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }]
|
||||
});
|
||||
this._handler.ResponsesToReturn.Enqueue(new AgentMessage
|
||||
this._handler.ResponsesToReturn.Enqueue(new Message
|
||||
{
|
||||
Role = MessageRole.Agent,
|
||||
Parts = [new TextPart { Text = "Response" }],
|
||||
Role = Role.Agent,
|
||||
Parts = [Part.FromText("Response")],
|
||||
});
|
||||
|
||||
var agent = await this._resolver.GetAIAgentAsync(this._httpClient);
|
||||
var agent = await this._resolver.GetAIAgentAsync(httpClient: this._httpClient);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync("Test input");
|
||||
@@ -78,6 +78,41 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable
|
||||
Assert.Equal(new Uri("http://test-endpoint/agent"), this._handler.CapturedUris[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_WithOptions_PassesOptionsToFactoryAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._handler.ResponsesToReturn.Enqueue(new AgentCard
|
||||
{
|
||||
Name = "Options Agent",
|
||||
Description = "Agent with multiple interfaces",
|
||||
SupportedInterfaces =
|
||||
[
|
||||
new AgentInterface { Url = "http://httpjson/agent", ProtocolBinding = ProtocolBindingNames.HttpJson },
|
||||
new AgentInterface { Url = "http://jsonrpc/agent", ProtocolBinding = ProtocolBindingNames.JsonRpc },
|
||||
]
|
||||
});
|
||||
this._handler.ResponsesToReturn.Enqueue(new Message
|
||||
{
|
||||
Role = Role.Agent,
|
||||
Parts = [Part.FromText("Response")],
|
||||
});
|
||||
|
||||
var options = new A2AClientOptions
|
||||
{
|
||||
PreferredBindings = [ProtocolBindingNames.JsonRpc]
|
||||
};
|
||||
|
||||
var agent = await this._resolver.GetAIAgentAsync(httpClient: this._httpClient, options: options);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync("Test input");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, this._handler.CapturedUris.Count);
|
||||
Assert.Equal(new Uri("http://jsonrpc/agent"), this._handler.CapturedUris[1]);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._handler.Dispose();
|
||||
@@ -104,13 +139,18 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
else if (response is AgentMessage message)
|
||||
else if (response is Message message)
|
||||
{
|
||||
var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse<A2AEvent>("response-id", message);
|
||||
var sendMessageResponse = new SendMessageResponse { Message = message };
|
||||
var jsonRpcResponse = new JsonRpcResponse
|
||||
{
|
||||
Id = "response-id",
|
||||
Result = JsonSerializer.SerializeToNode(sendMessageResponse, A2AJsonUtilities.DefaultOptions)
|
||||
};
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
|
||||
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions), Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -30,4 +30,40 @@ public sealed class A2AClientExtensionsTests
|
||||
Assert.Equal(TestName, agent.Name);
|
||||
Assert.Equal(TestDescription, agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAIAgent_WithIA2AClient_ReturnsA2AAgentWithSpecifiedProperties()
|
||||
{
|
||||
// Arrange - use IA2AClient reference type to verify the extension method works with the interface
|
||||
IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint"));
|
||||
|
||||
const string TestId = "ia2a-agent-id";
|
||||
const string TestName = "IA2A Agent";
|
||||
const string TestDescription = "Agent created from IA2AClient";
|
||||
|
||||
// Act
|
||||
var agent = a2aClient.AsAIAgent(TestId, TestName, TestDescription);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<A2AAgent>(agent);
|
||||
Assert.Equal(TestId, agent.Id);
|
||||
Assert.Equal(TestName, agent.Name);
|
||||
Assert.Equal(TestDescription, agent.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAIAgent_WithIA2AClient_ExposesClientViaGetService()
|
||||
{
|
||||
// Arrange
|
||||
IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint"));
|
||||
|
||||
// Act
|
||||
var agent = a2aClient.AsAIAgent();
|
||||
|
||||
// Assert
|
||||
var service = agent.GetService(typeof(IA2AClient));
|
||||
Assert.NotNull(service);
|
||||
Assert.Same(a2aClient, service);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-16
@@ -32,20 +32,19 @@ public sealed class ChatMessageExtensionsTests
|
||||
Assert.NotNull(a2aMessage.MessageId);
|
||||
Assert.NotEmpty(a2aMessage.MessageId);
|
||||
|
||||
Assert.Equal(MessageRole.User, a2aMessage.Role);
|
||||
Assert.Equal(Role.User, a2aMessage.Role);
|
||||
|
||||
Assert.NotNull(a2aMessage.Parts);
|
||||
Assert.Equal(3, a2aMessage.Parts.Count);
|
||||
|
||||
var filePart = Assert.IsType<FilePart>(a2aMessage.Parts[0]);
|
||||
Assert.NotNull(filePart.File);
|
||||
Assert.Equal("https://example.com/report.pdf", filePart.File.Uri?.ToString());
|
||||
Assert.Equal(PartContentCase.Url, a2aMessage.Parts[0].ContentCase);
|
||||
Assert.Equal("https://example.com/report.pdf", a2aMessage.Parts[0].Url);
|
||||
|
||||
var secondTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[1]);
|
||||
Assert.Equal("please summarize the file content", secondTextPart.Text);
|
||||
Assert.Equal(PartContentCase.Text, a2aMessage.Parts[1].ContentCase);
|
||||
Assert.Equal("please summarize the file content", a2aMessage.Parts[1].Text);
|
||||
|
||||
var thirdTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[2]);
|
||||
Assert.Equal("and send it to me over email", thirdTextPart.Text);
|
||||
Assert.Equal(PartContentCase.Text, a2aMessage.Parts[2].ContentCase);
|
||||
Assert.Equal("and send it to me over email", a2aMessage.Parts[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -71,19 +70,18 @@ public sealed class ChatMessageExtensionsTests
|
||||
Assert.NotNull(a2aMessage.MessageId);
|
||||
Assert.NotEmpty(a2aMessage.MessageId);
|
||||
|
||||
Assert.Equal(MessageRole.User, a2aMessage.Role);
|
||||
Assert.Equal(Role.User, a2aMessage.Role);
|
||||
|
||||
Assert.NotNull(a2aMessage.Parts);
|
||||
Assert.Equal(3, a2aMessage.Parts.Count);
|
||||
|
||||
var filePart = Assert.IsType<FilePart>(a2aMessage.Parts[0]);
|
||||
Assert.NotNull(filePart.File);
|
||||
Assert.Equal("https://example.com/report.pdf", filePart.File.Uri?.ToString());
|
||||
Assert.Equal(PartContentCase.Url, a2aMessage.Parts[0].ContentCase);
|
||||
Assert.Equal("https://example.com/report.pdf", a2aMessage.Parts[0].Url);
|
||||
|
||||
var secondTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[1]);
|
||||
Assert.Equal("please summarize the file content", secondTextPart.Text);
|
||||
Assert.Equal(PartContentCase.Text, a2aMessage.Parts[1].ContentCase);
|
||||
Assert.Equal("please summarize the file content", a2aMessage.Parts[1].Text);
|
||||
|
||||
var thirdTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[2]);
|
||||
Assert.Equal("and send it to me over email", thirdTextPart.Text);
|
||||
Assert.Equal(PartContentCase.Text, a2aMessage.Parts[2].ContentCase);
|
||||
Assert.Equal("and send it to me over email", a2aMessage.Parts[2].Text);
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -1,5 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.A2A\Microsoft.Agents.AI.A2A.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,966 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using A2A;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="A2AAgentHandler"/> class.
|
||||
/// </summary>
|
||||
public sealed class A2AAgentHandlerTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that when metadata is null, the options passed to RunAsync have
|
||||
/// AllowBackgroundResponses disabled and no AdditionalProperties.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
A2AAgentHandler handler = CreateHandler(CreateAgentMock(options => capturedOptions = options));
|
||||
|
||||
// Act
|
||||
await InvokeExecuteAsync(handler, new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.False(capturedOptions.AllowBackgroundResponses);
|
||||
Assert.Null(capturedOptions.AdditionalProperties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when metadata is non-empty, the options passed to RunAsync have
|
||||
/// AdditionalProperties populated with the converted metadata values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenMetadataIsNonEmpty_PassesOptionsWithAdditionalPropertiesToRunAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
A2AAgentHandler handler = CreateHandler(CreateAgentMock(options => capturedOptions = options));
|
||||
|
||||
// Act
|
||||
await InvokeExecuteAsync(handler, new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx", StreamingResponse = false,
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] },
|
||||
Metadata = new Dictionary<string, JsonElement>
|
||||
{
|
||||
["key1"] = JsonSerializer.SerializeToElement("value1"),
|
||||
["key2"] = JsonSerializer.SerializeToElement(42)
|
||||
}
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.False(capturedOptions.AllowBackgroundResponses);
|
||||
Assert.NotNull(capturedOptions.AdditionalProperties);
|
||||
Assert.Equal(2, capturedOptions.AdditionalProperties.Count);
|
||||
Assert.Equal("value1", capturedOptions.AdditionalProperties["key1"]?.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the agent response has AdditionalProperties, the returned Message.Metadata contains the converted values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenResponseHasAdditionalProperties_ReturnsMessageWithMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProps = new()
|
||||
{
|
||||
["responseKey1"] = "responseValue1",
|
||||
["responseKey2"] = 123
|
||||
};
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
|
||||
{
|
||||
AdditionalProperties = additionalProps
|
||||
};
|
||||
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.NotNull(message.Metadata);
|
||||
Assert.Equal(2, message.Metadata.Count);
|
||||
Assert.True(message.Metadata.ContainsKey("responseKey1"));
|
||||
Assert.True(message.Metadata.ContainsKey("responseKey2"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the agent response has null AdditionalProperties, the returned Message.Metadata is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenResponseHasNullAdditionalProperties_ReturnsMessageWithNullMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
|
||||
{
|
||||
AdditionalProperties = null
|
||||
};
|
||||
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.Null(message.Metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the agent response has empty AdditionalProperties, the returned Message.Metadata is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenResponseHasEmptyAdditionalProperties_ReturnsMessageWithNullMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
|
||||
{
|
||||
AdditionalProperties = []
|
||||
};
|
||||
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.Null(message.Metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when runMode is DisallowBackground, AllowBackgroundResponses is false.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_DisallowBackgroundMode_SetsAllowBackgroundResponsesFalseAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
A2AAgentHandler handler = CreateHandler(
|
||||
CreateAgentMock(options => capturedOptions = options),
|
||||
runMode: AgentRunMode.DisallowBackground);
|
||||
|
||||
// Act
|
||||
await InvokeExecuteAsync(handler, new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.False(capturedOptions.AllowBackgroundResponses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in AllowBackgroundIfSupported mode, AllowBackgroundResponses is true.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_AllowBackgroundIfSupportedMode_SetsAllowBackgroundResponsesTrueAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
A2AAgentHandler handler = CreateHandler(
|
||||
CreateAgentMock(options => capturedOptions = options),
|
||||
runMode: AgentRunMode.AllowBackgroundIfSupported);
|
||||
|
||||
// Act
|
||||
await InvokeExecuteAsync(handler, new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.True(capturedOptions.AllowBackgroundResponses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a custom Dynamic delegate returning false sets AllowBackgroundResponses to false.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_DynamicMode_WithFalseCallback_SetsAllowBackgroundResponsesFalseAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
A2AAgentHandler handler = CreateHandler(
|
||||
CreateAgentMock(options => capturedOptions = options),
|
||||
runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false)));
|
||||
|
||||
// Act
|
||||
await InvokeExecuteAsync(handler, new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.False(capturedOptions.AllowBackgroundResponses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a custom Dynamic delegate returning true sets AllowBackgroundResponses to true.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_DynamicMode_WithTrueCallback_SetsAllowBackgroundResponsesTrueAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
A2AAgentHandler handler = CreateHandler(
|
||||
CreateAgentMock(options => capturedOptions = options),
|
||||
runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)));
|
||||
|
||||
// Act
|
||||
await InvokeExecuteAsync(handler, new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.True(capturedOptions.AllowBackgroundResponses);
|
||||
}
|
||||
|
||||
#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, task status events are emitted.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenResponseHasContinuationToken_EmitsTaskStatusEventsAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")])
|
||||
{
|
||||
ContinuationToken = CreateTestContinuationToken()
|
||||
};
|
||||
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = false,
|
||||
TaskId = "task-1",
|
||||
ContextId = "ctx-1",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert - should have emitted status update events (Submitted + Working)
|
||||
Assert.True(events.StatusUpdates.Count >= 1);
|
||||
Assert.Empty(events.Messages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the incoming message has a ContextId, it is used for the response
|
||||
/// rather than generating a new one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenMessageHasContextId_UsesProvidedContextIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
|
||||
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = false,
|
||||
TaskId = "",
|
||||
ContextId = "my-context-123",
|
||||
Message = new Message
|
||||
{
|
||||
MessageId = "test-id",
|
||||
ContextId = "my-context-123",
|
||||
Role = Role.User,
|
||||
Parts = [new Part { Text = "Hello" }]
|
||||
}
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.Equal("my-context-123", message.ContextId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that on continuation when the agent completes (no ContinuationToken), task is completed with artifact.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_OnContinuation_WhenComplete_EmitsArtifactAndCompletedAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]);
|
||||
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = false,
|
||||
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
|
||||
TaskId = "task-1",
|
||||
ContextId = "ctx-1",
|
||||
|
||||
Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
|
||||
});
|
||||
|
||||
// Assert - should have artifact + completed status
|
||||
Assert.True(events.ArtifactUpdates.Count > 0);
|
||||
Assert.True(events.StatusUpdates.Count > 0);
|
||||
Assert.Empty(events.Messages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the agent throws during a continuation,
|
||||
/// the handler emits a Failed status and re-throws the exception.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_OnContinuation_WhenAgentThrows_EmitsFailedStatusAsync()
|
||||
{
|
||||
// Arrange
|
||||
int callCount = 0;
|
||||
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, _ =>
|
||||
throw new InvalidOperationException("Agent failed"));
|
||||
A2AAgentHandler handler = CreateHandler(agentMock);
|
||||
|
||||
// Act & Assert
|
||||
var events = new EventCollector();
|
||||
var eventQueue = new AgentEventQueue();
|
||||
var readerTask = ReadEventsAsync(eventQueue, events);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
handler.ExecuteAsync(
|
||||
new RequestContext
|
||||
{
|
||||
StreamingResponse = false,
|
||||
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
|
||||
TaskId = "task-1",
|
||||
ContextId = "ctx-1",
|
||||
|
||||
Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
|
||||
},
|
||||
eventQueue,
|
||||
CancellationToken.None));
|
||||
eventQueue.Complete(null);
|
||||
await readerTask;
|
||||
|
||||
// Assert - should have emitted Failed status
|
||||
Assert.True(events.StatusUpdates.Count > 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the agent throws during a continuation and the cancellation token
|
||||
/// is already cancelled, the handler still emits a Failed status and re-throws the
|
||||
/// original exception (not an OperationCanceledException from FailAsync).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_OnContinuation_WhenAgentThrowsWithCancelledToken_StillEmitsFailedStatusAsync()
|
||||
{
|
||||
// Arrange
|
||||
int callCount = 0;
|
||||
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, _ =>
|
||||
throw new InvalidOperationException("Agent failed"));
|
||||
A2AAgentHandler handler = CreateHandler(agentMock);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel(); // Pre-cancel the token
|
||||
|
||||
// Act & Assert - the original InvalidOperationException should be thrown, not OperationCanceledException
|
||||
var events = new EventCollector();
|
||||
var eventQueue = new AgentEventQueue();
|
||||
var readerTask = ReadEventsAsync(eventQueue, events);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
handler.ExecuteAsync(
|
||||
new RequestContext
|
||||
{
|
||||
StreamingResponse = false,
|
||||
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
|
||||
TaskId = "task-1",
|
||||
ContextId = "ctx-1",
|
||||
|
||||
Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
|
||||
},
|
||||
eventQueue,
|
||||
cts.Token));
|
||||
eventQueue.Complete(null);
|
||||
await readerTask;
|
||||
|
||||
// Assert - should have emitted Failed status even with a cancelled token
|
||||
Assert.True(events.StatusUpdates.Count > 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the agent throws OperationCanceledException during a continuation,
|
||||
/// no Failed status is emitted.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_OnContinuation_WhenOperationCancelled_DoesNotEmitFailedAsync()
|
||||
{
|
||||
// Arrange
|
||||
int callCount = 0;
|
||||
Mock<AIAgent> agentMock = CreateAgentMockWithCallCount(ref callCount, _ =>
|
||||
throw new OperationCanceledException("Cancelled"));
|
||||
A2AAgentHandler handler = CreateHandler(agentMock);
|
||||
|
||||
// Act & Assert
|
||||
var events = new EventCollector();
|
||||
var eventQueue = new AgentEventQueue();
|
||||
var readerTask = ReadEventsAsync(eventQueue, events);
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() =>
|
||||
handler.ExecuteAsync(
|
||||
new RequestContext
|
||||
{
|
||||
StreamingResponse = false,
|
||||
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
|
||||
TaskId = "task-1",
|
||||
ContextId = "ctx-1",
|
||||
|
||||
Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
|
||||
},
|
||||
eventQueue,
|
||||
CancellationToken.None));
|
||||
eventQueue.Complete(null);
|
||||
await readerTask;
|
||||
|
||||
// Assert - should NOT have emitted any status (OperationCanceledException is re-thrown without marking Failed)
|
||||
Assert.Empty(events.StatusUpdates);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ReferenceTaskIds throws NotSupportedException.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithReferenceTaskIds_ThrowsNotSupportedExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
A2AAgentHandler handler = CreateHandler(CreateAgentMock(_ => { }));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotSupportedException>(() =>
|
||||
InvokeExecuteAsync(handler, new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message
|
||||
{
|
||||
MessageId = "test-id",
|
||||
Role = Role.User,
|
||||
Parts = [new Part { Text = "Hello" }],
|
||||
ReferenceTaskIds = ["other-task-id"]
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when ContextId is null, a new one is generated and used in the response.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenContextIdIsNull_GeneratesContextIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
|
||||
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = false,
|
||||
TaskId = "",
|
||||
ContextId = null!,
|
||||
Message = new Message
|
||||
{
|
||||
MessageId = "test-id",
|
||||
Role = Role.User,
|
||||
Parts = [new Part { Text = "Hello" }]
|
||||
}
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.NotNull(message.ContextId);
|
||||
Assert.NotEmpty(message.ContextId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when Message is null, the handler still succeeds with empty chat messages.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenMessageIsNull_SucceedsWithEmptyMessagesAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
|
||||
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response));
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = false,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = null!
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.Equal("ctx", message.ContextId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the dynamic AllowBackgroundWhen delegate receives the correct RequestContext.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_DynamicMode_DelegateReceivesRequestContextAsync()
|
||||
{
|
||||
// Arrange
|
||||
A2ARunDecisionContext? capturedContext = null;
|
||||
A2AAgentHandler handler = CreateHandler(
|
||||
CreateAgentMock(_ => { }),
|
||||
runMode: AgentRunMode.AllowBackgroundWhen((ctx, _) =>
|
||||
{
|
||||
capturedContext = ctx;
|
||||
return ValueTask.FromResult(false);
|
||||
}));
|
||||
|
||||
var requestContext = new RequestContext
|
||||
{
|
||||
TaskId = "my-task", ContextId = "my-ctx", StreamingResponse = false,
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
};
|
||||
|
||||
// Act
|
||||
await InvokeExecuteAsync(handler, requestContext);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Same(requestContext, capturedContext.RequestContext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that CancelAsync emits a Canceled status event.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CancelAsync_EmitsCanceledStatusAsync()
|
||||
{
|
||||
// Arrange
|
||||
A2AAgentHandler handler = CreateHandler(CreateAgentMock(_ => { }));
|
||||
var events = new EventCollector();
|
||||
var eventQueue = new AgentEventQueue();
|
||||
var readerTask = ReadEventsAsync(eventQueue, events);
|
||||
|
||||
// Act
|
||||
await handler.CancelAsync(
|
||||
new RequestContext
|
||||
{
|
||||
StreamingResponse = false,
|
||||
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
|
||||
TaskId = "task-1",
|
||||
ContextId = "ctx-1",
|
||||
Task = new AgentTask { Id = "task-1", ContextId = "ctx-1" }
|
||||
},
|
||||
eventQueue,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
eventQueue.Complete(null);
|
||||
await readerTask;
|
||||
Assert.True(events.StatusUpdates.Count > 0);
|
||||
}
|
||||
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when no session store is provided, the handler uses InMemoryAgentSessionStore
|
||||
/// and can execute successfully.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Handler_WithNullSessionStore_UsesInMemorySessionStoreAndExecutesSuccessfullyAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
|
||||
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: null);
|
||||
|
||||
// Act
|
||||
var events = await CollectEventsAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = false,
|
||||
TaskId = "",
|
||||
ContextId = "ctx-1",
|
||||
Message = new Message
|
||||
{
|
||||
MessageId = "test-id",
|
||||
Role = Role.User,
|
||||
Parts = [new Part { Text = "Hello" }]
|
||||
}
|
||||
});
|
||||
|
||||
// Assert
|
||||
Message message = Assert.Single(events.Messages);
|
||||
Assert.Equal("Reply", message.Parts![0].Text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when a custom session store is provided, it is used instead of the
|
||||
/// default InMemoryAgentSessionStore.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Handler_WithCustomSessionStore_UsesProvidedSessionStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
var mockSessionStore = new Mock<AgentSessionStore>();
|
||||
mockSessionStore
|
||||
.Setup(x => x.GetSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
mockSessionStore
|
||||
.Setup(x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]);
|
||||
A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: mockSessionStore.Object);
|
||||
|
||||
// Act
|
||||
await InvokeExecuteAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = false,
|
||||
TaskId = "",
|
||||
ContextId = "ctx-1",
|
||||
Message = new Message
|
||||
{
|
||||
MessageId = "test-id",
|
||||
Role = Role.User,
|
||||
Parts = [new Part { Text = "Hello" }]
|
||||
}
|
||||
});
|
||||
|
||||
// Assert - verify the custom session store was called
|
||||
mockSessionStore.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.Is<string>(s => s == "ctx-1"),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
mockSessionStore.Verify(
|
||||
x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.Is<string>(s => s == "ctx-1"),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when no session store is provided, the default InMemoryAgentSessionStore
|
||||
/// persists sessions across multiple calls with the same context ID.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Handler_WithNullSessionStore_SessionIsPersistedAcrossCallsAsync()
|
||||
{
|
||||
// Arrange - track how many times CreateSessionCoreAsync is called
|
||||
int createSessionCallCount = 0;
|
||||
var sessionInstance = new TestAgentSession();
|
||||
|
||||
Mock<AIAgent> agentMock = new() { CallBase = true };
|
||||
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.Callback(() => Interlocked.Increment(ref createSessionCallCount))
|
||||
.ReturnsAsync(() => new TestAgentSession());
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<ValueTask<JsonElement>>("SerializeSessionCoreAsync",
|
||||
ItExpr.IsAny<AgentSession>(),
|
||||
ItExpr.IsAny<JsonSerializerOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(JsonDocument.Parse("{}").RootElement);
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("DeserializeSessionCoreAsync",
|
||||
ItExpr.IsAny<JsonElement>(),
|
||||
ItExpr.IsAny<JsonSerializerOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(sessionInstance);
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Reply")]));
|
||||
|
||||
A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: null);
|
||||
|
||||
var context = new RequestContext
|
||||
{
|
||||
StreamingResponse = false,
|
||||
TaskId = "",
|
||||
ContextId = "ctx-persistent",
|
||||
Message = new Message
|
||||
{
|
||||
MessageId = "test-id",
|
||||
Role = Role.User,
|
||||
Parts = [new Part { Text = "Hello" }]
|
||||
}
|
||||
};
|
||||
|
||||
// Act - call twice with the same context ID
|
||||
await InvokeExecuteAsync(handler, context);
|
||||
await InvokeExecuteAsync(handler, context);
|
||||
|
||||
// Assert - CreateSessionCoreAsync should be called once (first call creates, second retrieves from store)
|
||||
Assert.Equal(1, createSessionCallCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the AllowBackgroundWhen delegate throws, the exception propagates
|
||||
/// and the agent is not invoked.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_DynamicMode_WhenCallbackThrows_PropagatesExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
bool agentInvoked = false;
|
||||
A2AAgentHandler handler = CreateHandler(
|
||||
CreateAgentMock(_ => agentInvoked = true),
|
||||
runMode: AgentRunMode.AllowBackgroundWhen((_, _) =>
|
||||
throw new InvalidOperationException("Callback failed")));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
InvokeExecuteAsync(handler, new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
}));
|
||||
|
||||
Assert.False(agentInvoked);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the CancellationToken is propagated to the AllowBackgroundWhen delegate.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_DynamicMode_CancellationTokenIsPropagatedToCallbackAsync()
|
||||
{
|
||||
// Arrange
|
||||
CancellationToken capturedToken = default;
|
||||
using var cts = new CancellationTokenSource();
|
||||
A2AAgentHandler handler = CreateHandler(
|
||||
CreateAgentMock(_ => { }),
|
||||
runMode: AgentRunMode.AllowBackgroundWhen((_, ct) =>
|
||||
{
|
||||
capturedToken = ct;
|
||||
return ValueTask.FromResult(false);
|
||||
}));
|
||||
|
||||
// Act
|
||||
var eventQueue = new AgentEventQueue();
|
||||
await handler.ExecuteAsync(
|
||||
new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }
|
||||
},
|
||||
eventQueue,
|
||||
cts.Token);
|
||||
eventQueue.Complete(null);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(cts.Token, capturedToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the agent run mode is applied on the continuation/task-update path,
|
||||
/// not just the new message path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_OnContinuation_RunModeIsAppliedAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
A2AAgentHandler handler = CreateHandler(
|
||||
CreateAgentMock(options => capturedOptions = options),
|
||||
runMode: AgentRunMode.AllowBackgroundIfSupported);
|
||||
|
||||
// Act
|
||||
await InvokeExecuteAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = false,
|
||||
TaskId = "task-1",
|
||||
ContextId = "ctx-1",
|
||||
Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] },
|
||||
|
||||
Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.True(capturedOptions.AllowBackgroundResponses);
|
||||
}
|
||||
|
||||
private static A2AAgentHandler CreateHandler(
|
||||
Mock<AIAgent> agentMock,
|
||||
AgentRunMode? runMode = null,
|
||||
AgentSessionStore? agentSessionStore = null)
|
||||
{
|
||||
runMode ??= AgentRunMode.DisallowBackground;
|
||||
|
||||
var hostAgent = new AIHostAgent(
|
||||
innerAgent: agentMock.Object,
|
||||
sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore());
|
||||
|
||||
return new A2AAgentHandler(hostAgent, runMode);
|
||||
}
|
||||
|
||||
private static Mock<AIAgent> CreateAgentMock(Action<AgentRunOptions?> optionsCallback)
|
||||
{
|
||||
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>())
|
||||
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>(
|
||||
(_, _, options, _) => optionsCallback(options))
|
||||
.ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Test response")]));
|
||||
|
||||
return agentMock;
|
||||
}
|
||||
|
||||
private static Mock<AIAgent> CreateAgentMockWithResponse(AgentResponse response)
|
||||
{
|
||||
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(response);
|
||||
|
||||
return agentMock;
|
||||
}
|
||||
|
||||
private static Mock<AIAgent> CreateAgentMockWithCallCount(
|
||||
ref int callCount,
|
||||
Func<int, AgentResponse> responseFactory)
|
||||
{
|
||||
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 static async Task InvokeExecuteAsync(A2AAgentHandler handler, RequestContext context)
|
||||
{
|
||||
var eventQueue = new AgentEventQueue();
|
||||
await handler.ExecuteAsync(context, eventQueue, CancellationToken.None);
|
||||
eventQueue.Complete(null);
|
||||
}
|
||||
|
||||
private static async Task<EventCollector> CollectEventsAsync(A2AAgentHandler handler, RequestContext context)
|
||||
{
|
||||
var events = new EventCollector();
|
||||
var eventQueue = new AgentEventQueue();
|
||||
var readerTask = ReadEventsAsync(eventQueue, events);
|
||||
|
||||
await handler.ExecuteAsync(context, eventQueue, CancellationToken.None);
|
||||
eventQueue.Complete(null);
|
||||
await readerTask;
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private static async Task ReadEventsAsync(AgentEventQueue eventQueue, EventCollector collector)
|
||||
{
|
||||
await foreach (var response in eventQueue)
|
||||
{
|
||||
switch (response.PayloadCase)
|
||||
{
|
||||
case StreamResponseCase.Message:
|
||||
collector.Messages.Add(response.Message!);
|
||||
break;
|
||||
case StreamResponseCase.Task:
|
||||
collector.Tasks.Add(response.Task!);
|
||||
break;
|
||||
case StreamResponseCase.StatusUpdate:
|
||||
collector.StatusUpdates.Add(response.StatusUpdate!);
|
||||
break;
|
||||
case StreamResponseCase.ArtifactUpdate:
|
||||
collector.ArtifactUpdates.Add(response.ArtifactUpdate!);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable MEAI001
|
||||
private static ResponseContinuationToken CreateTestContinuationToken()
|
||||
{
|
||||
return ResponseContinuationToken.FromBytes(new byte[] { 0x01, 0x02, 0x03 });
|
||||
}
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
private sealed class EventCollector
|
||||
{
|
||||
public List<Message> Messages { get; } = [];
|
||||
public List<AgentTask> Tasks { get; } = [];
|
||||
public List<TaskStatusUpdateEvent> StatusUpdates { get; } = [];
|
||||
public List<TaskArtifactUpdateEvent> ArtifactUpdates { get; } = [];
|
||||
}
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
}
|
||||
+559
@@ -0,0 +1,559 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for A2AEndpointRouteBuilderExtensions and A2AServerServiceCollectionExtensions methods.
|
||||
/// </summary>
|
||||
public sealed class A2AEndpointRouteBuilderExtensionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null endpoints.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AHttpJson_WithAgentBuilder_NullEndpoints_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
endpoints.MapA2AHttpJson(agentBuilder, "/a2a"));
|
||||
|
||||
Assert.Equal("endpoints", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null agentBuilder.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AHttpJson_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
IHostedAgentBuilder agentBuilder = null!;
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
app.MapA2AHttpJson(agentBuilder, "/a2a"));
|
||||
|
||||
Assert.Equal("agentBuilder", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2AHttpJson with IHostedAgentBuilder correctly maps the agent with default configuration.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AHttpJson_WithAgentBuilder_DefaultConfiguration_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
agentBuilder.AddA2AServer();
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = app.MapA2AHttpJson(agentBuilder, "/a2a");
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2AHttpJson with string agent name correctly maps the agent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AHttpJson_WithAgentName_DefaultConfiguration_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddA2AServer("agent");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = app.MapA2AHttpJson("agent", "/a2a");
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2AJsonRpc with IHostedAgentBuilder correctly maps the agent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AJsonRpc_WithAgentBuilder_DefaultConfiguration_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
agentBuilder.AddA2AServer();
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = app.MapA2AJsonRpc(agentBuilder, "/a2a");
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2AJsonRpc with string agent name correctly maps the agent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AJsonRpc_WithAgentName_DefaultConfiguration_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddA2AServer("agent");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = app.MapA2AJsonRpc("agent", "/a2a");
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that both MapA2AHttpJson and MapA2AJsonRpc can be called for the same agent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AHttpJson_And_MapA2AJsonRpc_SameAgent_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
agentBuilder.AddA2AServer();
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var httpResult = app.MapA2AHttpJson(agentBuilder, "/a2a");
|
||||
var rpcResult = app.MapA2AJsonRpc(agentBuilder, "/a2a");
|
||||
Assert.NotNull(httpResult);
|
||||
Assert.NotNull(rpcResult);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that multiple agents can be mapped to different paths.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AHttpJson_MultipleAgents_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agent1Builder = builder.AddAIAgent("agent1", "Instructions1", chatClientServiceKey: "chat-client");
|
||||
IHostedAgentBuilder agent2Builder = builder.AddAIAgent("agent2", "Instructions2", chatClientServiceKey: "chat-client");
|
||||
agent1Builder.AddA2AServer();
|
||||
agent2Builder.AddA2AServer();
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
app.MapA2AHttpJson(agent1Builder, "/a2a/agent1");
|
||||
app.MapA2AHttpJson(agent2Builder, "/a2a/agent2");
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that custom paths can be specified for A2A endpoints.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AHttpJson_WithCustomPath_AcceptsValidPath()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
agentBuilder.AddA2AServer();
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
app.MapA2AHttpJson(agentBuilder, "/custom/a2a/path");
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddA2AServer with custom A2AServerRegistrationOptions succeeds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddA2AServer_WithCustomOptions_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
agentBuilder.AddA2AServer(options => options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported);
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = app.MapA2AHttpJson(agentBuilder, "/a2a");
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null endpoints when using string agent name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AHttpJson_WithAgentName_NullEndpoints_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
endpoints.MapA2AHttpJson("agent", "/a2a"));
|
||||
|
||||
Assert.Equal("endpoints", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2AJsonRpc throws ArgumentNullException for null endpoints when using string agent name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AJsonRpc_WithAgentName_NullEndpoints_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
endpoints.MapA2AJsonRpc("agent", "/a2a"));
|
||||
|
||||
Assert.Equal("endpoints", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null agentName.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AHttpJson_WithAgentName_NullAgentName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
app.MapA2AHttpJson((string)null!, "/a2a"));
|
||||
|
||||
Assert.Equal("agentName", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2AHttpJson throws ArgumentException for empty agentName.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AHttpJson_WithAgentName_EmptyAgentName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert
|
||||
ArgumentException exception = Assert.Throws<ArgumentException>(() =>
|
||||
app.MapA2AHttpJson(string.Empty, "/a2a"));
|
||||
|
||||
Assert.Equal("agentName", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AHttpJson_NullPath_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
agentBuilder.AddA2AServer();
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
app.MapA2AHttpJson(agentBuilder, null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2AHttpJson throws ArgumentException for whitespace-only path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AHttpJson_WhitespacePath_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
agentBuilder.AddA2AServer();
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
app.MapA2AHttpJson(agentBuilder, " "));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddA2AServer throws ArgumentNullException for null services.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddA2AServer_NullServices_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
IServiceCollection services = null!;
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
services.AddA2AServer("agent"));
|
||||
|
||||
Assert.Equal("services", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddA2AServer throws ArgumentNullException for null agentName.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddA2AServer_NullAgentName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
IServiceCollection services = new ServiceCollection();
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
services.AddA2AServer((string)null!));
|
||||
|
||||
Assert.Equal("agentName", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddA2AServer throws ArgumentException for empty agentName.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddA2AServer_EmptyAgentName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
IServiceCollection services = new ServiceCollection();
|
||||
|
||||
// Act & Assert
|
||||
ArgumentException exception = Assert.Throws<ArgumentException>(() =>
|
||||
services.AddA2AServer(string.Empty));
|
||||
|
||||
Assert.Equal("agentName", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddA2AServer on IHostedAgentBuilder throws ArgumentNullException for null builder.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddA2AServer_NullAgentBuilder_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
IHostedAgentBuilder agentBuilder = null!;
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
agentBuilder.AddA2AServer());
|
||||
|
||||
Assert.Equal("agentBuilder", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2AHttpJson throws ArgumentNullException for null AIAgent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AHttpJson_WithAIAgent_NullAgent_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
AIAgent agent = null!;
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
app.MapA2AHttpJson(agent, "/a2a"));
|
||||
|
||||
Assert.Equal("agent", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2AHttpJson throws ArgumentNullException for AIAgent with null Name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AHttpJson_WithAIAgent_NullName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
var agentMock = new Mock<AIAgent>();
|
||||
agentMock.Setup(a => a.Name).Returns((string?)null);
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
app.MapA2AHttpJson(agentMock.Object, "/a2a"));
|
||||
|
||||
Assert.Equal("agent.Name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2AHttpJson throws ArgumentException for AIAgent with whitespace Name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AHttpJson_WithAIAgent_WhitespaceName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
var agentMock = new Mock<AIAgent>();
|
||||
agentMock.Setup(a => a.Name).Returns(" ");
|
||||
|
||||
// Act & Assert
|
||||
ArgumentException exception = Assert.Throws<ArgumentException>(() =>
|
||||
app.MapA2AHttpJson(agentMock.Object, "/a2a"));
|
||||
|
||||
Assert.Equal("agent.Name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2AJsonRpc throws ArgumentNullException for null AIAgent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AJsonRpc_WithAIAgent_NullAgent_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
AIAgent agent = null!;
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
app.MapA2AJsonRpc(agent, "/a2a"));
|
||||
|
||||
Assert.Equal("agent", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2AJsonRpc throws ArgumentNullException for AIAgent with null Name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AJsonRpc_WithAIAgent_NullName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
var agentMock = new Mock<AIAgent>();
|
||||
agentMock.Setup(a => a.Name).Returns((string?)null);
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
app.MapA2AJsonRpc(agentMock.Object, "/a2a"));
|
||||
|
||||
Assert.Equal("agent.Name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2AJsonRpc throws ArgumentException for AIAgent with whitespace Name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AJsonRpc_WithAIAgent_WhitespaceName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
var agentMock = new Mock<AIAgent>();
|
||||
agentMock.Setup(a => a.Name).Returns(" ");
|
||||
|
||||
// Act & Assert
|
||||
ArgumentException exception = Assert.Throws<ArgumentException>(() =>
|
||||
app.MapA2AJsonRpc(agentMock.Object, "/a2a"));
|
||||
|
||||
Assert.Equal("agent.Name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2AHttpJson throws InvalidOperationException when no A2AServer has been
|
||||
/// registered for the specified agent via AddA2AServer.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AHttpJson_WithoutAddA2AServer_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert
|
||||
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
app.MapA2AHttpJson("agent", "/a2a"));
|
||||
|
||||
Assert.Contains("agent", exception.Message);
|
||||
Assert.Contains("AddA2AServer", exception.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2AJsonRpc throws InvalidOperationException when no A2AServer has been
|
||||
/// registered for the specified agent via AddA2AServer.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2AJsonRpc_WithoutAddA2AServer_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert
|
||||
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
app.MapA2AJsonRpc("agent", "/a2a"));
|
||||
|
||||
Assert.Contains("agent", exception.Message);
|
||||
Assert.Contains("AddA2AServer", exception.Message);
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using A2A;
|
||||
using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
|
||||
|
||||
public sealed class A2AIntegrationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that calling the A2A card endpoint with MapA2A returns an agent card with a URL populated.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_WithAgentCard_CardEndpointReturnsCardWithUrlAsync()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("test-agent", "Test instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
var agentCard = new AgentCard
|
||||
{
|
||||
Name = "Test Agent",
|
||||
Description = "A test agent for A2A communication",
|
||||
Version = "1.0"
|
||||
};
|
||||
|
||||
// Map A2A with the agent card
|
||||
app.MapA2A(agentBuilder, "/a2a/test-agent", agentCard);
|
||||
|
||||
await app.StartAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Get the test server client
|
||||
TestServer testServer = app.Services.GetRequiredService<IServer>() as TestServer
|
||||
?? throw new InvalidOperationException("TestServer not found");
|
||||
var httpClient = testServer.CreateClient();
|
||||
|
||||
// Act - Query the agent card endpoint
|
||||
var requestUri = new Uri("/a2a/test-agent/v1/card", UriKind.Relative);
|
||||
var response = await httpClient.GetAsync(requestUri);
|
||||
|
||||
// Assert
|
||||
Assert.True(response.IsSuccessStatusCode, $"Expected successful response but got {response.StatusCode}");
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var jsonDoc = JsonDocument.Parse(content);
|
||||
var root = jsonDoc.RootElement;
|
||||
|
||||
// Verify the card has expected properties
|
||||
Assert.True(root.TryGetProperty("name", out var nameProperty));
|
||||
Assert.Equal("Test Agent", nameProperty.GetString());
|
||||
|
||||
Assert.True(root.TryGetProperty("description", out var descProperty));
|
||||
Assert.Equal("A test agent for A2A communication", descProperty.GetString());
|
||||
|
||||
// Verify the card has a URL property and it's not null/empty
|
||||
Assert.True(root.TryGetProperty("url", out var urlProperty));
|
||||
Assert.NotEqual(JsonValueKind.Null, urlProperty.ValueKind);
|
||||
|
||||
var url = urlProperty.GetString();
|
||||
Assert.NotNull(url);
|
||||
Assert.NotEmpty(url);
|
||||
Assert.StartsWith("http", url, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// agentCard's URL matches the agent endpoint
|
||||
Assert.Equal($"{testServer.BaseAddress.ToString().TrimEnd('/')}/a2a/test-agent", url);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await app.StopAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
+459
@@ -0,0 +1,459 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using A2A;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="A2AServerServiceCollectionExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class A2AServerServiceCollectionExtensionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that AddA2AServer with an agent name registers a keyed A2AServer
|
||||
/// that can be resolved from the service provider.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddA2AServer_WithAgentName_ResolvesKeyedA2AServerAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "test-agent";
|
||||
var services = new ServiceCollection();
|
||||
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
|
||||
|
||||
// Act
|
||||
services.AddA2AServer(AgentName);
|
||||
|
||||
// Assert
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var server = provider.GetKeyedService<A2AServer>(AgentName);
|
||||
Assert.NotNull(server);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddA2AServer with an agent instance registers a keyed A2AServer
|
||||
/// that can be resolved from the service provider using the agent's name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddA2AServer_WithAgentInstance_ResolvesKeyedA2AServerAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "instance-agent";
|
||||
var agentMock = CreateAgentMock(AgentName);
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Act
|
||||
services.AddA2AServer(agentMock.Object);
|
||||
|
||||
// Assert
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var server = provider.GetKeyedService<A2AServer>(AgentName);
|
||||
Assert.NotNull(server);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when no ITaskStore or AgentSessionStore are registered,
|
||||
/// AddA2AServer falls back to in-memory defaults and resolves successfully.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddA2AServer_WithNoCustomStores_FallsBackToInMemoryDefaultsAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "default-stores-agent";
|
||||
var services = new ServiceCollection();
|
||||
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
|
||||
|
||||
// Act
|
||||
services.AddA2AServer(AgentName);
|
||||
|
||||
// Assert - resolution succeeds without any stores registered
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var server = provider.GetKeyedService<A2AServer>(AgentName);
|
||||
Assert.NotNull(server);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when a custom ITaskStore is registered, AddA2AServer uses it
|
||||
/// instead of the default InMemoryTaskStore.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddA2AServer_WithCustomTaskStore_ResolvesSuccessfullyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "custom-taskstore-agent";
|
||||
var services = new ServiceCollection();
|
||||
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
|
||||
|
||||
var mockTaskStore = new Mock<ITaskStore>();
|
||||
services.AddKeyedSingleton(AgentName, mockTaskStore.Object);
|
||||
|
||||
// Act
|
||||
services.AddA2AServer(AgentName);
|
||||
|
||||
// Assert
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var server = provider.GetKeyedService<A2AServer>(AgentName);
|
||||
Assert.NotNull(server);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when a custom AgentSessionStore is registered, AddA2AServer uses it
|
||||
/// instead of the default InMemoryAgentSessionStore.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddA2AServer_WithCustomAgentSessionStore_ResolvesSuccessfullyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "custom-sessionstore-agent";
|
||||
var services = new ServiceCollection();
|
||||
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
|
||||
|
||||
var mockSessionStore = new Mock<AgentSessionStore>();
|
||||
services.AddKeyedSingleton(AgentName, mockSessionStore.Object);
|
||||
|
||||
// Act
|
||||
services.AddA2AServer(AgentName);
|
||||
|
||||
// Assert
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var server = provider.GetKeyedService<A2AServer>(AgentName);
|
||||
Assert.NotNull(server);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when a custom IAgentHandler is registered, AddA2AServer uses it
|
||||
/// instead of creating a default A2AAgentHandler.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddA2AServer_WithCustomAgentHandler_ResolvesSuccessfullyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "custom-handler-agent";
|
||||
var services = new ServiceCollection();
|
||||
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
|
||||
|
||||
var mockHandler = new Mock<IAgentHandler>();
|
||||
services.AddKeyedSingleton(AgentName, mockHandler.Object);
|
||||
|
||||
// Act
|
||||
services.AddA2AServer(AgentName);
|
||||
|
||||
// Assert
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var server = provider.GetKeyedService<A2AServer>(AgentName);
|
||||
Assert.NotNull(server);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the configureOptions callback is invoked when provided.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddA2AServer_WithConfigureOptions_InvokesCallbackAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "options-agent";
|
||||
var services = new ServiceCollection();
|
||||
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
|
||||
|
||||
bool callbackInvoked = false;
|
||||
|
||||
// Act
|
||||
services.AddA2AServer(AgentName, options =>
|
||||
{
|
||||
callbackInvoked = true;
|
||||
options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported;
|
||||
});
|
||||
|
||||
// Assert - callback is invoked during resolution
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var server = provider.GetKeyedService<A2AServer>(AgentName);
|
||||
Assert.NotNull(server);
|
||||
Assert.True(callbackInvoked);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddA2AServer with a null configureOptions does not throw.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddA2AServer_WithNullConfigureOptions_ResolvesSuccessfullyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "null-options-agent";
|
||||
var services = new ServiceCollection();
|
||||
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
|
||||
|
||||
// Act
|
||||
services.AddA2AServer(AgentName, configureOptions: null);
|
||||
|
||||
// Assert
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var server = provider.GetKeyedService<A2AServer>(AgentName);
|
||||
Assert.NotNull(server);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddA2AServer throws when the agent name is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddA2AServer_WithNullAgentName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsAny<ArgumentException>(() => services.AddA2AServer(agentName: null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddA2AServer throws when the agent name is whitespace.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddA2AServer_WithWhitespaceAgentName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsAny<ArgumentException>(() => services.AddA2AServer(agentName: " "));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddA2AServer throws when the services parameter is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddA2AServer_WithNullServices_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
IServiceCollection services = null!;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => services.AddA2AServer("agent"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddA2AServer with an agent instance throws when the agent is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddA2AServer_WithNullAgent_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => services.AddA2AServer(agent: null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddA2AServer with an agent instance throws when the agent's Name is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddA2AServer_WithAgent_NullName_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var agentMock = new Mock<AIAgent>();
|
||||
agentMock.Setup(a => a.Name).Returns((string?)null);
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
services.AddA2AServer(agentMock.Object));
|
||||
|
||||
Assert.Equal("agent.Name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AddA2AServer with an agent instance throws when the agent's Name is whitespace.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AddA2AServer_WithAgent_WhitespaceName_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
var agentMock = new Mock<AIAgent>();
|
||||
agentMock.Setup(a => a.Name).Returns(" ");
|
||||
|
||||
// Act & Assert
|
||||
ArgumentException exception = Assert.Throws<ArgumentException>(() =>
|
||||
services.AddA2AServer(agentMock.Object));
|
||||
|
||||
Assert.Equal("agent.Name", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when a custom <see cref="IAgentHandler"/> is registered as a keyed service,
|
||||
/// the <see cref="A2AServer"/> uses it to process requests instead of the default handler.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddA2AServer_WithCustomHandler_CustomHandlerIsInvokedOnRequestAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "custom-handler-wiring";
|
||||
var services = new ServiceCollection();
|
||||
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
|
||||
|
||||
var mockHandler = new Mock<IAgentHandler>();
|
||||
mockHandler
|
||||
.Setup(h => h.ExecuteAsync(
|
||||
It.IsAny<RequestContext>(),
|
||||
It.IsAny<AgentEventQueue>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns((RequestContext _, AgentEventQueue eq, CancellationToken ct) =>
|
||||
eq.EnqueueMessageAsync(
|
||||
new Message { MessageId = "resp", Role = Role.Agent, Parts = [new Part { Text = "Reply" }] }, ct).AsTask());
|
||||
|
||||
services.AddKeyedSingleton(AgentName, mockHandler.Object);
|
||||
|
||||
services.AddA2AServer(AgentName);
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var server = provider.GetRequiredKeyedService<A2AServer>(AgentName);
|
||||
|
||||
// Act
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var response = await server.SendMessageAsync(CreateTestSendMessageRequest(), cts.Token);
|
||||
|
||||
// Assert - the custom handler was invoked, not the default A2AAgentHandler
|
||||
mockHandler.Verify(
|
||||
h => h.ExecuteAsync(
|
||||
It.IsAny<RequestContext>(),
|
||||
It.IsAny<AgentEventQueue>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase);
|
||||
Assert.NotNull(response.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when a custom <see cref="AgentSessionStore"/> is registered as a keyed service
|
||||
/// and no custom <see cref="IAgentHandler"/> is registered, the default handler uses the custom
|
||||
/// session store for session management during request processing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddA2AServer_WithCustomSessionStore_NoHandler_SessionStoreIsUsedOnRequestAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "custom-sessionstore-wiring";
|
||||
var services = new ServiceCollection();
|
||||
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object);
|
||||
|
||||
var mockSessionStore = new Mock<AgentSessionStore>();
|
||||
mockSessionStore
|
||||
.Setup(x => x.GetSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new TestAgentSession());
|
||||
mockSessionStore
|
||||
.Setup(x => x.SaveSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<AgentSession>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ValueTask.CompletedTask);
|
||||
|
||||
services.AddKeyedSingleton(AgentName, mockSessionStore.Object);
|
||||
|
||||
services.AddA2AServer(AgentName);
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var server = provider.GetRequiredKeyedService<A2AServer>(AgentName);
|
||||
|
||||
// Act
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var response = await server.SendMessageAsync(CreateTestSendMessageRequest(), cts.Token);
|
||||
|
||||
// Assert - the custom session store was used, not InMemoryAgentSessionStore
|
||||
mockSessionStore.Verify(
|
||||
x => x.GetSessionAsync(
|
||||
It.IsAny<AIAgent>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase);
|
||||
Assert.NotNull(response.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when no custom stores or handlers are registered, the server uses
|
||||
/// the default in-memory stores and processes requests successfully end-to-end.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddA2AServer_WithNoCustomStores_DefaultStoresProcessRequestSuccessfullyAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "default-stores-request";
|
||||
var services = new ServiceCollection();
|
||||
services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMockForRequests(AgentName).Object);
|
||||
|
||||
services.AddA2AServer(AgentName);
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var server = provider.GetRequiredKeyedService<A2AServer>(AgentName);
|
||||
|
||||
// Act
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var response = await server.SendMessageAsync(CreateTestSendMessageRequest(), cts.Token);
|
||||
|
||||
// Assert - request was processed successfully with default in-memory stores
|
||||
Assert.NotNull(response);
|
||||
Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase);
|
||||
Assert.NotNull(response.Message);
|
||||
}
|
||||
|
||||
private static SendMessageRequest CreateTestSendMessageRequest() =>
|
||||
new()
|
||||
{
|
||||
Message = new Message
|
||||
{
|
||||
MessageId = "test-id",
|
||||
Role = Role.User,
|
||||
Parts = [new Part { Text = "Hello" }]
|
||||
}
|
||||
};
|
||||
|
||||
private static Mock<AIAgent> CreateAgentMock(string name)
|
||||
{
|
||||
Mock<AIAgent> agentMock = new() { CallBase = true };
|
||||
agentMock.SetupGet(x => x.Name).Returns(name);
|
||||
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(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Test response")]));
|
||||
|
||||
return agentMock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock <see cref="AIAgent"/> with session serialization support, suitable for
|
||||
/// tests that exercise the full request processing path with <see cref="InMemoryAgentSessionStore"/>.
|
||||
/// </summary>
|
||||
private static Mock<AIAgent> CreateAgentMockForRequests(string name)
|
||||
{
|
||||
Mock<AIAgent> agentMock = CreateAgentMock(name);
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<ValueTask<JsonElement>>("SerializeSessionCoreAsync",
|
||||
ItExpr.IsAny<AgentSession>(),
|
||||
ItExpr.IsAny<JsonSerializerOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(JsonDocument.Parse("{}").RootElement);
|
||||
|
||||
return agentMock;
|
||||
}
|
||||
|
||||
private sealed class TestAgentSession : AgentSession;
|
||||
}
|
||||
@@ -1,866 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using A2A;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AIAgentExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class AIAgentExtensionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 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_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
|
||||
|
||||
// Act
|
||||
await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
|
||||
Metadata = null
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.False(capturedOptions.AllowBackgroundResponses);
|
||||
Assert.Null(capturedOptions.AdditionalProperties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when messageSendParams.Metadata has values, the options.AdditionalProperties contains the converted values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_WhenMetadataHasValues_PassesOptionsWithAdditionalPropertiesToRunAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
|
||||
|
||||
// Act
|
||||
await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
|
||||
Metadata = new Dictionary<string, JsonElement>
|
||||
{
|
||||
["key1"] = JsonSerializer.SerializeToElement("value1"),
|
||||
["key2"] = JsonSerializer.SerializeToElement(42)
|
||||
}
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.NotNull(capturedOptions.AdditionalProperties);
|
||||
Assert.Equal(2, capturedOptions.AdditionalProperties.Count);
|
||||
Assert.True(capturedOptions.AdditionalProperties.ContainsKey("key1"));
|
||||
Assert.True(capturedOptions.AdditionalProperties.ContainsKey("key2"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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_PassesOptionsWithNoAdditionalPropertiesToRunAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
|
||||
|
||||
// Act
|
||||
await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
|
||||
Metadata = []
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.False(capturedOptions.AllowBackgroundResponses);
|
||||
Assert.Null(capturedOptions.AdditionalProperties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the agent response has AdditionalProperties, the returned AgentMessage.Metadata contains the converted values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_WhenResponseHasAdditionalProperties_ReturnsAgentMessageWithMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProps = new()
|
||||
{
|
||||
["responseKey1"] = "responseValue1",
|
||||
["responseKey2"] = 123
|
||||
};
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
|
||||
{
|
||||
AdditionalProperties = additionalProps
|
||||
};
|
||||
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
|
||||
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
|
||||
Assert.NotNull(agentMessage.Metadata);
|
||||
Assert.Equal(2, agentMessage.Metadata.Count);
|
||||
Assert.True(agentMessage.Metadata.ContainsKey("responseKey1"));
|
||||
Assert.True(agentMessage.Metadata.ContainsKey("responseKey2"));
|
||||
Assert.Equal("responseValue1", agentMessage.Metadata["responseKey1"].GetString());
|
||||
Assert.Equal(123, agentMessage.Metadata["responseKey2"].GetInt32());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the agent response has null AdditionalProperties, the returned AgentMessage.Metadata is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_WhenResponseHasNullAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
|
||||
{
|
||||
AdditionalProperties = null
|
||||
};
|
||||
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
|
||||
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
|
||||
Assert.Null(agentMessage.Metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the agent response has empty AdditionalProperties, the returned AgentMessage.Metadata is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_WhenResponseHasEmptyAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
|
||||
{
|
||||
AdditionalProperties = []
|
||||
};
|
||||
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
|
||||
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
|
||||
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 };
|
||||
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>())
|
||||
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>(
|
||||
(_, _, options, _) => optionsCallback(options))
|
||||
.ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Test response")]));
|
||||
|
||||
return agentMock;
|
||||
}
|
||||
|
||||
private static Mock<AIAgent> CreateAgentMockWithResponse(AgentResponse response)
|
||||
{
|
||||
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(response);
|
||||
|
||||
return agentMock;
|
||||
}
|
||||
|
||||
private static async Task<A2AResponse> InvokeOnMessageReceivedAsync(ITaskManager taskManager, MessageSendParams messageSendParams)
|
||||
{
|
||||
Func<MessageSendParams, CancellationToken, Task<A2AResponse>>? handler = taskManager.OnMessageReceived;
|
||||
Assert.NotNull(handler);
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AgentRunMode"/> class.
|
||||
/// </summary>
|
||||
public sealed class AgentRunModeTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that AllowBackgroundWhen throws ArgumentNullException for null delegate.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AllowBackgroundWhen_NullDelegate_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
AgentRunMode.AllowBackgroundWhen(null!));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that DisallowBackground equals another DisallowBackground instance.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Equals_DisallowBackground_AreEqual()
|
||||
{
|
||||
// Arrange
|
||||
var mode1 = AgentRunMode.DisallowBackground;
|
||||
var mode2 = AgentRunMode.DisallowBackground;
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(mode1.Equals(mode2));
|
||||
Assert.True(mode1 == mode2);
|
||||
Assert.False(mode1 != mode2);
|
||||
Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that AllowBackgroundIfSupported equals another AllowBackgroundIfSupported instance.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Equals_AllowBackgroundIfSupported_AreEqual()
|
||||
{
|
||||
// Arrange
|
||||
var mode1 = AgentRunMode.AllowBackgroundIfSupported;
|
||||
var mode2 = AgentRunMode.AllowBackgroundIfSupported;
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(mode1.Equals(mode2));
|
||||
Assert.True(mode1 == mode2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that DisallowBackground and AllowBackgroundIfSupported are not equal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Equals_DifferentModes_AreNotEqual()
|
||||
{
|
||||
// Arrange
|
||||
var disallow = AgentRunMode.DisallowBackground;
|
||||
var allow = AgentRunMode.AllowBackgroundIfSupported;
|
||||
|
||||
// Act & Assert
|
||||
Assert.False(disallow.Equals(allow));
|
||||
Assert.False(disallow == allow);
|
||||
Assert.True(disallow != allow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Equals returns false for null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Equals_Null_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var mode = AgentRunMode.DisallowBackground;
|
||||
|
||||
// Act & Assert
|
||||
Assert.False(mode.Equals(null));
|
||||
Assert.False(mode.Equals((object?)null));
|
||||
Assert.False(mode == null);
|
||||
Assert.True(mode != null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that two null AgentRunMode values are equal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Equals_BothNull_AreEqual()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunMode? mode1 = null;
|
||||
AgentRunMode? mode2 = null;
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(mode1 == mode2);
|
||||
Assert.False(mode1 != mode2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ToString returns expected values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ToString_ReturnsExpectedValues()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Equal("message", AgentRunMode.DisallowBackground.ToString());
|
||||
Assert.Equal("task", AgentRunMode.AllowBackgroundIfSupported.ToString());
|
||||
Assert.Equal("dynamic", AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)).ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Equals works correctly with object parameter.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Equals_WithObjectParameter_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var mode = AgentRunMode.DisallowBackground;
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(mode.Equals((object)AgentRunMode.DisallowBackground));
|
||||
Assert.False(mode.Equals((object)AgentRunMode.AllowBackgroundIfSupported));
|
||||
Assert.False(mode.Equals("not a run mode"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that two AllowBackgroundWhen instances with different delegates are not considered equal,
|
||||
/// because equality includes delegate identity for dynamic modes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Equals_AllowBackgroundWhen_DifferentDelegates_AreNotEqual()
|
||||
{
|
||||
// Arrange
|
||||
var mode1 = AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true));
|
||||
var mode2 = AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false));
|
||||
|
||||
// Act & Assert
|
||||
Assert.False(mode1.Equals(mode2));
|
||||
Assert.True(mode1 != mode2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that two AllowBackgroundWhen instances with the same delegate are considered equal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Equals_AllowBackgroundWhen_SameDelegate_AreEqual()
|
||||
{
|
||||
// Arrange
|
||||
static ValueTask<bool> CallbackAsync(A2ARunDecisionContext _, CancellationToken __) => ValueTask.FromResult(true);
|
||||
var mode1 = AgentRunMode.AllowBackgroundWhen(CallbackAsync);
|
||||
var mode2 = AgentRunMode.AllowBackgroundWhen(CallbackAsync);
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(mode1.Equals(mode2));
|
||||
Assert.True(mode1 == mode2);
|
||||
Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode());
|
||||
}
|
||||
}
|
||||
+82
-17
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using A2A;
|
||||
using Microsoft.Agents.AI.Hosting.A2A.Converters;
|
||||
@@ -10,66 +11,66 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests.Converters;
|
||||
public class MessageConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToChatMessages_MessageSendParams_Null_ReturnsEmptyCollection()
|
||||
public void ToChatMessages_SendMessageRequest_Null_ReturnsEmptyCollection()
|
||||
{
|
||||
MessageSendParams? messageSendParams = null;
|
||||
SendMessageRequest? sendMessageRequest = null;
|
||||
|
||||
var result = messageSendParams!.ToChatMessages();
|
||||
var result = sendMessageRequest!.ToChatMessages();
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToChatMessages_MessageSendParams_WithNullMessage_ReturnsEmptyCollection()
|
||||
public void ToChatMessages_SendMessageRequest_WithNullMessage_ReturnsEmptyCollection()
|
||||
{
|
||||
var messageSendParams = new MessageSendParams
|
||||
var sendMessageRequest = new SendMessageRequest
|
||||
{
|
||||
Message = null!
|
||||
};
|
||||
|
||||
var result = messageSendParams.ToChatMessages();
|
||||
var result = sendMessageRequest.ToChatMessages();
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToChatMessages_MessageSendParams_WithMessageWithoutParts_ReturnsEmptyCollection()
|
||||
public void ToChatMessages_SendMessageRequest_WithMessageWithoutParts_ReturnsEmptyCollection()
|
||||
{
|
||||
var messageSendParams = new MessageSendParams
|
||||
var sendMessageRequest = new SendMessageRequest
|
||||
{
|
||||
Message = new AgentMessage
|
||||
Message = new Message
|
||||
{
|
||||
MessageId = "test-id",
|
||||
Role = MessageRole.User,
|
||||
Role = Role.User,
|
||||
Parts = null!
|
||||
}
|
||||
};
|
||||
|
||||
var result = messageSendParams.ToChatMessages();
|
||||
var result = sendMessageRequest.ToChatMessages();
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToChatMessages_MessageSendParams_WithValidTextMessage_ReturnsCorrectChatMessage()
|
||||
public void ToChatMessages_SendMessageRequest_WithValidTextMessage_ReturnsCorrectChatMessage()
|
||||
{
|
||||
var messageSendParams = new MessageSendParams
|
||||
var sendMessageRequest = new SendMessageRequest
|
||||
{
|
||||
Message = new AgentMessage
|
||||
Message = new Message
|
||||
{
|
||||
MessageId = "test-id",
|
||||
Role = MessageRole.User,
|
||||
Role = Role.User,
|
||||
Parts =
|
||||
[
|
||||
new TextPart { Text = "Hello, world!" }
|
||||
new Part { Text = "Hello, world!" }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var result = messageSendParams.ToChatMessages();
|
||||
var result = sendMessageRequest.ToChatMessages();
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
@@ -82,4 +83,68 @@ public class MessageConverterTests
|
||||
var textContent = Assert.IsType<TextContent>(chatMessage.Contents.First());
|
||||
Assert.Equal("Hello, world!", textContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToParts_NullList_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
IList<ChatMessage>? messages = null;
|
||||
|
||||
// Act
|
||||
var result = messages!.ToParts();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToParts_EmptyList_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
IList<ChatMessage> messages = [];
|
||||
|
||||
// Act
|
||||
var result = messages.ToParts();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToParts_WithTextContent_ReturnsTextPart()
|
||||
{
|
||||
// Arrange
|
||||
IList<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, "Hello from the agent!")
|
||||
];
|
||||
|
||||
// Act
|
||||
var result = messages.ToParts();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("Hello from the agent!", result[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToParts_WithMultipleMessages_ReturnsAllParts()
|
||||
{
|
||||
// Arrange
|
||||
IList<ChatMessage> messages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "First message"),
|
||||
new ChatMessage(ChatRole.Assistant, "Second message")
|
||||
];
|
||||
|
||||
// Act
|
||||
var result = messages.ToParts();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal("First message", result[0].Text);
|
||||
Assert.Equal("Second message", result[1].Text);
|
||||
}
|
||||
}
|
||||
|
||||
-479
@@ -1,479 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using A2A;
|
||||
using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions.MapA2A method.
|
||||
/// </summary>
|
||||
public sealed class EndpointRouteA2ABuilderExtensionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A throws ArgumentNullException for null endpoints.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithAgentBuilder_NullEndpoints_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
endpoints.MapA2A(agentBuilder, "/a2a"));
|
||||
|
||||
Assert.Equal("endpoints", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A throws ArgumentNullException for null agentBuilder.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
IHostedAgentBuilder agentBuilder = null!;
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
app.MapA2A(agentBuilder, "/a2a"));
|
||||
|
||||
Assert.Equal("agentBuilder", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with IHostedAgentBuilder correctly maps the agent with default task manager configuration.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithAgentBuilder_DefaultConfiguration_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = app.MapA2A(agentBuilder, "/a2a");
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with IHostedAgentBuilder and custom task manager configuration succeeds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithAgentBuilder_CustomTaskManagerConfiguration_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = app.MapA2A(agentBuilder, "/a2a", taskManager => { });
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with IHostedAgentBuilder and agent card succeeds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithAgentBuilder_WithAgentCard_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
var agentCard = new AgentCard
|
||||
{
|
||||
Name = "Test Agent",
|
||||
Description = "A test agent for A2A communication"
|
||||
};
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = app.MapA2A(agentBuilder, "/a2a", agentCard);
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with IHostedAgentBuilder, agent card, and custom task manager configuration succeeds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithAgentBuilder_WithAgentCardAndCustomConfiguration_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
var agentCard = new AgentCard
|
||||
{
|
||||
Name = "Test Agent",
|
||||
Description = "A test agent for A2A communication"
|
||||
};
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = app.MapA2A(agentBuilder, "/a2a", agentCard, taskManager => { });
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A throws ArgumentNullException for null endpoints when using string agent name.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithAgentName_NullEndpoints_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
endpoints.MapA2A("agent", "/a2a"));
|
||||
|
||||
Assert.Equal("endpoints", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with string agent name correctly maps the agent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithAgentName_DefaultConfiguration_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = app.MapA2A("agent", "/a2a");
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with string agent name and custom task manager configuration succeeds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithAgentName_CustomTaskManagerConfiguration_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = app.MapA2A("agent", "/a2a", taskManager => { });
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with string agent name and agent card succeeds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithAgentName_WithAgentCard_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
var agentCard = new AgentCard
|
||||
{
|
||||
Name = "Test Agent",
|
||||
Description = "A test agent for A2A communication"
|
||||
};
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = app.MapA2A("agent", "/a2a", agentCard);
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with string agent name, agent card, and custom task manager configuration succeeds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithAgentName_WithAgentCardAndCustomConfiguration_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
var agentCard = new AgentCard
|
||||
{
|
||||
Name = "Test Agent",
|
||||
Description = "A test agent for A2A communication"
|
||||
};
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = app.MapA2A("agent", "/a2a", agentCard, taskManager => { });
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A throws ArgumentNullException for null endpoints when using AIAgent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithAIAgent_NullEndpoints_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
endpoints.MapA2A((AIAgent)null!, "/a2a"));
|
||||
|
||||
Assert.Equal("endpoints", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with AIAgent correctly maps the agent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithAIAgent_DefaultConfiguration_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
AIAgent agent = app.Services.GetRequiredKeyedService<AIAgent>("agent");
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = app.MapA2A(agent, "/a2a");
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with AIAgent and custom task manager configuration succeeds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithAIAgent_CustomTaskManagerConfiguration_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
AIAgent agent = app.Services.GetRequiredKeyedService<AIAgent>("agent");
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = app.MapA2A(agent, "/a2a", taskManager => { });
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with AIAgent and agent card succeeds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithAIAgent_WithAgentCard_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
AIAgent agent = app.Services.GetRequiredKeyedService<AIAgent>("agent");
|
||||
|
||||
var agentCard = new AgentCard
|
||||
{
|
||||
Name = "Test Agent",
|
||||
Description = "A test agent for A2A communication"
|
||||
};
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = app.MapA2A(agent, "/a2a", agentCard);
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A with AIAgent, agent card, and custom task manager configuration succeeds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithAIAgent_WithAgentCardAndCustomConfiguration_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
AIAgent agent = app.Services.GetRequiredKeyedService<AIAgent>("agent");
|
||||
|
||||
var agentCard = new AgentCard
|
||||
{
|
||||
Name = "Test Agent",
|
||||
Description = "A test agent for A2A communication"
|
||||
};
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = app.MapA2A(agent, "/a2a", agentCard, taskManager => { });
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that MapA2A throws ArgumentNullException for null endpoints when using ITaskManager.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithTaskManager_NullEndpoints_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!;
|
||||
ITaskManager taskManager = null!;
|
||||
|
||||
// Act & Assert
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
endpoints.MapA2A(taskManager, "/a2a"));
|
||||
|
||||
Assert.Equal("endpoints", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that multiple agents can be mapped to different paths.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_MultipleAgents_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agent1Builder = builder.AddAIAgent("agent1", "Instructions1", chatClientServiceKey: "chat-client");
|
||||
IHostedAgentBuilder agent2Builder = builder.AddAIAgent("agent2", "Instructions2", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
app.MapA2A(agent1Builder, "/a2a/agent1");
|
||||
app.MapA2A(agent2Builder, "/a2a/agent2");
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that custom paths can be specified for A2A endpoints.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithCustomPath_AcceptsValidPath()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
app.MapA2A(agentBuilder, "/custom/a2a/path");
|
||||
Assert.NotNull(app);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that task manager configuration callback is invoked correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithAgentBuilder_TaskManagerConfigurationCallbackInvoked()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
bool configureCallbackInvoked = false;
|
||||
|
||||
// Act
|
||||
app.MapA2A(agentBuilder, "/a2a", taskManager =>
|
||||
{
|
||||
configureCallbackInvoked = true;
|
||||
Assert.NotNull(taskManager);
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.True(configureCallbackInvoked);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that agent card with all properties is accepted.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapA2A_WithAgentBuilder_FullAgentCard_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
IChatClient mockChatClient = new DummyChatClient();
|
||||
builder.Services.AddKeyedSingleton("chat-client", mockChatClient);
|
||||
IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client");
|
||||
builder.Services.AddLogging();
|
||||
using WebApplication app = builder.Build();
|
||||
|
||||
var agentCard = new AgentCard
|
||||
{
|
||||
Name = "Test Agent",
|
||||
Description = "A comprehensive test agent"
|
||||
};
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
var result = app.MapA2A(agentBuilder, "/a2a", agentCard);
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user