mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: A2A agent (#520)
* add a2a agent * Update dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgent.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * address pr review feedback * move unit tests for extension methods to the extensions folder * address pr review comments * address pr review comments * address pr review feedback * move a2a agent sample to console app * remove unnecessary Ids set for new projects in the solution file * remove unnecessary configuration --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
97d72c967f
commit
1d2f833122
@@ -52,6 +52,7 @@
|
||||
</Folder>
|
||||
<Folder Name="/Samples/HowToCreateAnAIAgentByProvider/">
|
||||
<File Path="samples/HowToCreateAnAIAgentByProvider/README.md" />
|
||||
<Project Path="samples/HowToCreateAnAIAgentByProvider/AIAgent_With_A2A/AIAgent_With_A2A.csproj" />
|
||||
<Project Path="samples/HowToCreateAnAIAgentByProvider/AIAgent_With_AzureFoundry/AIAgent_With_AzureFoundry.csproj" />
|
||||
<Project Path="samples/HowToCreateAnAIAgentByProvider/AIAgent_With_AzureOpenAIChatCompletion/AIAgent_With_AzureOpenAIChatCompletion.csproj" />
|
||||
<Project Path="samples/HowToCreateAnAIAgentByProvider/AIAgent_With_AzureOpenAIResponses/AIAgent_With_AzureOpenAIResponses.csproj" />
|
||||
@@ -161,6 +162,7 @@
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Microsoft.Agents.Orchestration/Microsoft.Agents.Orchestration.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.Workflows/Microsoft.Agents.Workflows.csproj" />
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.A2A/Microsoft.Extensions.AI.Agents.A2A.csproj" />
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.Abstractions/Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.AzureAI/Microsoft.Extensions.AI.Agents.AzureAI.csproj" />
|
||||
<Project Path="src/Microsoft.Extensions.AI.Agents.CopilotStudio/Microsoft.Extensions.AI.Agents.CopilotStudio.csproj" />
|
||||
@@ -189,6 +191,7 @@
|
||||
<Folder Name="/Tests/UnitTests/">
|
||||
<Project Path="tests/Microsoft.Agents.Orchestration.UnitTests/Microsoft.Agents.Orchestration.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.Workflows.UnitTests/Microsoft.Agents.Workflows.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Microsoft.Extensions.AI.Agents.A2A.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests/Microsoft.Extensions.AI.Agents.Hosting.A2A.Tests.csproj" Id="2a1c544d-237d-4436-8732-ba0c447ac06b" />
|
||||
<Project Path="tests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests/Microsoft.Extensions.AI.Agents.Hosting.UnitTests.csproj" />
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<LangVersion>12</LangVersion>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="A2A" />
|
||||
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-preview.5.25277.114" />
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-preview.5.25277.114" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.A2A\Microsoft.Extensions.AI.Agents.A2A.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to create and use a simple AI agent with an existing A2A agent.
|
||||
|
||||
using System;
|
||||
using A2A;
|
||||
using Microsoft.Extensions.AI.Agents;
|
||||
using Microsoft.Extensions.AI.Agents.A2A;
|
||||
|
||||
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));
|
||||
|
||||
// Create an instance of the AIAgent for an existing A2A agent specified by the agent card.
|
||||
AIAgent agent = await agentCardResolver.GetAIAgentAsync();
|
||||
|
||||
// Invoke the agent and output the text result.
|
||||
AgentRunResponse response = await agent.RunAsync("Tell me a joke about a pirate.");
|
||||
Console.WriteLine(response);
|
||||
@@ -0,0 +1,34 @@
|
||||
# Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 8.0 SDK or later
|
||||
- Access to the A2A agent host service
|
||||
|
||||
**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 variables:
|
||||
|
||||
```powershell
|
||||
$env:A2A_AGENT_HOST="https://your-a2a-agent-host" # Replace with your A2A agent host endpoint
|
||||
```
|
||||
|
||||
## Advanced scenario
|
||||
|
||||
This method can be used to create AI agents for A2A agents whose hosts support the [Direct Configuration / Private Discovery](https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#3-direct-configuration--private-discovery) discovery mechanism.
|
||||
|
||||
```csharp
|
||||
using A2A;
|
||||
using Microsoft.Extensions.AI.Agents;
|
||||
using Microsoft.Extensions.AI.Agents.A2A;
|
||||
|
||||
// Create an A2AClient pointing to your `echo` A2A agent endpoint
|
||||
A2AClient a2aClient = new(new Uri("https://your-a2a-agent-host/echo"));
|
||||
|
||||
// Create an AIAgent from the A2AClient
|
||||
AIAgent agent = a2aClient.GetAIAgent();
|
||||
|
||||
// Run the agent
|
||||
AgentRunResponse response = await agent.RunAsync("Tell me a joke about a pirate.");
|
||||
Console.WriteLine(response);
|
||||
```
|
||||
@@ -13,6 +13,7 @@ See the README.md for each sample for the prerequisites for that sample.
|
||||
|
||||
|Sample|Description|
|
||||
|---|---|
|
||||
|[Creating an AIAgent with A2A](./AIAgent_With_A2A/)|This sample demonstrates how to create AIAgent for an existing A2A agent.|
|
||||
|[Creating an AIAgent with AzureFoundry](./AIAgent_With_AzureFoundry/)|This sample demonstrates how to create an Azure Foundry agent and expose it as an AIAgent|
|
||||
|[Creating an AIAgent with Azure OpenAI ChatCompletion](./AIAgent_With_AzureOpenAIChatCompletion/)|This sample demonstrates how to create an AIAgent using Azure OpenAI ChatCompletion as the underlying inference service|
|
||||
|[Creating an AIAgent with Azure OpenAI Responses](./AIAgent_With_AzureOpenAIResponses/)|This sample demonstrates how to create an AIAgent using Azure OpenAI Responses as the underlying inference service|
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using A2A;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an <see cref="AIAgent"/> that can interact with remote agents that are exposed via the A2A protocol
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This agent supports only messages as a response from A2A agents.
|
||||
/// Support for tasks will be added later as part of the long-running
|
||||
/// executions work.
|
||||
/// </remarks>
|
||||
internal sealed class A2AAgent : AIAgent
|
||||
{
|
||||
private readonly A2AClient _a2aClient;
|
||||
private readonly string? _id;
|
||||
private readonly string? _name;
|
||||
private readonly string? _description;
|
||||
private readonly string? _displayName;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="A2AAgent"/> class.
|
||||
/// </summary>
|
||||
/// <param name="a2aClient">The A2A client to use for interacting with A2A agents.</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="displayName">The display name 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, string? displayName = null, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
_ = Throw.IfNull(a2aClient);
|
||||
|
||||
this._a2aClient = a2aClient;
|
||||
this._id = id;
|
||||
this._name = name;
|
||||
this._description = description;
|
||||
this._displayName = displayName;
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<A2AAgent>();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<AgentRunResponse> RunAsync(IReadOnlyCollection<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateInputMessages(messages);
|
||||
|
||||
var a2aMessage = messages.ToA2AMessage();
|
||||
|
||||
// Linking the message to the existing conversation, if any.
|
||||
a2aMessage.ContextId = thread?.ConversationId;
|
||||
|
||||
this._logger.LogA2AAgentInvokingAgent(nameof(RunAsync), this.Id, this.Name);
|
||||
|
||||
var a2aResponse = await this._a2aClient.SendMessageAsync(new MessageSendParams { Message = a2aMessage }, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name);
|
||||
|
||||
if (a2aResponse is Message message)
|
||||
{
|
||||
UpdateThreadConversationId(thread, message);
|
||||
|
||||
return new AgentRunResponse
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ResponseId = message.MessageId,
|
||||
RawRepresentation = message,
|
||||
Messages = [message.ToChatMessage()],
|
||||
AdditionalProperties = message.Metadata.ToAdditionalProperties(),
|
||||
};
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"Only message responses are supported from A2A agents. Received: {a2aResponse.GetType().FullName ?? "null"}");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IReadOnlyCollection<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateInputMessages(messages);
|
||||
|
||||
var a2aMessage = messages.ToA2AMessage();
|
||||
|
||||
// Linking the message to the existing conversation, if any.
|
||||
a2aMessage.ContextId = thread?.ConversationId;
|
||||
|
||||
this._logger.LogA2AAgentInvokingAgent(nameof(RunStreamingAsync), this.Id, this.Name);
|
||||
|
||||
var a2aSseEvents = this._a2aClient.SendMessageStreamAsync(new MessageSendParams { Message = a2aMessage }, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
this._logger.LogAgentChatClientInvokedAgent(nameof(RunStreamingAsync), this.Id, this.Name);
|
||||
|
||||
await foreach (var sseEvent in a2aSseEvents)
|
||||
{
|
||||
if (sseEvent.Data is not Message message)
|
||||
{
|
||||
throw new NotSupportedException($"Only message responses are supported from A2A agents. Received: {sseEvent.Data?.GetType().FullName ?? "null"}");
|
||||
}
|
||||
|
||||
UpdateThreadConversationId(thread, message);
|
||||
|
||||
yield return new AgentRunResponseUpdate
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ResponseId = message.MessageId,
|
||||
RawRepresentation = message,
|
||||
Role = ChatRole.Assistant,
|
||||
MessageId = message.MessageId,
|
||||
Contents = [.. message.Parts.Select(part => part.ToAIContent())],
|
||||
AdditionalProperties = message.Metadata.ToAdditionalProperties(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Id => this._id ?? base.Id;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? Name => this._name ?? base.Name;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string DisplayName => this._displayName ?? base.DisplayName;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? Description => this._description ?? base.Description;
|
||||
|
||||
private static void ValidateInputMessages(IReadOnlyCollection<ChatMessage> messages)
|
||||
{
|
||||
_ = Throw.IfNull(messages);
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
if (message.Role != ChatRole.User)
|
||||
{
|
||||
throw new ArgumentException($"All input messages for A2A agents must have the role '{ChatRole.User}'. Found '{message.Role}'.", nameof(messages));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateThreadConversationId(AgentThread? thread, Message message)
|
||||
{
|
||||
if (thread is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Surface cases where the A2A agent responds with a message that
|
||||
// has a different context Id than the thread's conversation Id.
|
||||
if (thread.ConversationId is not null && message.ContextId is not null && thread.ConversationId != message.ContextId)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The {nameof(message.ContextId)} returned from the A2A agent is different from the conversation Id of the provided {nameof(AgentThread)}.");
|
||||
}
|
||||
|
||||
// Assign a server-generated context Id to the thread if it's not already set.
|
||||
thread.ConversationId ??= message.ContextId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for logging <see cref="A2AAgent"/> invocations.
|
||||
/// </summary>
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal static partial class A2AAgentLogMessages
|
||||
{
|
||||
/// <summary>
|
||||
/// Logs <see cref="A2AAgent"/> invoking agent (started).
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Debug,
|
||||
Message = "[{MethodName}] A2AAgent {AgentId}/{AgentName} invoking underlying A2A agent.")]
|
||||
public static partial void LogA2AAgentInvokingAgent(
|
||||
this ILogger logger,
|
||||
string methodName,
|
||||
string agentId,
|
||||
string? agentName);
|
||||
|
||||
/// <summary>
|
||||
/// Logs <see cref="A2AAgent"/> invoked agent (complete).
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = "[{MethodName}] A2AAgent {AgentId}/{AgentName} invoked underlying A2A agent.")]
|
||||
public static partial void LogAgentChatClientInvokedAgent(
|
||||
this ILogger logger,
|
||||
string methodName,
|
||||
string agentId,
|
||||
string? agentName);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using A2A;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="A2ACardResolver"/>
|
||||
/// to simplify the creation of A2A agents.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These extensions bridge the gap between A2A SDK client objects
|
||||
/// and the Microsoft Extensions AI Agent framework.
|
||||
/// <para>
|
||||
/// They allow developers to easily create AI agents that can interact
|
||||
/// with A2A agents by handling the conversion from A2A clients to
|
||||
/// <see cref="A2AAgent"/> instances that implement the <see cref="AIAgent"/> interface.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class A2ACardResolverExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method can be used to create AI agents for A2A agents whose hosts support one of the A2A discovery mechanisms:
|
||||
/// <list type="bullet">
|
||||
/// <item><see href="https://github.com/a2aproject/A2A/blob/main/docs/topics/agent-discovery.md#1-well-known-uri">Well-Known URI</see></item>
|
||||
/// <item><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></item>
|
||||
/// </list>
|
||||
/// </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="loggerFactory">The logger factory for enabling logging within the agent.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to use when retrieving the agent card.</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)
|
||||
{
|
||||
// Obtain the agent card from the resolver.
|
||||
var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Create the A2A client using the agent URL from the card.
|
||||
var a2aClient = new A2AClient(new Uri(agentCard.Url), httpClient);
|
||||
|
||||
return a2aClient.GetAIAgent(name: agentCard.Name, description: agentCard.Description, loggerFactory: loggerFactory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using A2A;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="A2AClient"/>
|
||||
/// to simplify the creation of A2A agents.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These extensions bridge the gap between A2A SDK client objects
|
||||
/// and the Microsoft Extensions AI Agent framework.
|
||||
/// <para>
|
||||
/// They allow developers to easily create AI agents that can interact
|
||||
/// with A2A agents by handling the conversion from A2A clients to
|
||||
/// <see cref="A2AAgent"/> instances that implement the <see cref="AIAgent"/> interface.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class A2AClientExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves an instance of <see cref="AIAgent"/> for an existing A2A agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method can be used to create AI agents for A2A agents whose hosts support the
|
||||
/// <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="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="displayName">The display name 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 GetAIAgent(this A2AClient client, string? id = null, string? name = null, string? description = null, string? displayName = null, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
return new A2AAgent(client, id, name, description, displayName, loggerFactory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using A2A;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for the <see cref="Message"/> class.
|
||||
/// </summary>
|
||||
internal static class A2AMessageExtensions
|
||||
{
|
||||
public static ChatMessage ToChatMessage(this Message message)
|
||||
{
|
||||
List<AIContent>? aiContents = null;
|
||||
|
||||
foreach (var part in message.Parts)
|
||||
{
|
||||
(aiContents ??= []).Add(part.ToAIContent());
|
||||
}
|
||||
|
||||
return new ChatMessage(ChatRole.Assistant, aiContents)
|
||||
{
|
||||
AdditionalProperties = message.Metadata.ToAdditionalProperties(),
|
||||
RawRepresentation = message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for A2A metadata dictionary.
|
||||
/// </summary>
|
||||
internal static class A2AMetadataExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a dictionary of metadata to an <see cref="AdditionalPropertiesDictionary"/>.
|
||||
/// </summary>
|
||||
/// <param name="metadata">The metadata dictionary to convert.</param>
|
||||
/// <returns>The converted <see cref="AdditionalPropertiesDictionary"/>, or null if the input is null or empty.</returns>
|
||||
public static AdditionalPropertiesDictionary? ToAdditionalProperties(this Dictionary<string, JsonElement>? metadata)
|
||||
{
|
||||
if (metadata is not { Count: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var additionalProperties = new AdditionalPropertiesDictionary();
|
||||
foreach (var kvp in metadata)
|
||||
{
|
||||
additionalProperties[kvp.Key] = kvp.Value;
|
||||
}
|
||||
return additionalProperties;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using A2A;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for the <see cref="Part"/> class.
|
||||
/// </summary>
|
||||
internal static class A2APartExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts an A2A <see cref="Part"/> to an <see cref="AIContent"/>.
|
||||
/// </summary>
|
||||
/// <param name="part">The A2A part to convert.</param>
|
||||
/// <returns>The corresponding <see cref="AIContent"/>.</returns>
|
||||
public static AIContent ToAIContent(this Part part)
|
||||
{
|
||||
return part switch
|
||||
{
|
||||
TextPart textPart => new TextContent(textPart.Text)
|
||||
{
|
||||
RawRepresentation = textPart,
|
||||
AdditionalProperties = textPart.Metadata.ToAdditionalProperties()
|
||||
},
|
||||
FilePart filePart when filePart.File is FileWithUri fileWithUrl => new HostedFileContent(fileWithUrl.Uri)
|
||||
{
|
||||
RawRepresentation = filePart,
|
||||
AdditionalProperties = filePart.Metadata.ToAdditionalProperties()
|
||||
},
|
||||
_ => throw new NotSupportedException($"Part type '{part.GetType().Name}' is not supported.")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using A2A;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for the <see cref="AIContent"/> class.
|
||||
/// </summary>
|
||||
internal static class AIContentExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a collection of <see cref="AIContent"/> to a list of <see cref="Part"/> objects.
|
||||
/// </summary>
|
||||
/// <param name="contents">The collection of AI contents to convert.</param>"
|
||||
/// <returns>The list of A2A <see cref="Part"/> objects.</returns>
|
||||
public static List<Part>? ToA2AParts(this IEnumerable<AIContent> contents)
|
||||
{
|
||||
List<Part>? parts = null;
|
||||
|
||||
foreach (var content in contents)
|
||||
{
|
||||
(parts ??= []).Add(content.ToA2APart());
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a <see cref="AIContent"/> to a <see cref="Part"/> object."/>
|
||||
/// </summary>
|
||||
/// <param name="content">AI content to convert.</param>
|
||||
/// <returns>The corresponding A2A <see cref="Part"/> object.</returns>
|
||||
public static Part ToA2APart(this AIContent content)
|
||||
{
|
||||
return content switch
|
||||
{
|
||||
TextContent textContent => new TextPart { Text = textContent.Text },
|
||||
HostedFileContent hostedFileContent => new FilePart { File = new FileWithUri { Uri = hostedFileContent.FileId } },
|
||||
_ => throw new NotSupportedException($"Unsupported content type: {content.GetType().Name}."),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using A2A;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.A2A;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for the <see cref="ChatMessage"/> class.
|
||||
/// </summary>
|
||||
internal static class ChatMessageExtensions
|
||||
{
|
||||
public static Message ToA2AMessage(this IReadOnlyCollection<ChatMessage> messages)
|
||||
{
|
||||
List<Part> allParts = [];
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
if (message.Contents.ToA2AParts() is { Count: > 0 } ps)
|
||||
{
|
||||
allParts.AddRange(ps);
|
||||
}
|
||||
}
|
||||
|
||||
return new Message
|
||||
{
|
||||
MessageId = Guid.NewGuid().ToString(),
|
||||
Role = MessageRole.User,
|
||||
Parts = allParts,
|
||||
};
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
|
||||
<VersionSuffix>alpha</VersionSuffix>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="A2A" />
|
||||
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-preview.5.25277.114" />
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-preview.5.25277.114" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft.Extensions.AI.Agents.A2A</Title>
|
||||
<Description>Defines AIAgent for interacting with application-to-application (A2A) agents.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Extensions.AI.Agents.Abstractions\Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Extensions.AI.Agents.A2A.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+1
@@ -11,6 +11,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
<PackageReference Include="System.Linq.Async" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
<PackageReference Include="xRetry" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="System.Linq.Async" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+1
@@ -12,6 +12,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="System.Linq.Async" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+1
@@ -12,6 +12,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="System.Linq.Async" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.ServerSentEvents;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using A2A;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="A2AAgent"/> class.
|
||||
/// </summary>
|
||||
public sealed class A2AAgentTests : IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly A2AClientHttpMessageHandlerStub _handler;
|
||||
private readonly A2AClient _a2aClient;
|
||||
private readonly A2AAgent _agent;
|
||||
|
||||
public A2AAgentTests()
|
||||
{
|
||||
this._handler = new A2AClientHttpMessageHandlerStub();
|
||||
this._httpClient = new HttpClient(this._handler, false);
|
||||
this._a2aClient = new A2AClient(new Uri("http://test-endpoint"), this._httpClient);
|
||||
this._agent = new A2AAgent(this._a2aClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithAllParameters_InitializesPropertiesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
const string TestId = "test-id";
|
||||
const string TestName = "test-name";
|
||||
const string TestDescription = "test-description";
|
||||
const string TestDisplayName = "test-display-name";
|
||||
|
||||
// Act
|
||||
var agent = new A2AAgent(this._a2aClient, TestId, TestName, TestDescription, TestDisplayName);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(TestId, agent.Id);
|
||||
Assert.Equal(TestName, agent.Name);
|
||||
Assert.Equal(TestDescription, agent.Description);
|
||||
Assert.Equal(TestDisplayName, agent.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNullA2AClient_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => new A2AAgent(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithDefaultParameters_UsesBaseProperties()
|
||||
{
|
||||
// Act
|
||||
var agent = new A2AAgent(this._a2aClient);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent.Id);
|
||||
Assert.NotEmpty(agent.Id);
|
||||
Assert.Null(agent.Name);
|
||||
Assert.Null(agent.Description);
|
||||
Assert.Equal(agent.Id, agent.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_NonUserRoleMessages_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var inputMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.Assistant, "I am an assistant message"),
|
||||
new(ChatRole.User, "Valid user message")
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => this._agent.RunAsync(inputMessages));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithValidUserMessage_RunsSuccessfullyAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._handler.ResponseToReturn = new Message
|
||||
{
|
||||
MessageId = "response-123",
|
||||
Role = MessageRole.Agent,
|
||||
Parts = new List<Part>
|
||||
{
|
||||
new TextPart { Text = "Hello! How can I help you today?" }
|
||||
}
|
||||
};
|
||||
|
||||
var inputMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Hello, world!")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await this._agent.RunAsync(inputMessages);
|
||||
|
||||
// Assert input message sent to A2AClient
|
||||
var inputMessage = this._handler.CapturedMessageSendParams?.Message;
|
||||
Assert.NotNull(inputMessage);
|
||||
Assert.Single(inputMessage.Parts);
|
||||
Assert.Equal(MessageRole.User, inputMessage.Role);
|
||||
Assert.Equal("Hello, world!", ((TextPart)inputMessage.Parts[0]).Text);
|
||||
|
||||
// Assert response from A2AClient is converted correctly
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(this._agent.Id, result.AgentId);
|
||||
Assert.Equal("response-123", result.ResponseId);
|
||||
|
||||
Assert.NotNull(result.RawRepresentation);
|
||||
Assert.IsType<Message>(result.RawRepresentation);
|
||||
Assert.Equal("response-123", ((Message)result.RawRepresentation).MessageId);
|
||||
|
||||
Assert.Single(result.Messages);
|
||||
Assert.Equal(ChatRole.Assistant, result.Messages[0].Role);
|
||||
Assert.Equal("Hello! How can I help you today?", result.Messages[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithNewThread_UpdatesThreadConversationIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._handler.ResponseToReturn = new Message
|
||||
{
|
||||
MessageId = "response-123",
|
||||
Role = MessageRole.Agent,
|
||||
Parts = new List<Part>
|
||||
{
|
||||
new TextPart { Text = "Response" }
|
||||
},
|
||||
ContextId = "new-context-id"
|
||||
};
|
||||
|
||||
var inputMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
|
||||
var thread = this._agent.GetNewThread();
|
||||
|
||||
// Act
|
||||
await this._agent.RunAsync(inputMessages, thread);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("new-context-id", thread.ConversationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithExistingThread_SetConversationIdToMessageAsync()
|
||||
{
|
||||
// Arrange
|
||||
var inputMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
|
||||
var thread = this._agent.GetNewThread();
|
||||
thread.ConversationId = "existing-context-id";
|
||||
|
||||
// Act
|
||||
await this._agent.RunAsync(inputMessages, thread);
|
||||
|
||||
// Assert
|
||||
var message = this._handler.CapturedMessageSendParams?.Message;
|
||||
Assert.NotNull(message);
|
||||
Assert.Equal("existing-context-id", message.ContextId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithThreadHavingDifferentContextId_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var inputMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
|
||||
this._handler.ResponseToReturn = new Message
|
||||
{
|
||||
MessageId = "response-123",
|
||||
Role = MessageRole.Agent,
|
||||
Parts = new List<Part>
|
||||
{
|
||||
new TextPart { Text = "Response" }
|
||||
},
|
||||
ContextId = "different-context"
|
||||
};
|
||||
|
||||
var thread = this._agent.GetNewThread();
|
||||
thread.ConversationId = "existing-context-id";
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => this._agent.RunAsync(inputMessages, thread));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithValidUserMessage_YieldsAgentRunResponseUpdatesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var inputMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Hello, streaming!")
|
||||
};
|
||||
|
||||
this._handler.StreamingResponseToReturn = new Message()
|
||||
{
|
||||
MessageId = "stream-1",
|
||||
Role = MessageRole.Agent,
|
||||
Parts = new List<Part> { new TextPart { Text = "Hello" } },
|
||||
ContextId = "stream-context"
|
||||
};
|
||||
|
||||
// Act
|
||||
var updates = new List<AgentRunResponseUpdate>();
|
||||
await foreach (var update in this._agent.RunStreamingAsync(inputMessages))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Single(updates);
|
||||
|
||||
// Assert input message sent to A2AClient
|
||||
var inputMessage = this._handler.CapturedMessageSendParams?.Message;
|
||||
Assert.NotNull(inputMessage);
|
||||
Assert.Single(inputMessage.Parts);
|
||||
Assert.Equal(MessageRole.User, inputMessage.Role);
|
||||
Assert.Equal("Hello, streaming!", ((TextPart)inputMessage.Parts[0]).Text);
|
||||
|
||||
// Assert response from A2AClient is converted correctly
|
||||
Assert.Equal(ChatRole.Assistant, updates[0].Role);
|
||||
Assert.Equal("Hello", updates[0].Text);
|
||||
Assert.Equal("stream-1", updates[0].MessageId);
|
||||
Assert.Equal(this._agent.Id, updates[0].AgentId);
|
||||
Assert.Equal("stream-1", updates[0].ResponseId);
|
||||
|
||||
Assert.NotNull(updates[0].RawRepresentation);
|
||||
Assert.IsType<Message>(updates[0].RawRepresentation);
|
||||
Assert.Equal("stream-1", ((Message)updates[0].RawRepresentation!).MessageId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithThread_UpdatesThreadConversationIdAsync()
|
||||
{
|
||||
// Arrange
|
||||
var inputMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test streaming")
|
||||
};
|
||||
|
||||
this._handler.StreamingResponseToReturn = new Message()
|
||||
{
|
||||
MessageId = "stream-1",
|
||||
Role = MessageRole.Agent,
|
||||
Parts = new List<Part> { new TextPart { Text = "Response" } },
|
||||
ContextId = "new-stream-context"
|
||||
};
|
||||
|
||||
var thread = this._agent.GetNewThread();
|
||||
|
||||
// Act
|
||||
await foreach (var update in this._agent.RunStreamingAsync(inputMessages, thread))
|
||||
{
|
||||
// Just iterate through to trigger the logic
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal("new-stream-context", thread.ConversationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithExistingThread_SetConversationIdToMessageAsync()
|
||||
{
|
||||
// Arrange
|
||||
var inputMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test streaming")
|
||||
};
|
||||
|
||||
this._handler.StreamingResponseToReturn = new Message();
|
||||
|
||||
var thread = this._agent.GetNewThread();
|
||||
thread.ConversationId = "existing-context-id";
|
||||
|
||||
// Act
|
||||
await foreach (var update in this._agent.RunStreamingAsync(inputMessages, thread))
|
||||
{
|
||||
// Just iterate through to trigger the logic
|
||||
}
|
||||
|
||||
// Assert
|
||||
var message = this._handler.CapturedMessageSendParams?.Message;
|
||||
Assert.NotNull(message);
|
||||
Assert.Equal("existing-context-id", message.ContextId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithThreadHavingDifferentContextId_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var thread = this._agent.GetNewThread();
|
||||
thread.ConversationId = "existing-context-id";
|
||||
|
||||
var inputMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test streaming")
|
||||
};
|
||||
|
||||
this._handler.StreamingResponseToReturn = new Message()
|
||||
{
|
||||
MessageId = "stream-1",
|
||||
Role = MessageRole.Agent,
|
||||
Parts = new List<Part> { new TextPart { Text = "Response" } },
|
||||
ContextId = "different-context"
|
||||
};
|
||||
|
||||
// Act
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
await foreach (var update in this._agent.RunStreamingAsync(inputMessages, thread))
|
||||
{
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_NonUserRoleMessages_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var inputMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.Assistant, "I am an assistant message")
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
{
|
||||
await foreach (var update in this._agent.RunStreamingAsync(inputMessages))
|
||||
{
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithHostedFileContent_ConvertsToFilePartAsync()
|
||||
{
|
||||
// Arrange
|
||||
var inputMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, new List<AIContent>
|
||||
{
|
||||
new TextContent("Check this file:"),
|
||||
new HostedFileContent("https://example.com/file.pdf")
|
||||
})
|
||||
};
|
||||
|
||||
// Act
|
||||
await this._agent.RunAsync(inputMessages);
|
||||
|
||||
// Assert
|
||||
var message = this._handler.CapturedMessageSendParams?.Message;
|
||||
Assert.NotNull(message);
|
||||
Assert.Equal(2, message.Parts.Count);
|
||||
Assert.IsType<TextPart>(message.Parts[0]);
|
||||
Assert.Equal("Check this file:", ((TextPart)message.Parts[0]).Text);
|
||||
Assert.IsType<FilePart>(message.Parts[1]);
|
||||
Assert.Equal("https://example.com/file.pdf", ((FileWithUri)((FilePart)message.Parts[1]).File).Uri);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._handler.Dispose();
|
||||
this._httpClient.Dispose();
|
||||
}
|
||||
internal sealed class A2AClientHttpMessageHandlerStub : HttpMessageHandler
|
||||
{
|
||||
public MessageSendParams? CapturedMessageSendParams { get; set; }
|
||||
|
||||
public A2AEvent? ResponseToReturn { get; set; }
|
||||
|
||||
public A2AEvent? StreamingResponseToReturn { get; set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Capture the request content
|
||||
#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods; overload doesn't exist on .NET …
|
||||
var content = await request.Content!.ReadAsStringAsync();
|
||||
#pragma warning restore CA2016
|
||||
|
||||
var jsonRpcRequest = JsonSerializer.Deserialize<JsonRpcRequest>(content)!;
|
||||
|
||||
this.CapturedMessageSendParams = jsonRpcRequest.Params?.Deserialize<MessageSendParams>();
|
||||
|
||||
// Return the pre-configured non-streaming response
|
||||
if (this.ResponseToReturn is not null)
|
||||
{
|
||||
var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse<A2AEvent>("response-id", this.ResponseToReturn);
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
// Return the pre-configured streaming response
|
||||
else if (this.StreamingResponseToReturn is not null)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
|
||||
await SseFormatter.WriteAsync(
|
||||
new SseItem<JsonRpcResponse>[]
|
||||
{
|
||||
new(JsonRpcResponse.CreateJsonRpcResponse<A2AEvent>("response-id", this.StreamingResponseToReturn!))
|
||||
}.ToAsyncEnumerable(),
|
||||
stream,
|
||||
(item, writer) =>
|
||||
{
|
||||
using Utf8JsonWriter json = new(writer, new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping });
|
||||
JsonSerializer.Serialize(json, item.Data);
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
stream.Position = 0;
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StreamContent(stream)
|
||||
{
|
||||
Headers = { { "Content-Type", "text/event-stream" } }
|
||||
}
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse<A2AEvent>("response-id", new Message());
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using A2A;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="A2ACardResolverExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class A2ACardResolverExtensionsTests : IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly HttpMessageHandlerStub _handler;
|
||||
private readonly A2ACardResolver _resolver;
|
||||
|
||||
public A2ACardResolverExtensionsTests()
|
||||
{
|
||||
this._handler = new HttpMessageHandlerStub();
|
||||
this._httpClient = new HttpClient(this._handler, false);
|
||||
this._resolver = new A2ACardResolver(new Uri("http://test-host"), httpClient: this._httpClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_WithValidAgentCard_ReturnsAIAgentAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._handler.ResponsesToReturn.Enqueue(new AgentCard
|
||||
{
|
||||
Name = "Test Agent",
|
||||
Description = "A test agent for unit testing",
|
||||
Url = "http://test-endpoint/agent"
|
||||
});
|
||||
|
||||
// Act
|
||||
var agent = await this._resolver.GetAIAgentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<A2AAgent>(agent);
|
||||
Assert.Equal("Test Agent", agent.Name);
|
||||
Assert.Equal("A test agent for unit testing", agent.Description);
|
||||
|
||||
// Verify that there was only one request made to retrieve the agent card
|
||||
Assert.Single(this._handler.CapturedUris);
|
||||
Assert.StartsWith("http://test-host/", this._handler.CapturedUris[0].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunIAgentAsync_WithUrlFromAgentCard_SendsRequestToTheUrlAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._handler.ResponsesToReturn.Enqueue(new AgentCard
|
||||
{
|
||||
Url = "http://test-endpoint/agent"
|
||||
});
|
||||
this._handler.ResponsesToReturn.Enqueue(new Message
|
||||
{
|
||||
Role = MessageRole.Agent,
|
||||
Parts = new List<Part> { new TextPart { Text = "Response" } },
|
||||
});
|
||||
|
||||
var agent = await this._resolver.GetAIAgentAsync(this._httpClient);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync("Test input");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, this._handler.CapturedUris.Count); // One for getting the card, one for sending the message to the agent
|
||||
Assert.Equal(new Uri("http://test-endpoint/agent"), this._handler.CapturedUris[1]);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._handler.Dispose();
|
||||
this._httpClient.Dispose();
|
||||
}
|
||||
|
||||
internal sealed class HttpMessageHandlerStub : HttpMessageHandler
|
||||
{
|
||||
public Queue ResponsesToReturn { get; } = new();
|
||||
|
||||
public List<Uri> CapturedUris { get; } = [];
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
this.CapturedUris.Add(request.RequestUri!);
|
||||
|
||||
var response = this.ResponsesToReturn.Dequeue();
|
||||
|
||||
if (response is AgentCard agentCard)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(agentCard);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
else if (response is Message message)
|
||||
{
|
||||
var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse<A2AEvent>("response-id", message);
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
// Return empty agent card if none specified
|
||||
var emptyCard = new AgentCard();
|
||||
var emptyJson = JsonSerializer.Serialize(emptyCard);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(emptyJson, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using A2A;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="A2AClientExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class A2AClientExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetAIAgent_WithAllParameters_ReturnsA2AAgentWithSpecifiedProperties()
|
||||
{
|
||||
// Arrange
|
||||
var a2aClient = new A2AClient(new Uri("http://test-endpoint"));
|
||||
|
||||
const string TestId = "test-agent-id";
|
||||
const string TestName = "Test Agent";
|
||||
const string TestDescription = "This is a test agent description";
|
||||
const string TestDisplayName = "Test Display Name";
|
||||
|
||||
// Act
|
||||
var agent = a2aClient.GetAIAgent(TestId, TestName, TestDescription, TestDisplayName);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.IsType<A2AAgent>(agent);
|
||||
Assert.Equal(TestId, agent.Id);
|
||||
Assert.Equal(TestName, agent.Name);
|
||||
Assert.Equal(TestDescription, agent.Description);
|
||||
Assert.Equal(TestDisplayName, agent.DisplayName);
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using A2A;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="A2AMessageExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class A2AMessageExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToChatMessage_WithMixedParts_ReturnsChatMessageWithMixedContents()
|
||||
{
|
||||
// Arrange
|
||||
var uri = "https://example.com/image.jpg";
|
||||
|
||||
var metadata = new Dictionary<string, JsonElement>
|
||||
{
|
||||
["isUrgent"] = JsonDocument.Parse("true").RootElement
|
||||
};
|
||||
|
||||
var message = new Message
|
||||
{
|
||||
MessageId = "mixed-parts-id",
|
||||
Role = MessageRole.Agent,
|
||||
Parts = new List<Part>
|
||||
{
|
||||
new TextPart { Text = "Here's an image:" },
|
||||
new FilePart { File = new FileWithUri { Uri = uri } },
|
||||
new TextPart { Text = "What do you think?" }
|
||||
},
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = message.ToChatMessage();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(ChatRole.Assistant, result.Role);
|
||||
Assert.Equal(message, result.RawRepresentation);
|
||||
|
||||
Assert.NotNull(result.Contents);
|
||||
Assert.Equal(3, result.Contents.Count);
|
||||
|
||||
var firstContent = Assert.IsType<TextContent>(result.Contents[0]);
|
||||
Assert.Equal("Here's an image:", firstContent.Text);
|
||||
|
||||
var fileContent = Assert.IsType<HostedFileContent>(result.Contents[1]);
|
||||
Assert.Equal(uri, fileContent.FileId);
|
||||
|
||||
var lastContent = Assert.IsType<TextContent>(result.Contents[2]);
|
||||
Assert.Equal("What do you think?", lastContent.Text);
|
||||
|
||||
Assert.NotNull(result.AdditionalProperties);
|
||||
Assert.Single(result.AdditionalProperties);
|
||||
|
||||
Assert.True(result.AdditionalProperties.ContainsKey("isUrgent"));
|
||||
Assert.True(((JsonElement)result.AdditionalProperties["isUrgent"]!).GetBoolean());
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="A2AMetadataExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class A2AMetadataExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToAdditionalProperties_WithNullMetadata_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, JsonElement>? metadata = null;
|
||||
|
||||
// Act
|
||||
var result = metadata.ToAdditionalProperties();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAdditionalProperties_WithEmptyMetadata_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var metadata = new Dictionary<string, JsonElement>();
|
||||
|
||||
// Act
|
||||
var result = metadata.ToAdditionalProperties();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAdditionalProperties_WithMultipleProperties_ReturnsAdditionalPropertiesDictionaryWithAllProperties()
|
||||
{
|
||||
// Arrange
|
||||
var metadata = new Dictionary<string, JsonElement>
|
||||
{
|
||||
{ "stringKey", JsonSerializer.SerializeToElement("stringValue") },
|
||||
{ "numberKey", JsonSerializer.SerializeToElement(42) },
|
||||
{ "booleanKey", JsonSerializer.SerializeToElement(true) }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = metadata.ToAdditionalProperties();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.Count);
|
||||
|
||||
Assert.True(result.ContainsKey("stringKey"));
|
||||
Assert.Equal("stringValue", ((JsonElement)result["stringKey"]!).GetString());
|
||||
|
||||
Assert.True(result.ContainsKey("numberKey"));
|
||||
Assert.Equal(42, ((JsonElement)result["numberKey"]!).GetInt32());
|
||||
|
||||
Assert.True(result.ContainsKey("booleanKey"));
|
||||
Assert.True(((JsonElement)result["booleanKey"]!).GetBoolean());
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using A2A;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="A2APartExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class A2APartExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToAIContent_WithTextPart_ReturnsTextContent()
|
||||
{
|
||||
// Arrange
|
||||
var textPart = new TextPart { Text = "Hello, world!" };
|
||||
|
||||
// Act
|
||||
var result = textPart.ToAIContent();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(textPart, result.RawRepresentation);
|
||||
|
||||
var textContent = Assert.IsType<TextContent>(result);
|
||||
Assert.Equal("Hello, world!", textContent.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAIContent_WithTextPartWithMetadata_ReturnsTextContentWithAdditionalProperties()
|
||||
{
|
||||
// Arrange
|
||||
var metadata = new Dictionary<string, JsonElement>
|
||||
{
|
||||
["key1"] = JsonDocument.Parse("\"value1\"").RootElement,
|
||||
["key2"] = JsonDocument.Parse("42").RootElement,
|
||||
["key3"] = JsonDocument.Parse("true").RootElement
|
||||
};
|
||||
var textPart = new TextPart
|
||||
{
|
||||
Text = "Hello with metadata!",
|
||||
Metadata = metadata
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = textPart.ToAIContent();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
var textContent = Assert.IsType<TextContent>(result);
|
||||
Assert.Equal("Hello with metadata!", textContent.Text);
|
||||
Assert.NotNull(textContent.AdditionalProperties);
|
||||
Assert.Equal(3, textContent.AdditionalProperties.Count);
|
||||
Assert.True(textContent.AdditionalProperties.ContainsKey("key1"));
|
||||
Assert.True(textContent.AdditionalProperties.ContainsKey("key2"));
|
||||
Assert.True(textContent.AdditionalProperties.ContainsKey("key3"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAIContent_WithFilePartWithFileWithUri_ReturnsHostedFileContent()
|
||||
{
|
||||
// Arrange
|
||||
var uri = "https://example.com/file.txt";
|
||||
var filePart = new FilePart { File = new FileWithUri { Uri = uri } };
|
||||
|
||||
// Act
|
||||
var result = filePart.ToAIContent();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(filePart, result.RawRepresentation);
|
||||
|
||||
var hostedFileContent = Assert.IsType<HostedFileContent>(result);
|
||||
Assert.Equal(uri, hostedFileContent.FileId);
|
||||
Assert.Null(hostedFileContent.AdditionalProperties);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAIContent_WithCustomPartType_ThrowsNotSupportedException()
|
||||
{
|
||||
// Arrange
|
||||
var customPart = new MockPart();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<NotSupportedException>(() => customPart.ToAIContent());
|
||||
Assert.Equal("Part type 'MockPart' is not supported.", exception.Message);
|
||||
}
|
||||
|
||||
// Mock class for testing unsupported scenarios
|
||||
private sealed class MockPart : Part
|
||||
{
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using A2A;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AIContentExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class AIContentExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToA2APart_WithTextContent_ReturnsTextPart()
|
||||
{
|
||||
// Arrange
|
||||
var textContent = new TextContent("Hello, world!");
|
||||
|
||||
// Act
|
||||
var result = textContent.ToA2APart();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
|
||||
var textPart = Assert.IsType<TextPart>(result);
|
||||
Assert.Equal("Hello, world!", textPart.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2APart_WithHostedFileContent_ReturnsFilePart()
|
||||
{
|
||||
// Arrange
|
||||
var uri = "https://example.com/file.txt";
|
||||
var hostedFileContent = new HostedFileContent(uri);
|
||||
|
||||
// Act
|
||||
var result = hostedFileContent.ToA2APart();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
|
||||
var filePart = Assert.IsType<FilePart>(result);
|
||||
Assert.NotNull(filePart.File);
|
||||
|
||||
var fileWithUri = Assert.IsType<FileWithUri>(filePart.File);
|
||||
Assert.Equal(uri, fileWithUri.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2APart_WithUnsupportedContentType_ThrowsNotSupportedException()
|
||||
{
|
||||
// Arrange
|
||||
var unsupportedContent = new MockAIContent();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<NotSupportedException>(() => unsupportedContent.ToA2APart());
|
||||
Assert.Equal("Unsupported content type: MockAIContent.", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AParts_WithEmptyCollection_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var emptyContents = new List<AIContent>();
|
||||
|
||||
// Act
|
||||
var result = emptyContents.ToA2AParts();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AParts_WithMultipleContents_ReturnsListWithAllParts()
|
||||
{
|
||||
// Arrange
|
||||
var contents = new List<AIContent>
|
||||
{
|
||||
new TextContent("First text"),
|
||||
new HostedFileContent("https://example.com/file1.txt"),
|
||||
new TextContent("Second text"),
|
||||
new HostedFileContent("https://example.com/file2.txt")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = contents.ToA2AParts();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(4, result.Count);
|
||||
|
||||
var firstTextPart = Assert.IsType<TextPart>(result[0]);
|
||||
Assert.Equal("First text", firstTextPart.Text);
|
||||
|
||||
var firstFilePart = Assert.IsType<FilePart>(result[1]);
|
||||
var firstFileWithUri = Assert.IsType<FileWithUri>(firstFilePart.File);
|
||||
Assert.Equal("https://example.com/file1.txt", firstFileWithUri.Uri);
|
||||
|
||||
var secondTextPart = Assert.IsType<TextPart>(result[2]);
|
||||
Assert.Equal("Second text", secondTextPart.Text);
|
||||
|
||||
var secondFilePart = Assert.IsType<FilePart>(result[3]);
|
||||
var secondFileWithUri = Assert.IsType<FileWithUri>(secondFilePart.File);
|
||||
Assert.Equal("https://example.com/file2.txt", secondFileWithUri.Uri);
|
||||
}
|
||||
|
||||
// Mock class for testing unsupported scenarios
|
||||
private sealed class MockAIContent : AIContent
|
||||
{
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using A2A;
|
||||
|
||||
namespace Microsoft.Extensions.AI.Agents.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="ChatMessageExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class ChatMessageExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToA2AMessage_WithMessageContainingMultipleContents_AddsAllContentsAsParts()
|
||||
{
|
||||
// Arrange
|
||||
var contents = new List<AIContent>
|
||||
{
|
||||
new HostedFileContent("https://example.com/report.pdf"),
|
||||
new TextContent("please summarize the file content"),
|
||||
new TextContent("and send it to me over email")
|
||||
};
|
||||
var chatMessage = new ChatMessage(ChatRole.User, contents);
|
||||
var messages = new List<ChatMessage> { chatMessage };
|
||||
|
||||
// Act
|
||||
var a2aMessage = messages.ToA2AMessage();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(a2aMessage);
|
||||
Assert.NotNull(a2aMessage.MessageId);
|
||||
Assert.NotEmpty(a2aMessage.MessageId);
|
||||
|
||||
Assert.Equal(MessageRole.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);
|
||||
var fileWithUri = Assert.IsType<FileWithUri>(filePart.File);
|
||||
Assert.Equal("https://example.com/report.pdf", fileWithUri.Uri);
|
||||
|
||||
var secondTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[1]);
|
||||
Assert.Equal("please summarize the file content", secondTextPart.Text);
|
||||
|
||||
var thirdTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[2]);
|
||||
Assert.Equal("and send it to me over email", thirdTextPart.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMessage_WithMixedMessages_AddsAllContentsAsParts()
|
||||
{
|
||||
// Arrange
|
||||
var firstMessage = new ChatMessage(ChatRole.User, [
|
||||
new HostedFileContent("https://example.com/report.pdf")
|
||||
]);
|
||||
var secondMessage = new ChatMessage(ChatRole.User, [
|
||||
new TextContent("please summarize the file content")
|
||||
]);
|
||||
var thirdMessage = new ChatMessage(ChatRole.User, [
|
||||
new TextContent("and send it to me over email")
|
||||
]);
|
||||
var messages = new List<ChatMessage> { firstMessage, secondMessage, thirdMessage };
|
||||
|
||||
// Act
|
||||
var a2aMessage = messages.ToA2AMessage();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(a2aMessage);
|
||||
Assert.NotNull(a2aMessage.MessageId);
|
||||
Assert.NotEmpty(a2aMessage.MessageId);
|
||||
|
||||
Assert.Equal(MessageRole.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);
|
||||
var fileWithUri = Assert.IsType<FileWithUri>(filePart.File);
|
||||
Assert.Equal("https://example.com/report.pdf", fileWithUri.Uri);
|
||||
|
||||
var secondTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[1]);
|
||||
Assert.Equal("please summarize the file content", secondTextPart.Text);
|
||||
|
||||
var thirdTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[2]);
|
||||
Assert.Equal("and send it to me over email", thirdTextPart.Text);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-preview.5.25277.114" />
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-preview.5.25277.114" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Extensions.AI.Agents.A2A\Microsoft.Extensions.AI.Agents.A2A.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+1
@@ -10,6 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.Json" />
|
||||
<PackageReference Include="System.Linq.Async" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+1
@@ -12,6 +12,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenTelemetry" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
|
||||
<PackageReference Include="System.Linq.Async" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user