From 1d2f833122c0f4fe6c062d35494476a8985c287c Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Mon, 1 Sep 2025 09:30:30 +0100 Subject: [PATCH] .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> --- dotnet/agent-framework-dotnet.slnx | 3 + .../AIAgent_With_A2A/AIAgent_With_A2A.csproj | 22 + .../AIAgent_With_A2A/Program.cs | 20 + .../AIAgent_With_A2A/README.md | 34 ++ .../HowToCreateAnAIAgentByProvider/README.md | 1 + .../A2AAgent.cs | 168 +++++++ .../A2AAgentLogMessages.cs | 37 ++ .../Extensions/A2ACardResolverExtensions.cs | 52 ++ .../Extensions/A2AClientExtensions.cs | 42 ++ .../Extensions/A2AMessageExtensions.cs | 28 ++ .../Extensions/A2AMetadataExtensions.cs | 32 ++ .../Extensions/A2APartExtensions.cs | 35 ++ .../Extensions/AIContentExtensions.cs | 45 ++ .../Extensions/ChatMessageExtensions.cs | 33 ++ .../Microsoft.Extensions.AI.Agents.A2A.csproj | 35 ++ .../AgentConformance.IntegrationTests.csproj | 1 + dotnet/tests/Directory.Build.props | 1 - ...soft.Agents.Orchestration.UnitTests.csproj | 1 + ...icrosoft.Agents.Workflows.UnitTests.csproj | 1 + .../A2AAgentTests.cs | 459 ++++++++++++++++++ .../A2ACardResolverExtensionsTests.cs | 126 +++++ .../Extensions/A2AClientExtensionsTests.cs | 35 ++ .../Extensions/A2AMessageExtensionsTests.cs | 64 +++ .../Extensions/A2AMetadataExtensionsTests.cs | 66 +++ .../Extensions/A2APartExtensionsTests.cs | 96 ++++ .../Extensions/AIContentExtensionsTests.cs | 112 +++++ .../Extensions/ChatMessageExtensionsTests.cs | 90 ++++ ....Extensions.AI.Agents.A2A.UnitTests.csproj | 16 + ...ns.AI.Agents.Abstractions.UnitTests.csproj | 1 + ...soft.Extensions.AI.Agents.UnitTests.csproj | 1 + 30 files changed, 1656 insertions(+), 1 deletion(-) create mode 100644 dotnet/samples/HowToCreateAnAIAgentByProvider/AIAgent_With_A2A/AIAgent_With_A2A.csproj create mode 100644 dotnet/samples/HowToCreateAnAIAgentByProvider/AIAgent_With_A2A/Program.cs create mode 100644 dotnet/samples/HowToCreateAnAIAgentByProvider/AIAgent_With_A2A/README.md create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgent.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgentLogMessages.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/A2ACardResolverExtensions.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/A2AClientExtensions.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/A2AMessageExtensions.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/A2AMetadataExtensions.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/A2APartExtensions.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/AIContentExtensions.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/ChatMessageExtensions.cs create mode 100644 dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Microsoft.Extensions.AI.Agents.A2A.csproj create mode 100644 dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/A2AAgentTests.cs create mode 100644 dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/A2AClientExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/A2AMessageExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/A2AMetadataExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/A2APartExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/AIContentExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/ChatMessageExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Microsoft.Extensions.AI.Agents.A2A.UnitTests.csproj diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index d14fe62675..c1dd73d982 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -52,6 +52,7 @@ + @@ -161,6 +162,7 @@ + @@ -189,6 +191,7 @@ + diff --git a/dotnet/samples/HowToCreateAnAIAgentByProvider/AIAgent_With_A2A/AIAgent_With_A2A.csproj b/dotnet/samples/HowToCreateAnAIAgentByProvider/AIAgent_With_A2A/AIAgent_With_A2A.csproj new file mode 100644 index 0000000000..d6c65c58dd --- /dev/null +++ b/dotnet/samples/HowToCreateAnAIAgentByProvider/AIAgent_With_A2A/AIAgent_With_A2A.csproj @@ -0,0 +1,22 @@ + + + + Exe + net9.0 + 12 + + enable + disable + + + + + + + + + + + + + diff --git a/dotnet/samples/HowToCreateAnAIAgentByProvider/AIAgent_With_A2A/Program.cs b/dotnet/samples/HowToCreateAnAIAgentByProvider/AIAgent_With_A2A/Program.cs new file mode 100644 index 0000000000..726ee2cf58 --- /dev/null +++ b/dotnet/samples/HowToCreateAnAIAgentByProvider/AIAgent_With_A2A/Program.cs @@ -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); diff --git a/dotnet/samples/HowToCreateAnAIAgentByProvider/AIAgent_With_A2A/README.md b/dotnet/samples/HowToCreateAnAIAgentByProvider/AIAgent_With_A2A/README.md new file mode 100644 index 0000000000..7100a2d1e1 --- /dev/null +++ b/dotnet/samples/HowToCreateAnAIAgentByProvider/AIAgent_With_A2A/README.md @@ -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); +``` \ No newline at end of file diff --git a/dotnet/samples/HowToCreateAnAIAgentByProvider/README.md b/dotnet/samples/HowToCreateAnAIAgentByProvider/README.md index 7c10bb1cd5..1c2d2ba996 100644 --- a/dotnet/samples/HowToCreateAnAIAgentByProvider/README.md +++ b/dotnet/samples/HowToCreateAnAIAgentByProvider/README.md @@ -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| diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgent.cs new file mode 100644 index 0000000000..fd24832755 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgent.cs @@ -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; + +/// +/// Represents an that can interact with remote agents that are exposed via the A2A protocol +/// +/// +/// 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. +/// +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; + + /// + /// Initializes a new instance of the class. + /// + /// The A2A client to use for interacting with A2A agents. + /// The unique identifier for the agent. + /// The the name of the agent. + /// The description of the agent. + /// The display name of the agent. + /// Optional logger factory to use for logging. + 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(); + } + + /// + public override async Task RunAsync(IReadOnlyCollection 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"}"); + } + + /// + public override async IAsyncEnumerable RunStreamingAsync(IReadOnlyCollection 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(), + }; + } + } + + /// + public override string Id => this._id ?? base.Id; + + /// + public override string? Name => this._name ?? base.Name; + + /// + public override string DisplayName => this._displayName ?? base.DisplayName; + + /// + public override string? Description => this._description ?? base.Description; + + private static void ValidateInputMessages(IReadOnlyCollection 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; + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgentLogMessages.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgentLogMessages.cs new file mode 100644 index 0000000000..c8f7934d3f --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgentLogMessages.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.AI.Agents.A2A; + +/// +/// Extensions for logging invocations. +/// +[ExcludeFromCodeCoverage] +internal static partial class A2AAgentLogMessages +{ + /// + /// Logs invoking agent (started). + /// + [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); + + /// + /// Logs invoked agent (complete). + /// + [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); +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/A2ACardResolverExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/A2ACardResolverExtensions.cs new file mode 100644 index 0000000000..b326d0f1ef --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/A2ACardResolverExtensions.cs @@ -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; + +/// +/// Provides extension methods for +/// to simplify the creation of A2A agents. +/// +/// +/// These extensions bridge the gap between A2A SDK client objects +/// and the Microsoft Extensions AI Agent framework. +/// +/// They allow developers to easily create AI agents that can interact +/// with A2A agents by handling the conversion from A2A clients to +/// instances that implement the interface. +/// +/// +public static class A2ACardResolverExtensions +{ + /// + /// Retrieves an instance of for an existing A2A agent. + /// + /// + /// This method can be used to create AI agents for A2A agents whose hosts support one of the A2A discovery mechanisms: + /// + /// Well-Known URI + /// Curated Registries (Catalog-Based Discovery) + /// + /// + /// The to use for the agent creation. + /// The to use for HTTP requests. + /// The logger factory for enabling logging within the agent. + /// The to use when retrieving the agent card. + /// An instance backed by the A2A agent. + public static async Task 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); + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/A2AClientExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/A2AClientExtensions.cs new file mode 100644 index 0000000000..730d1626b7 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/A2AClientExtensions.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using A2A; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.AI.Agents.A2A; + +/// +/// Provides extension methods for +/// to simplify the creation of A2A agents. +/// +/// +/// These extensions bridge the gap between A2A SDK client objects +/// and the Microsoft Extensions AI Agent framework. +/// +/// They allow developers to easily create AI agents that can interact +/// with A2A agents by handling the conversion from A2A clients to +/// instances that implement the interface. +/// +/// +public static class A2AClientExtensions +{ + /// + /// Retrieves an instance of for an existing A2A agent. + /// + /// + /// This method can be used to create AI agents for A2A agents whose hosts support the + /// Direct Configuration / Private Discovery + /// discovery mechanism. + /// + /// The to use for the agent. + /// The unique identifier for the agent. + /// The the name of the agent. + /// The description of the agent. + /// The display name of the agent. + /// Optional logger factory for enabling logging within the agent. + /// An instance backed by the A2A agent. + 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); + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/A2AMessageExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/A2AMessageExtensions.cs new file mode 100644 index 0000000000..b7acca952d --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/A2AMessageExtensions.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using A2A; + +namespace Microsoft.Extensions.AI.Agents.A2A; + +/// +/// Extension methods for the class. +/// +internal static class A2AMessageExtensions +{ + public static ChatMessage ToChatMessage(this Message message) + { + List? aiContents = null; + + foreach (var part in message.Parts) + { + (aiContents ??= []).Add(part.ToAIContent()); + } + + return new ChatMessage(ChatRole.Assistant, aiContents) + { + AdditionalProperties = message.Metadata.ToAdditionalProperties(), + RawRepresentation = message, + }; + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/A2AMetadataExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/A2AMetadataExtensions.cs new file mode 100644 index 0000000000..c41f5f6b53 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/A2AMetadataExtensions.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; + +namespace Microsoft.Extensions.AI.Agents.A2A; + +/// +/// Extension methods for A2A metadata dictionary. +/// +internal static class A2AMetadataExtensions +{ + /// + /// Converts a dictionary of metadata to an . + /// + /// The metadata dictionary to convert. + /// The converted , or null if the input is null or empty. + public static AdditionalPropertiesDictionary? ToAdditionalProperties(this Dictionary? 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; + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/A2APartExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/A2APartExtensions.cs new file mode 100644 index 0000000000..d3b1ef9f83 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/A2APartExtensions.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using A2A; + +namespace Microsoft.Extensions.AI.Agents.A2A; + +/// +/// Extension methods for the class. +/// +internal static class A2APartExtensions +{ + /// + /// Converts an A2A to an . + /// + /// The A2A part to convert. + /// The corresponding . + 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.") + }; + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/AIContentExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/AIContentExtensions.cs new file mode 100644 index 0000000000..ca4442ba66 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/AIContentExtensions.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using A2A; + +namespace Microsoft.Extensions.AI.Agents.A2A; + +/// +/// Extension methods for the class. +/// +internal static class AIContentExtensions +{ + /// + /// Converts a collection of to a list of objects. + /// + /// The collection of AI contents to convert." + /// The list of A2A objects. + public static List? ToA2AParts(this IEnumerable contents) + { + List? parts = null; + + foreach (var content in contents) + { + (parts ??= []).Add(content.ToA2APart()); + } + + return parts; + } + + /// + /// Converts a to a object."/> + /// + /// AI content to convert. + /// The corresponding A2A object. + 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}."), + }; + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/ChatMessageExtensions.cs new file mode 100644 index 0000000000..930b8c4295 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Extensions/ChatMessageExtensions.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using A2A; + +namespace Microsoft.Extensions.AI.Agents.A2A; + +/// +/// Extension methods for the class. +/// +internal static class ChatMessageExtensions +{ + public static Message ToA2AMessage(this IReadOnlyCollection messages) + { + List 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, + }; + } +} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Microsoft.Extensions.AI.Agents.A2A.csproj b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Microsoft.Extensions.AI.Agents.A2A.csproj new file mode 100644 index 0000000000..c1a191e495 --- /dev/null +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.A2A/Microsoft.Extensions.AI.Agents.A2A.csproj @@ -0,0 +1,35 @@ + + + + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) + alpha + + + + + + true + + + + + + + + + + + Microsoft.Extensions.AI.Agents.A2A + Defines AIAgent for interacting with application-to-application (A2A) agents. + + + + + + + + + + + diff --git a/dotnet/tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj b/dotnet/tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj index 6a5ea379bf..5512f41645 100644 --- a/dotnet/tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj +++ b/dotnet/tests/AgentConformance.IntegrationTests/AgentConformance.IntegrationTests.csproj @@ -11,6 +11,7 @@ + diff --git a/dotnet/tests/Directory.Build.props b/dotnet/tests/Directory.Build.props index 9fb9d9b797..6c5a318e86 100644 --- a/dotnet/tests/Directory.Build.props +++ b/dotnet/tests/Directory.Build.props @@ -18,7 +18,6 @@ - diff --git a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/Microsoft.Agents.Orchestration.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/Microsoft.Agents.Orchestration.UnitTests.csproj index db0653bade..cd25d6c76f 100644 --- a/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/Microsoft.Agents.Orchestration.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.Orchestration.UnitTests/Microsoft.Agents.Orchestration.UnitTests.csproj @@ -12,6 +12,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Microsoft.Agents.Workflows.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Microsoft.Agents.Workflows.UnitTests.csproj index 006de84807..af316019ad 100644 --- a/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Microsoft.Agents.Workflows.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.Workflows.UnitTests/Microsoft.Agents.Workflows.UnitTests.csproj @@ -12,6 +12,7 @@ + diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/A2AAgentTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/A2AAgentTests.cs new file mode 100644 index 0000000000..bc8f1a3938 --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/A2AAgentTests.cs @@ -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; + +/// +/// Unit tests for the class. +/// +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(() => 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 + { + new(ChatRole.Assistant, "I am an assistant message"), + new(ChatRole.User, "Valid user message") + }; + + // Act & Assert + await Assert.ThrowsAsync(() => 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 + { + new TextPart { Text = "Hello! How can I help you today?" } + } + }; + + var inputMessages = new List + { + 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(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 + { + new TextPart { Text = "Response" } + }, + ContextId = "new-context-id" + }; + + var inputMessages = new List + { + 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 + { + 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 + { + new(ChatRole.User, "Test message") + }; + + this._handler.ResponseToReturn = new Message + { + MessageId = "response-123", + Role = MessageRole.Agent, + Parts = new List + { + new TextPart { Text = "Response" } + }, + ContextId = "different-context" + }; + + var thread = this._agent.GetNewThread(); + thread.ConversationId = "existing-context-id"; + + // Act & Assert + await Assert.ThrowsAsync(() => this._agent.RunAsync(inputMessages, thread)); + } + + [Fact] + public async Task RunStreamingAsync_WithValidUserMessage_YieldsAgentRunResponseUpdatesAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Hello, streaming!") + }; + + this._handler.StreamingResponseToReturn = new Message() + { + MessageId = "stream-1", + Role = MessageRole.Agent, + Parts = new List { new TextPart { Text = "Hello" } }, + ContextId = "stream-context" + }; + + // Act + var updates = new List(); + 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(updates[0].RawRepresentation); + Assert.Equal("stream-1", ((Message)updates[0].RawRepresentation!).MessageId); + } + + [Fact] + public async Task RunStreamingAsync_WithThread_UpdatesThreadConversationIdAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Test streaming") + }; + + this._handler.StreamingResponseToReturn = new Message() + { + MessageId = "stream-1", + Role = MessageRole.Agent, + Parts = new List { 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 + { + 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 + { + new(ChatRole.User, "Test streaming") + }; + + this._handler.StreamingResponseToReturn = new Message() + { + MessageId = "stream-1", + Role = MessageRole.Agent, + Parts = new List { new TextPart { Text = "Response" } }, + ContextId = "different-context" + }; + + // Act + await Assert.ThrowsAsync(async () => + { + await foreach (var update in this._agent.RunStreamingAsync(inputMessages, thread)) + { + } + }); + } + + [Fact] + public async Task RunStreamingAsync_NonUserRoleMessages_ThrowsArgumentExceptionAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.Assistant, "I am an assistant message") + }; + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var update in this._agent.RunStreamingAsync(inputMessages)) + { + } + }); + } + + [Fact] + public async Task RunAsync_WithHostedFileContent_ConvertsToFilePartAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, new List + { + 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(message.Parts[0]); + Assert.Equal("Check this file:", ((TextPart)message.Parts[0]).Text); + Assert.IsType(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 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(content)!; + + this.CapturedMessageSendParams = jsonRpcRequest.Params?.Deserialize(); + + // Return the pre-configured non-streaming response + if (this.ResponseToReturn is not null) + { + var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("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[] + { + new(JsonRpcResponse.CreateJsonRpcResponse("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("response-id", new Message()); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json") + }; + } + } + } +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs new file mode 100644 index 0000000000..11189a53a8 --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs @@ -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; + +/// +/// Unit tests for the class. +/// +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(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 { 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 CapturedUris { get; } = []; + + protected override async Task 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("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") + }; + } + } +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/A2AClientExtensionsTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/A2AClientExtensionsTests.cs new file mode 100644 index 0000000000..ddd0701463 --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/A2AClientExtensionsTests.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using A2A; + +namespace Microsoft.Extensions.AI.Agents.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +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(agent); + Assert.Equal(TestId, agent.Id); + Assert.Equal(TestName, agent.Name); + Assert.Equal(TestDescription, agent.Description); + Assert.Equal(TestDisplayName, agent.DisplayName); + } +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/A2AMessageExtensionsTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/A2AMessageExtensionsTests.cs new file mode 100644 index 0000000000..7c5c7a9852 --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/A2AMessageExtensionsTests.cs @@ -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; + +/// +/// Unit tests for the class. +/// +public sealed class A2AMessageExtensionsTests +{ + [Fact] + public void ToChatMessage_WithMixedParts_ReturnsChatMessageWithMixedContents() + { + // Arrange + var uri = "https://example.com/image.jpg"; + + var metadata = new Dictionary + { + ["isUrgent"] = JsonDocument.Parse("true").RootElement + }; + + var message = new Message + { + MessageId = "mixed-parts-id", + Role = MessageRole.Agent, + Parts = new List + { + 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(result.Contents[0]); + Assert.Equal("Here's an image:", firstContent.Text); + + var fileContent = Assert.IsType(result.Contents[1]); + Assert.Equal(uri, fileContent.FileId); + + var lastContent = Assert.IsType(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()); + } +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/A2AMetadataExtensionsTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/A2AMetadataExtensionsTests.cs new file mode 100644 index 0000000000..da4fd6090b --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/A2AMetadataExtensionsTests.cs @@ -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; + +/// +/// Unit tests for the class. +/// +public sealed class A2AMetadataExtensionsTests +{ + [Fact] + public void ToAdditionalProperties_WithNullMetadata_ReturnsNull() + { + // Arrange + Dictionary? metadata = null; + + // Act + var result = metadata.ToAdditionalProperties(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ToAdditionalProperties_WithEmptyMetadata_ReturnsNull() + { + // Arrange + var metadata = new Dictionary(); + + // Act + var result = metadata.ToAdditionalProperties(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ToAdditionalProperties_WithMultipleProperties_ReturnsAdditionalPropertiesDictionaryWithAllProperties() + { + // Arrange + var metadata = new Dictionary + { + { "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()); + } +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/A2APartExtensionsTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/A2APartExtensionsTests.cs new file mode 100644 index 0000000000..88b8da93db --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/A2APartExtensionsTests.cs @@ -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; + +/// +/// Unit tests for the class. +/// +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(result); + Assert.Equal("Hello, world!", textContent.Text); + } + + [Fact] + public void ToAIContent_WithTextPartWithMetadata_ReturnsTextContentWithAdditionalProperties() + { + // Arrange + var metadata = new Dictionary + { + ["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(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(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(() => customPart.ToAIContent()); + Assert.Equal("Part type 'MockPart' is not supported.", exception.Message); + } + + // Mock class for testing unsupported scenarios + private sealed class MockPart : Part + { + } +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/AIContentExtensionsTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/AIContentExtensionsTests.cs new file mode 100644 index 0000000000..cf07dcffbf --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/AIContentExtensionsTests.cs @@ -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; + +/// +/// Unit tests for the class. +/// +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(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(result); + Assert.NotNull(filePart.File); + + var fileWithUri = Assert.IsType(filePart.File); + Assert.Equal(uri, fileWithUri.Uri); + } + + [Fact] + public void ToA2APart_WithUnsupportedContentType_ThrowsNotSupportedException() + { + // Arrange + var unsupportedContent = new MockAIContent(); + + // Act & Assert + var exception = Assert.Throws(() => unsupportedContent.ToA2APart()); + Assert.Equal("Unsupported content type: MockAIContent.", exception.Message); + } + + [Fact] + public void ToA2AParts_WithEmptyCollection_ReturnsNull() + { + // Arrange + var emptyContents = new List(); + + // Act + var result = emptyContents.ToA2AParts(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ToA2AParts_WithMultipleContents_ReturnsListWithAllParts() + { + // Arrange + var contents = new List + { + 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(result[0]); + Assert.Equal("First text", firstTextPart.Text); + + var firstFilePart = Assert.IsType(result[1]); + var firstFileWithUri = Assert.IsType(firstFilePart.File); + Assert.Equal("https://example.com/file1.txt", firstFileWithUri.Uri); + + var secondTextPart = Assert.IsType(result[2]); + Assert.Equal("Second text", secondTextPart.Text); + + var secondFilePart = Assert.IsType(result[3]); + var secondFileWithUri = Assert.IsType(secondFilePart.File); + Assert.Equal("https://example.com/file2.txt", secondFileWithUri.Uri); + } + + // Mock class for testing unsupported scenarios + private sealed class MockAIContent : AIContent + { + } +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/ChatMessageExtensionsTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/ChatMessageExtensionsTests.cs new file mode 100644 index 0000000000..d8a102e805 --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Extensions/ChatMessageExtensionsTests.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using A2A; + +namespace Microsoft.Extensions.AI.Agents.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class ChatMessageExtensionsTests +{ + [Fact] + public void ToA2AMessage_WithMessageContainingMultipleContents_AddsAllContentsAsParts() + { + // Arrange + var contents = new List + { + 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 }; + + // 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(a2aMessage.Parts[0]); + Assert.NotNull(filePart.File); + var fileWithUri = Assert.IsType(filePart.File); + Assert.Equal("https://example.com/report.pdf", fileWithUri.Uri); + + var secondTextPart = Assert.IsType(a2aMessage.Parts[1]); + Assert.Equal("please summarize the file content", secondTextPart.Text); + + var thirdTextPart = Assert.IsType(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 { 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(a2aMessage.Parts[0]); + Assert.NotNull(filePart.File); + var fileWithUri = Assert.IsType(filePart.File); + Assert.Equal("https://example.com/report.pdf", fileWithUri.Uri); + + var secondTextPart = Assert.IsType(a2aMessage.Parts[1]); + Assert.Equal("please summarize the file content", secondTextPart.Text); + + var thirdTextPart = Assert.IsType(a2aMessage.Parts[2]); + Assert.Equal("and send it to me over email", thirdTextPart.Text); + } +} diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Microsoft.Extensions.AI.Agents.A2A.UnitTests.csproj b/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Microsoft.Extensions.AI.Agents.A2A.UnitTests.csproj new file mode 100644 index 0000000000..637cf0a5f7 --- /dev/null +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.A2A.UnitTests/Microsoft.Extensions.AI.Agents.A2A.UnitTests.csproj @@ -0,0 +1,16 @@ + + + + $(ProjectsTargetFrameworks) + + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests.csproj b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests.csproj index 5a3a433f20..38ae8712cb 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests.csproj @@ -10,6 +10,7 @@ + diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/Microsoft.Extensions.AI.Agents.UnitTests.csproj b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/Microsoft.Extensions.AI.Agents.UnitTests.csproj index 024a90f81b..9baeb43714 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/Microsoft.Extensions.AI.Agents.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/Microsoft.Extensions.AI.Agents.UnitTests.csproj @@ -12,6 +12,7 @@ +