mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: [Breaking] Migrate A2A agent and hosting to A2A SDK v1 (#5423)
* update a2a agent to the latest a2a sdk (#5257) * Move A2A samples from 04-hosting to 02-agents (#5267) Move the A2A sample projects (A2AAgent_AsFunctionTools and A2AAgent_PollingForTaskCompletion) from samples/04-hosting/A2A/ to samples/02-agents/A2A/ to better align with the sample directory structure. Update solution file and samples README accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Fix stream reconnection for A2AAgent (#5275) * Add SSE stream reconnection support to A2AAgent Implement automatic reconnection for SSE streams that disconnect mid-task, using the Last-Event-ID header to resume from where the stream left off. Changes: - Add InvokeStreamingWithReconnectAsync method to A2AAgent with configurable max retries and delay between attempts - Add new log messages for reconnection events - Add A2AAgent_StreamReconnection sample demonstrating the feature - Update existing polling sample to use simplified SendMessageAsync API - Add unit tests for stream reconnection logic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address comments * Address PR review feedback - Dispose SSE enumerator before GetTaskAsync fallback to release HTTP connection - Wrap StreamWriter in using blocks with leaveOpen:true and explicit UTF-8 encoding - Print update.Text instead of update object in stream reconnection sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Use IA2AClientFactory to create A2AClient (#5277) * Refactor A2A extensions to use IA2AClientFactory and add ProtocolSelection sample - Update A2AAgentCardExtensions to accept IA2AClientFactory instead of A2AClientOptions - Update A2ACardResolverExtensions to accept IA2AClientFactory - Update A2AClientExtensions to accept IA2AClientFactory - Update A2AAgent to use IA2AClientFactory for client creation - Add A2AAgent_ProtocolSelection sample demonstrating protocol selection - Add comprehensive unit tests for all changes - Update README files with new sample reference Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Reorder params: options before loggerFactory in A2A extensions Move A2AClientOptions parameter before ILoggerFactory in AsAIAgent and GetAIAgentAsync extension methods to follow the repo convention of keeping LoggerFactory and CancellationToken as the last parameters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Migrate A2A hosting to A2A SDK v1 (#5363) * .NET: Migrate A2A hosting to A2A SDK v1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * remove unused agent card --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Split A2A endpoint mapping into protocol-specific methods (#5413) * .NET: Refactor A2A hosting registration into A2AServerServiceCollectionExtensions - Rename A2AHostingOptions to A2AServerRegistrationOptions - Move server registration logic from A2AEndpointRouteBuilderExtensions and AIAgentExtensions into new A2AServerServiceCollectionExtensions - Remove A2AProtocolBinding and AIAgentExtensions (consolidated) - Update samples and tests to use the new registration API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address copilot comments --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove unnecessary using directive in AgentWebChat.AgentHost Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * restore AsyncEnumerable package version * address copilot initial feedback * address automated code review and formatting issues * fix formatting issues * Add DI wiring verification tests for AddA2AServer Add three tests to A2AServerServiceCollectionExtensionsTests that verify custom keyed services are actually wired through to the A2AServer, not just that the server resolves non-null: - Custom IAgentHandler: verifies the keyed handler is invoked when processing a SendMessageRequest instead of the default A2AAgentHandler. - Custom AgentSessionStore (no handler): verifies the keyed session store's GetSessionAsync is called during request processing when no custom handler is registered. - Default stores end-to-end: verifies the InMemoryAgentSessionStore and InMemoryTaskStore defaults successfully process a request. Uses a new CreateAgentMockForRequests helper that includes SerializeSessionCoreAsync setup needed by InMemoryAgentSessionStore. All tests call A2AServer.SendMessageAsync directly (no HTTP layer needed) and use CancellationToken timeouts to guard against hangs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
acec9caa2f
commit
66e02c10e3
@@ -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