From 053414174374b06fe2d9085903deb7f98bacf80e Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Tue, 24 Mar 2026 23:22:01 +0000 Subject: [PATCH 01/11] migrate to the latest a2a sdk v1 --- dotnet/Directory.Packages.props | 4 +- .../A2AAgent_PollingForTaskCompletion.csproj | 3 +- .../A2AClientServer/A2AClient/Program.cs | 10 +- .../A2AServer/HostAgentFactory.cs | 35 +- .../A2AClientServer/A2AServer/Program.cs | 24 +- .../AgentWebChat.Web/A2AAgentClient.cs | 12 +- .../src/Microsoft.Agents.AI.A2A/A2AAgent.cs | 209 +++--- .../Extensions/A2AAgentCardExtensions.cs | 22 +- .../Extensions/ChatMessageExtensions.cs | 6 +- .../Microsoft.Agents.AI.A2A.csproj | 1 + .../EndpointRouteBuilderExtensions.cs | 257 ++----- .../A2ARunDecisionContext.cs | 8 +- .../AIAgentExtensions.cs | 438 ++++++------ .../AgentRunMode.cs | 8 +- .../Converters/MessageConverter.cs | 15 +- .../A2AAgentTests.cs | 627 +++++++++++++----- .../Extensions/A2AAIContentExtensionsTests.cs | 24 +- .../Extensions/A2AAgentCardExtensionsTests.cs | 56 +- .../Extensions/A2AAgentTaskExtensionsTests.cs | 20 +- .../Extensions/A2AArtifactExtensionsTests.cs | 12 +- .../A2ACardResolverExtensionsTests.cs | 21 +- .../Extensions/ChatMessageExtensionsTests.cs | 30 +- .../Microsoft.Agents.AI.A2A.UnitTests.csproj | 4 + .../A2AIntegrationTests.cs | 35 +- .../AIAgentExtensionsTests.cs | 480 +++++++------- .../Converters/MessageConverterTests.cs | 34 +- .../EndpointRouteA2ABuilderExtensionsTests.cs | 120 +--- 27 files changed, 1357 insertions(+), 1158 deletions(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index fa54be567c..1678e4b2ae 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -101,8 +101,8 @@ - - + + diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj b/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj index 1bccc99d4f..d91b20e34b 100644 --- a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj +++ b/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj @@ -2,7 +2,7 @@ Exe - net10.0 + net10.0 enable enable @@ -13,7 +13,6 @@ - diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/Program.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/Program.cs index 0b9696e3a1..32cb743de9 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/Program.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/Program.cs @@ -62,12 +62,10 @@ private static async Task HandleCommandsAsync(CancellationToken cancellationToke } var agentResponse = await hostAgent.Agent!.RunAsync(message, session, cancellationToken: cancellationToken); - foreach (var chatMessage in agentResponse.Messages) - { - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine($"\nAgent: {chatMessage.Text}"); - Console.ResetColor(); - } + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine($"\nAgent: {agentResponse.Text}"); + Console.ResetColor(); } } catch (Exception ex) diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs index 584b7db422..f3517e5d62 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs @@ -12,7 +12,7 @@ namespace A2AServer; internal static class HostAgentFactory { - internal static async Task<(AIAgent, AgentCard)> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string agentName, IList? tools = null) + internal static async Task<(AIAgent, AgentCard)> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string agentName, string[] agentUrls, IList? tools = null) { // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid @@ -24,16 +24,16 @@ internal static class HostAgentFactory AgentCard agentCard = agentType.ToUpperInvariant() switch { - "INVOICE" => GetInvoiceAgentCard(), - "POLICY" => GetPolicyAgentCard(), - "LOGISTICS" => GetLogisticsAgentCard(), + "INVOICE" => GetInvoiceAgentCard(agentUrls), + "POLICY" => GetPolicyAgentCard(agentUrls), + "LOGISTICS" => GetLogisticsAgentCard(agentUrls), _ => throw new ArgumentException($"Unsupported agent type: {agentType}"), }; return new(agent, agentCard); } - internal static async Task<(AIAgent, AgentCard)> CreateChatCompletionHostAgentAsync(string agentType, string model, string apiKey, string name, string instructions, IList? tools = null) + internal static async Task<(AIAgent, AgentCard)> CreateChatCompletionHostAgentAsync(string agentType, string model, string apiKey, string name, string instructions, string[] agentUrls, IList? tools = null) { AIAgent agent = new OpenAIClient(apiKey) .GetChatClient(model) @@ -41,9 +41,9 @@ internal static class HostAgentFactory AgentCard agentCard = agentType.ToUpperInvariant() switch { - "INVOICE" => GetInvoiceAgentCard(), - "POLICY" => GetPolicyAgentCard(), - "LOGISTICS" => GetLogisticsAgentCard(), + "INVOICE" => GetInvoiceAgentCard(agentUrls), + "POLICY" => GetPolicyAgentCard(agentUrls), + "LOGISTICS" => GetLogisticsAgentCard(agentUrls), _ => throw new ArgumentException($"Unsupported agent type: {agentType}"), }; @@ -51,7 +51,7 @@ internal static class HostAgentFactory } #region private - private static AgentCard GetInvoiceAgentCard() + private static AgentCard GetInvoiceAgentCard(string[] agentUrls) { var capabilities = new AgentCapabilities() { @@ -80,10 +80,11 @@ private static AgentCard GetInvoiceAgentCard() DefaultOutputModes = ["text"], Capabilities = capabilities, Skills = [invoiceQuery], + SupportedInterfaces = CreateAgentInterfaces(agentUrls) }; } - private static AgentCard GetPolicyAgentCard() + private static AgentCard GetPolicyAgentCard(string[] agentUrls) { var capabilities = new AgentCapabilities() { @@ -112,10 +113,11 @@ private static AgentCard GetPolicyAgentCard() DefaultOutputModes = ["text"], Capabilities = capabilities, Skills = [policyQuery], + SupportedInterfaces = CreateAgentInterfaces(agentUrls) }; } - private static AgentCard GetLogisticsAgentCard() + private static AgentCard GetLogisticsAgentCard(string[] agentUrls) { var capabilities = new AgentCapabilities() { @@ -144,7 +146,18 @@ private static AgentCard GetLogisticsAgentCard() DefaultOutputModes = ["text"], Capabilities = capabilities, Skills = [logisticsQuery], + SupportedInterfaces = CreateAgentInterfaces(agentUrls) }; } + + private static List CreateAgentInterfaces(string[] agentUrls) + { + return agentUrls.Select(url => new AgentInterface + { + Url = url, + ProtocolBinding = "JSONRPC", + ProtocolVersion = "1.0", + }).ToList(); + } #endregion } diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs index f1c0b966fe..dc57825e6c 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using A2A; using A2A.AspNetCore; using A2AServer; @@ -38,14 +38,15 @@ string? apiKey = configuration["OPENAI_API_KEY"]; string model = configuration["OPENAI_CHAT_MODEL_NAME"] ?? "gpt-4o-mini"; string? endpoint = configuration["AZURE_AI_PROJECT_ENDPOINT"]; +string[] agentUrls = (app.Configuration["urls"] ?? "http://localhost:5000").Split(';'); var invoiceQueryPlugin = new InvoiceQuery(); IList tools = - [ +[ AIFunctionFactory.Create(invoiceQueryPlugin.QueryInvoices), AIFunctionFactory.Create(invoiceQueryPlugin.QueryByTransactionId), AIFunctionFactory.Create(invoiceQueryPlugin.QueryByInvoiceId) - ]; +]; AIAgent hostA2AAgent; AgentCard hostA2AAgentCard; @@ -54,9 +55,9 @@ { (hostA2AAgent, hostA2AAgentCard) = agentType.ToUpperInvariant() switch { - "INVOICE" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, tools), - "POLICY" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName), - "LOGISTICS" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName), + "INVOICE" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls, tools), + "POLICY" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls), + "LOGISTICS" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls), _ => throw new ArgumentException($"Unsupported agent type: {agentType}"), }; } @@ -68,7 +69,7 @@ agentType, model, apiKey, "InvoiceAgent", """ You specialize in handling queries related to invoices. - """, tools), + """, agentUrls, tools), "POLICY" => await HostAgentFactory.CreateChatCompletionHostAgentAsync( agentType, model, apiKey, "PolicyAgent", """ @@ -84,7 +85,7 @@ You specialize in handling queries related to policies and customer communicatio resolution in SAP CRM and notify the customer via email within 2 business days, referencing the original invoice and the credit memo number. Use the 'Formal Credit Notification' email template." - """), + """, agentUrls), "LOGISTICS" => await HostAgentFactory.CreateChatCompletionHostAgentAsync( agentType, model, apiKey, "LogisticsAgent", """ @@ -95,7 +96,7 @@ You specialize in handling queries related to logistics. Shipment number: SHPMT-SAP-001 Item: TSHIRT-RED-L Quantity: 900 - """), + """, agentUrls), _ => throw new ArgumentException($"Unsupported agent type: {agentType}"), }; } @@ -104,10 +105,9 @@ You specialize in handling queries related to logistics. throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided"); } -var a2aTaskManager = app.MapA2A( +app.MapA2A( hostA2AAgent, path: "/", - agentCard: hostA2AAgentCard, - taskManager => app.MapWellKnownAgentCard(taskManager, "/")); + agentCard: hostA2AAgentCard); await app.RunAsync(); diff --git a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs index f790ec0daa..d2c67d0ca5 100644 --- a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs +++ b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs @@ -43,20 +43,21 @@ public override async IAsyncEnumerable RunStreamingAsync( { // Convert all messages to A2A parts and create a single message var parts = messages.ToParts(); - var a2aMessage = new AgentMessage + var a2aMessage = new Message { MessageId = Guid.NewGuid().ToString("N"), ContextId = contextId, - Role = MessageRole.User, + Role = Role.User, Parts = parts }; - var messageSendParams = new MessageSendParams { Message = a2aMessage }; + var messageSendParams = new SendMessageRequest { Message = a2aMessage }; var a2aResponse = await a2aClient.SendMessageAsync(messageSendParams, cancellationToken); // Handle different response types - if (a2aResponse is AgentMessage message) + if (a2aResponse.PayloadCase == SendMessageResponseCase.Message) { + var message = a2aResponse.Message!; var responseMessage = message.ToChatMessage(); if (responseMessage is { Contents.Count: > 0 }) { @@ -67,9 +68,10 @@ public override async IAsyncEnumerable RunStreamingAsync( }); } } - else if (a2aResponse is AgentTask agentTask) + else if (a2aResponse.PayloadCase == SendMessageResponseCase.Task) { // Manually convert AgentTask artifacts to ChatMessages since the extension method is internal + var agentTask = a2aResponse.Task!; if (agentTask.Artifacts is not null) { foreach (var artifact in agentTask.Artifacts) diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs index 9d98857e9b..6822140ded 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net.ServerSentEvents; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; @@ -100,64 +99,47 @@ protected override async Task RunCoreAsync(IEnumerable 0 } taskMessages) - { - response.Messages = taskMessages; - } + UpdateSession(typedSession, agentTask.ContextId, agentTask.Id); - return response; + return this.ConvertToAgentResponse(agentTask); } - throw new NotSupportedException($"Only Message and AgentTask responses are supported from A2A agents. Received: {a2aResponse.GetType().FullName ?? "null"}"); + throw new NotSupportedException($"Only Message and AgentTask responses are supported from A2A agents. Received: {a2aResponse.PayloadCase}"); } /// @@ -169,59 +151,61 @@ protected override async IAsyncEnumerable RunCoreStreamingA this._logger.LogA2AAgentInvokingAgent(nameof(RunStreamingAsync), this.Id, this.Name); - ConfiguredCancelableAsyncEnumerable> a2aSseEvents; + ConfiguredCancelableAsyncEnumerable streamEvents; - if (options?.ContinuationToken is not null) + if (GetContinuationToken(messages, options) is { } token) { - // Task stream resumption is not well defined in the A2A v2.* specification, leaving it to the agent implementations. - // The v3.0 specification improves this by defining task stream reconnection that allows obtaining the same stream - // from the beginning, but it does not define stream resumption from a specific point in the stream. - // Therefore, the code should be updated once the A2A .NET library supports the A2A v3.0 specification, - // and AF has the necessary model to allow consumers to know whether they need to resume the stream and add new updates to - // the existing ones or reconnect the stream and obtain all updates again. - // For more details, see the following issue: https://github.com/microsoft/agent-framework/issues/1764 - throw new InvalidOperationException("Reconnecting to task streams using continuation tokens is not supported yet."); - // a2aSseEvents = this._a2aClient.SubscribeToTaskAsync(token.TaskId, cancellationToken).ConfigureAwait(false); + streamEvents = this._a2aClient.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = token.TaskId }, cancellationToken).ConfigureAwait(false); } - - MessageSendParams sendParams = new() + else { - Message = CreateA2AMessage(typedSession, messages), - Metadata = options?.AdditionalProperties?.ToA2AMetadata() - }; + SendMessageRequest sendParams = new() + { + Message = CreateA2AMessage(typedSession, messages), + Metadata = options?.AdditionalProperties?.ToA2AMetadata() + }; - a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(sendParams, cancellationToken).ConfigureAwait(false); + streamEvents = this._a2aClient.SendStreamingMessageAsync(sendParams, cancellationToken).ConfigureAwait(false); + } this._logger.LogAgentChatClientInvokedAgent(nameof(RunStreamingAsync), this.Id, this.Name); string? contextId = null; string? taskId = null; - await foreach (var sseEvent in a2aSseEvents) + await foreach (var streamResponse in streamEvents) { - if (sseEvent.Data is AgentMessage message) - { - contextId = message.ContextId; - - yield return this.ConvertToAgentResponseUpdate(message); - } - else if (sseEvent.Data is AgentTask task) + switch (streamResponse.PayloadCase) { - contextId = task.ContextId; - taskId = task.Id; - - yield return this.ConvertToAgentResponseUpdate(task); - } - else if (sseEvent.Data is TaskUpdateEvent taskUpdateEvent) - { - contextId = taskUpdateEvent.ContextId; - taskId = taskUpdateEvent.TaskId; - - yield return this.ConvertToAgentResponseUpdate(taskUpdateEvent); - } - else - { - throw new NotSupportedException($"Only message, task, task update events are supported from A2A agents. Received: {sseEvent.Data.GetType().FullName ?? "null"}"); + case StreamResponseCase.Message: + var message = streamResponse.Message!; + contextId = message.ContextId; + yield return this.ConvertToAgentResponseUpdate(message); + break; + + case StreamResponseCase.Task: + var task = streamResponse.Task!; + contextId = task.ContextId; + taskId = task.Id; + yield return this.ConvertToAgentResponseUpdate(task); + break; + + case StreamResponseCase.StatusUpdate: + var statusUpdate = streamResponse.StatusUpdate!; + contextId = statusUpdate.ContextId; + taskId = statusUpdate.TaskId; + yield return this.ConvertToAgentResponseUpdate(statusUpdate); + break; + + case StreamResponseCase.ArtifactUpdate: + var artifactUpdate = streamResponse.ArtifactUpdate!; + contextId = artifactUpdate.ContextId; + taskId = artifactUpdate.TaskId; + yield return this.ConvertToAgentResponseUpdate(artifactUpdate); + break; + + default: + throw new NotSupportedException($"Only message, task, task update events are supported from A2A agents. Received: {streamResponse.PayloadCase}"); } } @@ -284,7 +268,7 @@ private static void UpdateSession(A2AAgentSession? session, string? contextId, s session.TaskId = taskId; } - private static AgentMessage CreateA2AMessage(A2AAgentSession typedSession, IEnumerable messages) + private static Message CreateA2AMessage(A2AAgentSession typedSession, IEnumerable messages) { var a2aMessage = messages.ToA2AMessage(); @@ -324,7 +308,34 @@ private static AgentMessage CreateA2AMessage(A2AAgentSession typedSession, IEnum return null; } - private AgentResponseUpdate ConvertToAgentResponseUpdate(AgentMessage message) + private AgentResponse ConvertToAgentResponse(Message message) + { + return new AgentResponse + { + AgentId = this.Id, + ResponseId = message.MessageId, + FinishReason = ChatFinishReason.Stop, + RawRepresentation = message, + Messages = [message.ToChatMessage()], + AdditionalProperties = message.Metadata?.ToAdditionalProperties(), + }; + } + + private AgentResponse ConvertToAgentResponse(AgentTask agentTask) + { + return new AgentResponse + { + AgentId = this.Id, + ResponseId = agentTask.Id, + FinishReason = MapTaskStateToFinishReason(agentTask.Status.State), + RawRepresentation = agentTask, + Messages = agentTask.ToChatMessages() ?? [], + ContinuationToken = CreateContinuationToken(agentTask.Id, agentTask.Status.State), + AdditionalProperties = agentTask.Metadata?.ToAdditionalProperties(), + }; + } + + private AgentResponseUpdate ConvertToAgentResponseUpdate(Message message) { return new AgentResponseUpdate { @@ -353,28 +364,30 @@ private AgentResponseUpdate ConvertToAgentResponseUpdate(AgentTask task) }; } - private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskUpdateEvent taskUpdateEvent) + private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskStatusUpdateEvent statusUpdateEvent) { - AgentResponseUpdate responseUpdate = new() + return new AgentResponseUpdate { AgentId = this.Id, - ResponseId = taskUpdateEvent.TaskId, - RawRepresentation = taskUpdateEvent, + ResponseId = statusUpdateEvent.TaskId, + RawRepresentation = statusUpdateEvent, Role = ChatRole.Assistant, - AdditionalProperties = taskUpdateEvent.Metadata?.ToAdditionalProperties() ?? [], + FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State), + AdditionalProperties = statusUpdateEvent.Metadata?.ToAdditionalProperties() ?? [], }; + } - if (taskUpdateEvent is TaskArtifactUpdateEvent artifactUpdateEvent) - { - responseUpdate.Contents = artifactUpdateEvent.Artifact.ToAIContents(); - responseUpdate.RawRepresentation = artifactUpdateEvent; - } - else if (taskUpdateEvent is TaskStatusUpdateEvent statusUpdateEvent) + private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskArtifactUpdateEvent artifactUpdateEvent) + { + return new AgentResponseUpdate { - responseUpdate.FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State); - } - - return responseUpdate; + AgentId = this.Id, + ResponseId = artifactUpdateEvent.TaskId, + RawRepresentation = artifactUpdateEvent, + Role = ChatRole.Assistant, + Contents = artifactUpdateEvent.Artifact.ToAIContents(), + AdditionalProperties = artifactUpdateEvent.Metadata?.ToAdditionalProperties() ?? [], + }; } private static ChatFinishReason? MapTaskStateToFinishReason(TaskState state) diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs index 1998d020b5..bda0290af5 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; +using System.Linq; using System.Net.Http; using Microsoft.Agents.AI; using Microsoft.Extensions.Logging; @@ -26,11 +28,27 @@ public static class A2AAgentCardExtensions /// The to use for the agent creation. /// The to use for HTTP requests. /// The logger factory for enabling logging within the agent. + /// + /// An optional callback to select which to use from the card's + /// . When not provided, the first interface is used. + /// /// An instance backed by the A2A agent. - public static AIAgent AsAIAgent(this AgentCard card, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null) + public static AIAgent AsAIAgent(this AgentCard card, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null, Func, AgentInterface>? interfaceSelector = null) { + var interfaces = card.SupportedInterfaces + ?? throw new InvalidOperationException("The AgentCard does not have any SupportedInterfaces."); + + // Use the provided selector or default to the first interface. + var selectedInterface = interfaceSelector is not null + ? interfaceSelector(interfaces) + : interfaces.FirstOrDefault() + ?? throw new InvalidOperationException("The AgentCard does not have any SupportedInterfaces with a URL."); + + var url = selectedInterface.Url + ?? throw new InvalidOperationException("The selected AgentInterface does not have a URL."); + // Create the A2A client using the agent URL from the card. - var a2aClient = new A2AClient(new Uri(card.Url), httpClient); + var a2aClient = new A2AClient(new Uri(url), httpClient); return a2aClient.AsAIAgent(name: card.Name, description: card.Description, loggerFactory: loggerFactory); } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/ChatMessageExtensions.cs index b1f1bd643a..dd0749ecc9 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/ChatMessageExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/ChatMessageExtensions.cs @@ -11,7 +11,7 @@ namespace Microsoft.Extensions.AI; /// internal static class ChatMessageExtensions { - internal static AgentMessage ToA2AMessage(this IEnumerable messages) + internal static Message ToA2AMessage(this IEnumerable messages) { List allParts = []; @@ -23,10 +23,10 @@ internal static AgentMessage ToA2AMessage(this IEnumerable messages } } - return new AgentMessage + return new Message { MessageId = Guid.NewGuid().ToString("N"), - Role = MessageRole.User, + Role = Role.User, Parts = allParts, }; } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj b/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj index b1b9ba7671..4e92826f56 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj @@ -1,6 +1,7 @@ + $(TargetFrameworksCore) preview $(NoWarn);MEAI001 diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs index af3ff093ee..ffd82f50b6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs @@ -27,14 +27,17 @@ public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions /// The to add the A2A endpoints to. /// The configuration builder for . /// The route group to use for A2A endpoints. - /// Configured for A2A integration. + /// An for further endpoint configuration. /// /// This method can be used to access A2A agents that support the /// Curated Registries (Catalog-Based Discovery) /// discovery mechanism. /// public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path) - => endpoints.MapA2A(agentBuilder, path, _ => { }); + { + ArgumentNullException.ThrowIfNull(agentBuilder); + return endpoints.MapA2A(agentBuilder.Name, path); + } /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. @@ -43,7 +46,7 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The configuration builder for . /// The route group to use for A2A endpoints. /// Controls the response behavior of the agent run. - /// Configured for A2A integration. + /// An for further endpoint configuration. public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentRunMode agentRunMode) { ArgumentNullException.ThrowIfNull(agentBuilder); @@ -56,42 +59,12 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The to add the A2A endpoints to. /// The name of the agent to use for A2A protocol integration. /// The route group to use for A2A endpoints. - /// Configured for A2A integration. + /// An for further endpoint configuration. public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path) - => endpoints.MapA2A(agentName, path, _ => { }); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The name of the agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentRunMode agentRunMode) { ArgumentNullException.ThrowIfNull(endpoints); var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - return endpoints.MapA2A(agent, path, _ => { }, agentRunMode); - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The configuration builder for . - /// The route group to use for A2A endpoints. - /// The callback to configure . - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, Action configureTaskManager) - { - ArgumentNullException.ThrowIfNull(agentBuilder); - return endpoints.MapA2A(agentBuilder.Name, path, configureTaskManager); + return endpoints.MapA2A(agent, path); } /// @@ -100,13 +73,13 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The to add the A2A endpoints to. /// The name of the agent to use for A2A protocol integration. /// The route group to use for A2A endpoints. - /// The callback to configure . - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, Action configureTaskManager) + /// Controls the response behavior of the agent run. + /// An for further endpoint configuration. + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentRunMode agentRunMode) { ArgumentNullException.ThrowIfNull(endpoints); var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - return endpoints.MapA2A(agent, path, configureTaskManager); + return endpoints.MapA2A(agent, path, agentRunMode); } /// @@ -115,15 +88,18 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The to add the A2A endpoints to. /// The configuration builder for . /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// Configured for A2A integration. + /// Agent card describing the agent's capabilities for discovery. + /// An for further endpoint configuration. /// /// This method can be used to access A2A agents that support the /// Curated Registries (Catalog-Based Discovery) /// discovery mechanism. /// public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard) - => endpoints.MapA2A(agentBuilder, path, agentCard, _ => { }); + { + ArgumentNullException.ThrowIfNull(agentBuilder); + return endpoints.MapA2A(agentBuilder.Name, path, agentCard); + } /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. @@ -131,15 +107,19 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The to add the A2A endpoints to. /// The name of the agent to use for A2A protocol integration. /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// Configured for A2A integration. + /// Agent card describing the agent's capabilities for discovery. + /// An for further endpoint configuration. /// /// This method can be used to access A2A agents that support the /// Curated Registries (Catalog-Based Discovery) /// discovery mechanism. /// public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard) - => endpoints.MapA2A(agentName, path, agentCard, _ => { }); + { + ArgumentNullException.ThrowIfNull(endpoints); + var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); + return endpoints.MapA2A(agent, path, agentCard); + } /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. @@ -147,9 +127,9 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The to add the A2A endpoints to. /// The configuration builder for . /// The route group to use for A2A endpoints. - /// Agent card info to return on query. + /// Agent card describing the agent's capabilities for discovery. /// Controls the response behavior of the agent run. - /// Configured for A2A integration. + /// An for further endpoint configuration. public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, AgentRunMode agentRunMode) { ArgumentNullException.ThrowIfNull(agentBuilder); @@ -162,9 +142,9 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The to add the A2A endpoints to. /// The name of the agent to use for A2A protocol integration. /// The route group to use for A2A endpoints. - /// Agent card info to return on query. + /// Agent card describing the agent's capabilities for discovery. /// Controls the response behavior of the agent run. - /// Configured for A2A integration. + /// An for further endpoint configuration. public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, AgentRunMode agentRunMode) { ArgumentNullException.ThrowIfNull(endpoints); @@ -172,74 +152,15 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo return endpoints.MapA2A(agent, path, agentCard, agentRunMode); } - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The configuration builder for . - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// The callback to configure . - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, Action configureTaskManager) - { - ArgumentNullException.ThrowIfNull(agentBuilder); - return endpoints.MapA2A(agentBuilder.Name, path, agentCard, configureTaskManager); - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The name of the agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// The callback to configure . - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action configureTaskManager) - => endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The name of the agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// The callback to configure . - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action configureTaskManager, AgentRunMode agentRunMode) - { - ArgumentNullException.ThrowIfNull(endpoints); - var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - return endpoints.MapA2A(agent, path, agentCard, configureTaskManager, agentRunMode); - } - /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. /// /// The to add the A2A endpoints to. /// The agent to use for A2A protocol integration. /// The route group to use for A2A endpoints. - /// Configured for A2A integration. + /// An for further endpoint configuration. public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path) - => endpoints.MapA2A(agent, path, _ => { }); + => endpoints.MapA2A(agent, path, AgentRunMode.DisallowBackground); /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. @@ -248,42 +169,16 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The agent to use for A2A protocol integration. /// The route group to use for A2A endpoints. /// Controls the response behavior of the agent run. - /// Configured for A2A integration. + /// An for further endpoint configuration. public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentRunMode agentRunMode) - => endpoints.MapA2A(agent, path, _ => { }, agentRunMode); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// The callback to configure . - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager) - => endpoints.MapA2A(agent, path, configureTaskManager, AgentRunMode.DisallowBackground); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// The callback to configure . - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager, AgentRunMode agentRunMode) { ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(agent); var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(agent.Name); - var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore, runMode: agentRunMode); - var endpointConventionBuilder = endpoints.MapA2A(taskManager, path); - - configureTaskManager(taskManager); - return endpointConventionBuilder; + var handler = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore, runMode: agentRunMode); + return A2ARouteBuilderExtensions.MapA2A(endpoints, handler, path); } /// @@ -292,15 +187,15 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The to add the A2A endpoints to. /// The agent to use for A2A protocol integration. /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// Configured for A2A integration. + /// Agent card describing the agent's capabilities for discovery. + /// An for further endpoint configuration. /// /// This method can be used to access A2A agents that support the /// Curated Registries (Catalog-Based Discovery) /// discovery mechanism. /// public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard) - => endpoints.MapA2A(agent, path, agentCard, _ => { }); + => endpoints.MapA2A(agent, path, agentCard, AgentRunMode.DisallowBackground); /// /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. @@ -308,78 +203,52 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// The to add the A2A endpoints to. /// The agent to use for A2A protocol integration. /// The route group to use for A2A endpoints. - /// Agent card info to return on query. + /// Agent card describing the agent's capabilities for discovery. /// Controls the response behavior of the agent run. - /// Configured for A2A integration. + /// An for further endpoint configuration. /// /// This method can be used to access A2A agents that support the /// Curated Registries (Catalog-Based Discovery) /// discovery mechanism. /// public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, AgentRunMode agentRunMode) - => endpoints.MapA2A(agent, path, agentCard, _ => { }, agentRunMode); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// The callback to configure . - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager) - => endpoints.MapA2A(agent, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// The callback to configure . - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager, AgentRunMode agentRunMode) { ArgumentNullException.ThrowIfNull(endpoints); ArgumentNullException.ThrowIfNull(agent); var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(agent.Name); - var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory, runMode: agentRunMode); - var endpointConventionBuilder = endpoints.MapA2A(taskManager, path); - - configureTaskManager(taskManager); + var handler = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore, runMode: agentRunMode); + A2ARouteBuilderExtensions.MapA2A(endpoints, handler, path); + endpoints.MapWellKnownAgentCard(agentCard); + return endpoints.MapHttpA2A(handler, agentCard); + } - return endpointConventionBuilder; + /// + /// Maps A2A JSON-RPC communication endpoints to the specified path using a pre-configured request handler. + /// + /// The to add the A2A endpoints to. + /// Pre-configured for handling A2A requests. + /// The route group to use for A2A endpoints. + /// An for further endpoint configuration. + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IA2ARequestHandler handler, string path) + { + return A2ARouteBuilderExtensions.MapA2A(endpoints, handler, path); } /// - /// Maps HTTP A2A communication endpoints to the specified path using the provided TaskManager. - /// TaskManager should be preconfigured before calling this method. + /// Maps A2A communication endpoints including JSON-RPC, well-known agent card, and REST API + /// to the specified path using a pre-configured request handler. /// /// The to add the A2A endpoints to. - /// Pre-configured A2A TaskManager to use for A2A endpoints handling. + /// Pre-configured for handling A2A requests. /// The route group to use for A2A endpoints. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, ITaskManager taskManager, string path) + /// Agent card describing the agent's capabilities for discovery. + /// An for further endpoint configuration. + public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IA2ARequestHandler handler, string path, AgentCard agentCard) { - // note: current SDK version registers multiple `.well-known/agent.json` handlers here. - // it makes app return HTTP 500, but will be fixed once new A2A SDK is released. - // see https://github.com/microsoft/agent-framework/issues/476 for details - A2ARouteBuilderExtensions.MapA2A(endpoints, taskManager, path); - return endpoints.MapHttpA2A(taskManager, path); + A2ARouteBuilderExtensions.MapA2A(endpoints, handler, path); + endpoints.MapWellKnownAgentCard(agentCard); + return endpoints.MapHttpA2A(handler, agentCard); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs index 6ff49f6ecb..9928aaf2f8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs @@ -9,13 +9,13 @@ namespace Microsoft.Agents.AI.Hosting.A2A; /// public sealed class A2ARunDecisionContext { - internal A2ARunDecisionContext(MessageSendParams messageSendParams) + internal A2ARunDecisionContext(SendMessageRequest sendMessageRequest) { - this.MessageSendParams = messageSendParams; + this.SendMessageRequest = sendMessageRequest; } /// - /// Gets the parameters of the incoming A2A message that triggered this run. + /// Gets the that triggered this run. /// - public MessageSendParams MessageSendParams { get; } + public SendMessageRequest SendMessageRequest { get; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index 31c520755f..53f0ef4bbf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text.Json; @@ -10,6 +11,7 @@ using Microsoft.Agents.AI.Hosting.A2A.Converters; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI.Hosting.A2A; @@ -28,15 +30,15 @@ public static class AIAgentExtensions /// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified . /// /// Agent to attach A2A messaging processing capabilities to. - /// Instance of to configure for A2A messaging. New instance will be created if not passed. + /// Instance of for task persistence. A new will be created if not passed. /// The logger factory to use for creating instances. /// The store to store session contents and metadata. /// Controls the response behavior of the agent run. /// Optional for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to if not provided. - /// The configured . - public static ITaskManager MapA2A( + /// The configured . + public static IA2ARequestHandler MapA2A( this AIAgent agent, - ITaskManager? taskManager = null, + ITaskStore? taskStore = null, ILoggerFactory? loggerFactory = null, AgentSessionStore? agentSessionStore = null, AgentRunMode? runMode = null, @@ -46,264 +48,282 @@ public static ITaskManager MapA2A( ArgumentNullException.ThrowIfNull(agent.Name); runMode ??= AgentRunMode.DisallowBackground; + taskStore ??= new InMemoryTaskStore(); var hostAgent = new AIHostAgent( innerAgent: agent, sessionStore: agentSessionStore ?? new NoopAgentSessionStore()); - taskManager ??= new TaskManager(); - // Resolve the JSON serializer options for continuation token serialization. May be custom for the user's agent. JsonSerializerOptions continuationTokenJsonOptions = jsonSerializerOptions ?? A2AHostingJsonUtilities.DefaultOptions; - // OnMessageReceived handles both message-only and task-based flows. - // The A2A SDK prioritizes OnMessageReceived over OnTaskCreated when both are set, - // so we consolidate all initial message handling here and return either - // an AgentMessage or AgentTask depending on the agent response. - // When the agent returns a ContinuationToken (long-running operation), a task is - // created for stateful tracking. Otherwise a lightweight AgentMessage is returned. - // See https://github.com/a2aproject/a2a-dotnet/issues/275 - taskManager.OnMessageReceived += (p, ct) => OnMessageReceivedAsync(p, hostAgent, runMode, taskManager, continuationTokenJsonOptions, ct); - - // Task flow for subsequent updates and cancellations - taskManager.OnTaskUpdated += (t, ct) => OnTaskUpdatedAsync(t, hostAgent, taskManager, continuationTokenJsonOptions, ct); - taskManager.OnTaskCancelled += OnTaskCancelledAsync; + // Wrap the task store to inject pending metadata (continuation tokens, history) during task + // materialization. The A2AServer runs the handler concurrently with event materialization, + // so the handler cannot directly access/modify tasks via the store during execution. + var wrappedTaskStore = new MetadataInjectingTaskStore(taskStore); - return taskManager; + var handler = new AIAgentA2AHandler(hostAgent, runMode, wrappedTaskStore, continuationTokenJsonOptions); + var logger = loggerFactory?.CreateLogger() ?? NullLogger.Instance; + return new A2AServer(handler, wrappedTaskStore, new ChannelEventNotifier(), logger, null); } - /// - /// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified . - /// - /// Agent to attach A2A messaging processing capabilities to. - /// The agent card to return on query. - /// Instance of to configure for A2A messaging. New instance will be created if not passed. - /// The logger factory to use for creating instances. - /// The store to store session contents and metadata. - /// Controls the response behavior of the agent run. - /// Optional for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to if not provided. - /// The configured . - public static ITaskManager MapA2A( - this AIAgent agent, - AgentCard agentCard, - ITaskManager? taskManager = null, - ILoggerFactory? loggerFactory = null, - AgentSessionStore? agentSessionStore = null, - AgentRunMode? runMode = null, - JsonSerializerOptions? jsonSerializerOptions = null) - { - taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore, runMode, jsonSerializerOptions); - - taskManager.OnAgentCardQuery += (context, query) => + private static Message CreateMessageFromResponse(string contextId, AgentResponse response) => + new() { - // A2A SDK assigns the url on its own - // we can help user if they did not set Url explicitly. - if (string.IsNullOrEmpty(agentCard.Url)) - { - agentCard.Url = context.TrimEnd('/'); - } + MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"), + ContextId = contextId, + Role = Role.Agent, + Parts = response.Messages.ToParts(), + Metadata = response.AdditionalProperties?.ToA2AMetadata() + }; - return Task.FromResult(agentCard); + // Task outputs should be returned as artifacts rather than messages: + // https://a2a-protocol.org/latest/specification/#37-messages-and-artifacts + private static Artifact CreateArtifactFromResponse(AgentResponse response) => + new() + { + ArtifactId = response.ResponseId ?? Guid.NewGuid().ToString("N"), + Parts = response.Messages.ToParts(), + Metadata = response.AdditionalProperties?.ToA2AMetadata() }; - return taskManager; + + private static void StoreContinuationToken( + AgentTask agentTask, + ResponseContinuationToken token, + JsonSerializerOptions continuationTokenJsonOptions) + { + agentTask.Metadata ??= []; + agentTask.Metadata[ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement( + token, + continuationTokenJsonOptions.GetTypeInfo(typeof(ResponseContinuationToken))); } - private static async Task OnMessageReceivedAsync( - MessageSendParams messageSendParams, - AIHostAgent hostAgent, - AgentRunMode runMode, - ITaskManager taskManager, - JsonSerializerOptions continuationTokenJsonOptions, - CancellationToken cancellationToken) + private static List ExtractChatMessagesFromTaskHistory(AgentTask? agentTask) { - // AIAgent does not support resuming from arbitrary prior tasks. - // Throw explicitly so the client gets a clear error rather than a response - // that silently ignores the referenced task context. - // Follow-ups on the *same* task are handled via OnTaskUpdated instead. - if (messageSendParams.Message.ReferenceTaskIds is { Count: > 0 }) + if (agentTask?.History is not { Count: > 0 }) { - throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context. Use OnTaskUpdated for follow-ups on the same task."); + return []; } - var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N"); - var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); - - // Decide whether to run in background based on user preferences and agent capabilities - var decisionContext = new A2ARunDecisionContext(messageSendParams); - var allowBackgroundResponses = await runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false); - - var options = messageSendParams.Metadata is not { Count: > 0 } - ? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses } - : new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() }; - - var response = await hostAgent.RunAsync( - messageSendParams.ToChatMessages(), - session: session, - options: options, - cancellationToken: cancellationToken).ConfigureAwait(false); - - await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); - - if (response.ContinuationToken is null) + var chatMessages = new List(agentTask.History.Count); + foreach (var message in agentTask.History) { - return CreateMessageFromResponse(contextId, response); + chatMessages.Add(message.ToChatMessage()); } - var agentTask = await InitializeTaskAsync(contextId, messageSendParams.Message, taskManager, cancellationToken).ConfigureAwait(false); - StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions); - await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false); - return agentTask; + return chatMessages; } - private static async Task OnTaskUpdatedAsync( - AgentTask agentTask, - AIHostAgent hostAgent, - ITaskManager taskManager, - JsonSerializerOptions continuationTokenJsonOptions, - CancellationToken cancellationToken) + /// + /// Wraps an to apply pending modifications (continuation tokens, + /// message history) when the A2AServer materializes task events. This is needed because + /// the A2AServer runs handler execution concurrently with event materialization, so the + /// handler cannot directly modify tasks in the store during execution. + /// + private sealed class MetadataInjectingTaskStore : ITaskStore { - var contextId = agentTask.ContextId ?? Guid.NewGuid().ToString("N"); - var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); + private readonly ITaskStore _inner; + private readonly ConcurrentDictionary> _pendingModifications = new(); - try - { - // Discard any stale continuation token — the incoming user message supersedes - // any previous background operation. AF agents don't support updating existing - // background responses (long-running operations); we start a fresh run from the - // existing session using the full chat history (which includes the new message). - agentTask.Metadata?.Remove(ContinuationTokenMetadataKey); + internal MetadataInjectingTaskStore(ITaskStore inner) => _inner = inner; - await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false); - - var response = await hostAgent.RunAsync( - ExtractChatMessagesFromTaskHistory(agentTask), - session: session, - options: new AgentRunOptions { AllowBackgroundResponses = true }, - cancellationToken: cancellationToken).ConfigureAwait(false); + internal void RegisterModification(string taskId, Action modification) + => _pendingModifications.AddOrUpdate(taskId, modification, (_, existing) => task => + { + existing(task); + modification(task); + }); - await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); + internal void ClearModification(string taskId) + => _pendingModifications.TryRemove(taskId, out _); - if (response.ContinuationToken is not null) - { - StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions); - await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false); - } - else + public async Task SaveTaskAsync(string taskId, AgentTask task, CancellationToken cancellationToken) + { + if (_pendingModifications.TryRemove(taskId, out var modification)) { - await CompleteWithArtifactAsync(agentTask.Id, response, taskManager, cancellationToken).ConfigureAwait(false); + modification(task); } + + await _inner.SaveTaskAsync(taskId, task, cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException) - { - throw; - } - catch (Exception) + + public Task GetTaskAsync(string taskId, CancellationToken cancellationToken) + => _inner.GetTaskAsync(taskId, cancellationToken); + + public Task DeleteTaskAsync(string taskId, CancellationToken cancellationToken) { - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Failed, - final: true, - cancellationToken: cancellationToken).ConfigureAwait(false); - throw; + _pendingModifications.TryRemove(taskId, out _); + return _inner.DeleteTaskAsync(taskId, cancellationToken); } + + public Task ListTasksAsync(ListTasksRequest request, CancellationToken cancellationToken) + => _inner.ListTasksAsync(request, cancellationToken); } - private static Task OnTaskCancelledAsync(AgentTask agentTask, CancellationToken cancellationToken) + /// + /// Private handler implementation that bridges AIAgent to the A2A v1 IAgentHandler interface. + /// + private sealed class AIAgentA2AHandler : IAgentHandler { - // Remove the continuation token from metadata if present. - // The task has already been marked as cancelled by the TaskManager. - agentTask.Metadata?.Remove(ContinuationTokenMetadataKey); - return Task.CompletedTask; - } + private readonly AIHostAgent _hostAgent; + private readonly AgentRunMode _runMode; + private readonly MetadataInjectingTaskStore _taskStore; + private readonly JsonSerializerOptions _continuationTokenJsonOptions; + + internal AIAgentA2AHandler( + AIHostAgent hostAgent, + AgentRunMode runMode, + MetadataInjectingTaskStore taskStore, + JsonSerializerOptions continuationTokenJsonOptions) + { + _hostAgent = hostAgent; + _runMode = runMode; + _taskStore = taskStore; + _continuationTokenJsonOptions = continuationTokenJsonOptions; + } - private static AgentMessage CreateMessageFromResponse(string contextId, AgentResponse response) => - new() + public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) { - MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"), - ContextId = contextId, - Role = MessageRole.Agent, - Parts = response.Messages.ToParts(), - Metadata = response.AdditionalProperties?.ToA2AMetadata() - }; + if (context.IsContinuation) + { + await HandleTaskUpdateAsync(context, eventQueue, cancellationToken).ConfigureAwait(false); + } + else + { + await HandleNewMessageAsync(context, eventQueue, cancellationToken).ConfigureAwait(false); + } + } - // Task outputs should be returned as artifacts rather than messages: - // https://a2a-protocol.org/latest/specification/#37-messages-and-artifacts - private static Artifact CreateArtifactFromResponse(AgentResponse response) => - new() + public Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) { - ArtifactId = response.ResponseId ?? Guid.NewGuid().ToString("N"), - Parts = response.Messages.ToParts(), - Metadata = response.AdditionalProperties?.ToA2AMetadata() - }; + // Remove the continuation token from metadata if present. + // The task has already been marked as cancelled by the A2AServer. + context.Task?.Metadata?.Remove(ContinuationTokenMetadataKey); + return Task.CompletedTask; + } - private static async Task InitializeTaskAsync( - string contextId, - AgentMessage originalMessage, - ITaskManager taskManager, - CancellationToken cancellationToken) - { - AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false); + private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + // AIAgent does not support resuming from arbitrary prior tasks. + // Follow-ups on the *same* task are handled via IsContinuation instead. + if (context.Message?.ReferenceTaskIds is { Count: > 0 }) + { + throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context."); + } - // Add the original user message to the task history. - // The A2A SDK does this internally when it creates tasks via OnTaskCreated. - agentTask.History ??= []; - agentTask.History.Add(originalMessage); + var contextId = context.ContextId; + var session = await _hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); - // Notify subscribers of the Submitted state per the A2A spec: https://a2a-protocol.org/latest/specification/#413-taskstate - await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Submitted, cancellationToken: cancellationToken).ConfigureAwait(false); + var sendRequest = new SendMessageRequest { Message = context.Message!, Metadata = context.Metadata }; + var decisionContext = new A2ARunDecisionContext(sendRequest); + var allowBackgroundResponses = await _runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false); - return agentTask; - } + var options = context.Metadata is not { Count: > 0 } + ? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses } + : new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() }; - private static void StoreContinuationToken( - AgentTask agentTask, - ResponseContinuationToken token, - JsonSerializerOptions continuationTokenJsonOptions) - { - // Serialize the continuation token into the task's metadata so it survives - // across requests and is cleaned up with the task itself. - agentTask.Metadata ??= []; - agentTask.Metadata[ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement( - token, - continuationTokenJsonOptions.GetTypeInfo(typeof(ResponseContinuationToken))); - } + var chatMessages = new List(); + if (context.Message?.Parts is not null) + { + chatMessages.Add(context.Message.ToChatMessage()); + } - private static async Task TransitionToWorkingAsync( - string taskId, - string contextId, - AgentResponse response, - ITaskManager taskManager, - CancellationToken cancellationToken) - { - // Include any intermediate progress messages from the response as a status message. - AgentMessage? progressMessage = response.Messages.Count > 0 ? CreateMessageFromResponse(contextId, response) : null; - await taskManager.UpdateStatusAsync(taskId, TaskState.Working, message: progressMessage, cancellationToken: cancellationToken).ConfigureAwait(false); - } + var response = await _hostAgent.RunAsync( + chatMessages, + session: session, + options: options, + cancellationToken: cancellationToken).ConfigureAwait(false); - private static async Task CompleteWithArtifactAsync( - string taskId, - AgentResponse response, - ITaskManager taskManager, - CancellationToken cancellationToken) - { - var artifact = CreateArtifactFromResponse(response); - await taskManager.ReturnArtifactAsync(taskId, artifact, cancellationToken).ConfigureAwait(false); - await taskManager.UpdateStatusAsync(taskId, TaskState.Completed, final: true, cancellationToken: cancellationToken).ConfigureAwait(false); - } + await _hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); - private static List ExtractChatMessagesFromTaskHistory(AgentTask agentTask) - { - if (agentTask.History is not { Count: > 0 }) - { - return []; + if (response.ContinuationToken is null) + { + // Simple message response — enqueue a full Message with metadata. + var replyMessage = CreateMessageFromResponse(contextId, response); + await eventQueue.EnqueueMessageAsync(replyMessage, cancellationToken).ConfigureAwait(false); + eventQueue.Complete(null); + } + else + { + // Long-running task — use TaskUpdater for stateful tracking. + // Register a pending modification so that when A2AServer materializes + // the task, the continuation token and original message are injected. + var continuationToken = response.ContinuationToken; + var continuationJsonOptions = _continuationTokenJsonOptions; + var originalMessage = context.Message; + _taskStore.RegisterModification(context.TaskId, task => + { + StoreContinuationToken(task, continuationToken, continuationJsonOptions); + task.History ??= []; + task.History.Add(originalMessage!); + }); + + var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId); + await taskUpdater.SubmitAsync(cancellationToken).ConfigureAwait(false); + + Message? progressMessage = response.Messages.Count > 0 ? CreateMessageFromResponse(contextId, response) : null; + await taskUpdater.StartWorkAsync(progressMessage, cancellationToken).ConfigureAwait(false); + } } - var chatMessages = new List(agentTask.History.Count); - foreach (var message in agentTask.History) + private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) { - chatMessages.Add(message.ToChatMessage()); - } + var contextId = context.ContextId; + var session = await _hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); + var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId); - return chatMessages; + try + { + // Discard any stale continuation token — the incoming user message supersedes + // any previous background operation. + var agentTask = context.Task; + agentTask?.Metadata?.Remove(ContinuationTokenMetadataKey); + + // Emit the existing task so the materializer has a non-null response. + // TaskUpdater status/artifact events alone use EnqueueStatusUpdateAsync + // and EnqueueArtifactUpdateAsync, which set StreamResponse.StatusUpdate + // and StreamResponse.ArtifactUpdate respectively. The materializer only + // initializes the response from events with StreamResponse.Task or + // StreamResponse.Message set. EnqueueTaskAsync sets StreamResponse.Task. + await eventQueue.EnqueueTaskAsync(agentTask!, cancellationToken).ConfigureAwait(false); + + await taskUpdater.StartWorkAsync(null, cancellationToken).ConfigureAwait(false); + + var response = await _hostAgent.RunAsync( + ExtractChatMessagesFromTaskHistory(agentTask), + session: session, + options: new AgentRunOptions { AllowBackgroundResponses = true }, + cancellationToken: cancellationToken).ConfigureAwait(false); + + await _hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); + + if (response.ContinuationToken is not null) + { + // Register continuation token injection for the next task save. + var continuationToken = response.ContinuationToken; + var continuationJsonOptions = _continuationTokenJsonOptions; + _taskStore.RegisterModification(context.TaskId, task => + StoreContinuationToken(task, continuationToken, continuationJsonOptions)); + + Message? progressMessage = response.Messages.Count > 0 ? CreateMessageFromResponse(contextId, response) : null; + await taskUpdater.StartWorkAsync(progressMessage, cancellationToken).ConfigureAwait(false); + } + else + { + var artifact = CreateArtifactFromResponse(response); + await taskUpdater.AddArtifactAsync(artifact.Parts ?? [], artifact.ArtifactId, artifact.Name, artifact.Description, true, false, cancellationToken).ConfigureAwait(false); + await taskUpdater.CompleteAsync(null, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + await taskUpdater.FailAsync(null, cancellationToken).ConfigureAwait(false); + throw; + } + } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs index 087df96aae..58c58d1d0b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs @@ -29,13 +29,13 @@ private AgentRunMode(string value, Func /// Dissallows the background responses from the agent. Is equivalent to configuring as false. - /// In the A2A protocol terminology will make responses be returned as AgentMessage. + /// In the A2A protocol terminology will make responses be returned as a Message. /// public static AgentRunMode DisallowBackground => new(MessageValue); /// /// Allows the background responses from the agent. Is equivalent to configuring as true. - /// In the A2A protocol terminology will make responses be returned as AgentTask if the agent supports background responses, and as AgentMessage otherwise. + /// In the A2A protocol terminology will make responses be returned as AgentTask if the agent supports background responses, and as a Message otherwise. /// public static AgentRunMode AllowBackgroundIfSupported => new(TaskValue); @@ -44,9 +44,9 @@ private AgentRunMode(string value, Func with the incoming /// message and returns a boolean specifying whether to run the agent in background mode. /// indicates that the agent should run in background mode and return an - /// AgentTask if the agent supports background mode; otherwise, it returns an AgentMessage + /// AgentTask if the agent supports background mode; otherwise, it returns a Message /// if the mode is not supported. indicates that the agent should run in - /// non-background mode and return an AgentMessage. + /// non-background mode and return a Message. /// /// /// An async delegate that decides whether the response should be wrapped in an AgentTask. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs index 5d2381a235..80f0a60986 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs @@ -30,22 +30,23 @@ public static List ToParts(this IList chatMessages) return parts; } + /// - /// Converts A2A MessageSendParams to a collection of Microsoft.Extensions.AI ChatMessage objects. + /// Converts an A2A SendMessageRequest to a collection of Microsoft.Extensions.AI ChatMessage objects. /// - /// The A2A message send parameters to convert. - /// A read-only collection of ChatMessage objects. - public static List ToChatMessages(this MessageSendParams messageSendParams) + /// The A2A send message request to convert. + /// A list of ChatMessage objects. + public static List ToChatMessages(this SendMessageRequest sendMessageRequest) { - if (messageSendParams is null) + if (sendMessageRequest is null) { return []; } var result = new List(); - if (messageSendParams.Message?.Parts is not null) + if (sendMessageRequest.Message?.Parts is not null) { - result.Add(messageSendParams.Message.ToChatMessage()); + result.Add(sendMessageRequest.Message.ToChatMessage()); } return result; diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs index 514922dd26..69d3d90c0f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs @@ -6,9 +6,7 @@ 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; @@ -89,14 +87,17 @@ public async Task RunAsync_AllowsNonUserRoleMessagesAsync() public async Task RunAsync_WithValidUserMessage_RunsSuccessfullyAsync() { // Arrange - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = - [ - new TextPart { Text = "Hello! How can I help you today?" } - ] + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = + [ + new Part { Text = "Hello! How can I help you today?" } + ] + } }; var inputMessages = new List @@ -108,11 +109,11 @@ public async Task RunAsync_WithValidUserMessage_RunsSuccessfullyAsync() var result = await this._agent.RunAsync(inputMessages); // Assert input message sent to A2AClient - var inputMessage = this._handler.CapturedMessageSendParams?.Message; + var inputMessage = this._handler.CapturedSendMessageRequest?.Message; Assert.NotNull(inputMessage); Assert.Single(inputMessage.Parts); - Assert.Equal(MessageRole.User, inputMessage.Role); - Assert.Equal("Hello, world!", ((TextPart)inputMessage.Parts[0]).Text); + Assert.Equal(Role.User, inputMessage.Role); + Assert.Equal("Hello, world!", inputMessage.Parts[0].Text); // Assert response from A2AClient is converted correctly Assert.NotNull(result); @@ -120,8 +121,8 @@ public async Task RunAsync_WithValidUserMessage_RunsSuccessfullyAsync() Assert.Equal("response-123", result.ResponseId); Assert.NotNull(result.RawRepresentation); - Assert.IsType(result.RawRepresentation); - Assert.Equal("response-123", ((AgentMessage)result.RawRepresentation).MessageId); + Assert.IsType(result.RawRepresentation); + Assert.Equal("response-123", ((Message)result.RawRepresentation).MessageId); Assert.Single(result.Messages); Assert.Equal(ChatRole.Assistant, result.Messages[0].Role); @@ -133,15 +134,18 @@ public async Task RunAsync_WithValidUserMessage_RunsSuccessfullyAsync() public async Task RunAsync_WithNewSession_UpdatesSessionConversationIdAsync() { // Arrange - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = - [ - new TextPart { Text = "Response" } - ], - ContextId = "new-context-id" + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = + [ + new Part { Text = "Response" } + ], + ContextId = "new-context-id" + } }; var inputMessages = new List @@ -177,7 +181,7 @@ public async Task RunAsync_WithExistingSession_SetConversationIdToMessageAsync() await this._agent.RunAsync(inputMessages, session); // Assert - var message = this._handler.CapturedMessageSendParams?.Message; + var message = this._handler.CapturedSendMessageRequest?.Message; Assert.NotNull(message); Assert.Equal("existing-context-id", message.ContextId); } @@ -191,15 +195,18 @@ public async Task RunAsync_WithSessionHavingDifferentContextId_ThrowsInvalidOper new(ChatRole.User, "Test message") }; - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = - [ - new TextPart { Text = "Response" } - ], - ContextId = "different-context" + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = + [ + new Part { Text = "Response" } + ], + ContextId = "different-context" + } }; var session = await this._agent.CreateSessionAsync(); @@ -219,12 +226,15 @@ public async Task RunStreamingAsync_WithValidUserMessage_YieldsAgentResponseUpda new(ChatRole.User, "Hello, streaming!") }; - this._handler.StreamingResponseToReturn = new AgentMessage() + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = "stream-1", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Hello" }], - ContextId = "stream-context" + Message = new Message + { + MessageId = "stream-1", + Role = Role.Agent, + Parts = [new Part { Text = "Hello" }], + ContextId = "stream-context" + } }; // Act @@ -238,11 +248,11 @@ public async Task RunStreamingAsync_WithValidUserMessage_YieldsAgentResponseUpda Assert.Single(updates); // Assert input message sent to A2AClient - var inputMessage = this._handler.CapturedMessageSendParams?.Message; + var inputMessage = this._handler.CapturedSendMessageRequest?.Message; Assert.NotNull(inputMessage); Assert.Single(inputMessage.Parts); - Assert.Equal(MessageRole.User, inputMessage.Role); - Assert.Equal("Hello, streaming!", ((TextPart)inputMessage.Parts[0]).Text); + Assert.Equal(Role.User, inputMessage.Role); + Assert.Equal("Hello, streaming!", inputMessage.Parts[0].Text); // Assert response from A2AClient is converted correctly Assert.Equal(ChatRole.Assistant, updates[0].Role); @@ -251,8 +261,8 @@ public async Task RunStreamingAsync_WithValidUserMessage_YieldsAgentResponseUpda Assert.Equal(this._agent.Id, updates[0].AgentId); Assert.Equal("stream-1", updates[0].ResponseId); Assert.Equal(ChatFinishReason.Stop, updates[0].FinishReason); - Assert.IsType(updates[0].RawRepresentation); - Assert.Equal("stream-1", ((AgentMessage)updates[0].RawRepresentation!).MessageId); + Assert.IsType(updates[0].RawRepresentation); + Assert.Equal("stream-1", ((Message)updates[0].RawRepresentation!).MessageId); } [Fact] @@ -264,12 +274,15 @@ public async Task RunStreamingAsync_WithSession_UpdatesSessionConversationIdAsyn new(ChatRole.User, "Test streaming") }; - this._handler.StreamingResponseToReturn = new AgentMessage() + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = "stream-1", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }], - ContextId = "new-stream-context" + Message = new Message + { + MessageId = "stream-1", + Role = Role.Agent, + Parts = [new Part { Text = "Response" }], + ContextId = "new-stream-context" + } }; var session = await this._agent.CreateSessionAsync(); @@ -294,7 +307,7 @@ public async Task RunStreamingAsync_WithExistingSession_SetConversationIdToMessa new(ChatRole.User, "Test streaming") }; - this._handler.StreamingResponseToReturn = new AgentMessage(); + this._handler.StreamingResponseToReturn = new StreamResponse { Message = new Message() }; var session = await this._agent.CreateSessionAsync(); var a2aSession = (A2AAgentSession)session; @@ -307,7 +320,7 @@ public async Task RunStreamingAsync_WithExistingSession_SetConversationIdToMessa } // Assert - var message = this._handler.CapturedMessageSendParams?.Message; + var message = this._handler.CapturedSendMessageRequest?.Message; Assert.NotNull(message); Assert.Equal("existing-context-id", message.ContextId); } @@ -325,12 +338,15 @@ public async Task RunStreamingAsync_WithSessionHavingDifferentContextId_ThrowsIn new(ChatRole.User, "Test streaming") }; - this._handler.StreamingResponseToReturn = new AgentMessage() + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = "stream-1", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }], - ContextId = "different-context" + Message = new Message + { + MessageId = "stream-1", + Role = Role.Agent, + Parts = [new Part { Text = "Response" }], + ContextId = "different-context" + } }; // Act @@ -346,12 +362,15 @@ await Assert.ThrowsAsync(async () => public async Task RunStreamingAsync_AllowsNonUserRoleMessagesAsync() { // Arrange - this._handler.StreamingResponseToReturn = new AgentMessage() + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = "stream-1", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }], - ContextId = "new-stream-context" + Message = new Message + { + MessageId = "stream-1", + Role = Role.Agent, + Parts = [new Part { Text = "Response" }], + ContextId = "new-stream-context" + } }; var inputMessages = new List @@ -385,13 +404,13 @@ public async Task RunAsync_WithHostedFileContent_ConvertsToFilePartAsync() await this._agent.RunAsync(inputMessages); // Assert - var message = this._handler.CapturedMessageSendParams?.Message; + var message = this._handler.CapturedSendMessageRequest?.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", ((FilePart)message.Parts[1]).File.Uri?.ToString()); + Assert.Equal(PartContentCase.Text, message.Parts[0].ContentCase); + Assert.Equal("Check this file:", message.Parts[0].Text); + Assert.Equal(PartContentCase.Url, message.Parts[1].ContentCase); + Assert.Equal("https://example.com/file.pdf", message.Parts[1].Url); } [Fact] @@ -413,10 +432,11 @@ public async Task RunAsync_WithContinuationTokenAndMessages_ThrowsInvalidOperati public async Task RunAsync_WithContinuationToken_CallsGetTaskAsyncAsync() { // Arrange - this._handler.ResponseToReturn = new AgentTask + this._handler.AgentTaskToReturn = new AgentTask { Id = "task-123", - ContextId = "context-123" + ContextId = "context-123", + Status = new() { State = TaskState.Submitted } }; var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken("task-123") }; @@ -425,19 +445,22 @@ public async Task RunAsync_WithContinuationToken_CallsGetTaskAsyncAsync() await this._agent.RunAsync([], options: options); // Assert - Assert.Equal("tasks/get", this._handler.CapturedJsonRpcRequest?.Method); - Assert.Equal("task-123", this._handler.CapturedTaskIdParams?.Id); + Assert.Equal("GetTask", this._handler.CapturedJsonRpcRequest?.Method); + Assert.Equal("task-123", this._handler.CapturedGetTaskRequest?.Id); } [Fact] public async Task RunAsync_WithTaskInSessionAndMessage_AddTaskAsReferencesToMessageAsync() { // Arrange - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response to task" }] + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Response to task" }] + } }; var session = (A2AAgentSession)await this._agent.CreateSessionAsync(); @@ -449,7 +472,7 @@ public async Task RunAsync_WithTaskInSessionAndMessage_AddTaskAsReferencesToMess await this._agent.RunAsync(inputMessage, session); // Assert - var message = this._handler.CapturedMessageSendParams?.Message; + var message = this._handler.CapturedSendMessageRequest?.Message; Assert.Null(message?.TaskId); Assert.NotNull(message?.ReferenceTaskIds); Assert.Contains("task-123", message.ReferenceTaskIds); @@ -459,11 +482,14 @@ public async Task RunAsync_WithTaskInSessionAndMessage_AddTaskAsReferencesToMess public async Task RunAsync_WithAgentTask_UpdatesSessionTaskIdAsync() { // Arrange - this._handler.ResponseToReturn = new AgentTask + this._handler.ResponseToReturn = new SendMessageResponse { - Id = "task-456", - ContextId = "context-789", - Status = new() { State = TaskState.Submitted } + Task = new AgentTask + { + Id = "task-456", + ContextId = "context-789", + Status = new() { State = TaskState.Submitted } + } }; var session = await this._agent.CreateSessionAsync(); @@ -480,16 +506,19 @@ public async Task RunAsync_WithAgentTask_UpdatesSessionTaskIdAsync() public async Task RunAsync_WithAgentTaskResponse_ReturnsTaskResponseCorrectlyAsync() { // Arrange - this._handler.ResponseToReturn = new AgentTask + this._handler.ResponseToReturn = new SendMessageResponse { - Id = "task-789", - ContextId = "context-456", - Status = new() { State = TaskState.Submitted }, - Metadata = new Dictionary + Task = new AgentTask + { + Id = "task-789", + ContextId = "context-456", + Status = new() { State = TaskState.Submitted }, + Metadata = new Dictionary { { "key1", JsonSerializer.SerializeToElement("value1") }, { "count", JsonSerializer.SerializeToElement(42) } } + } }; var session = await this._agent.CreateSessionAsync(); @@ -532,11 +561,14 @@ public async Task RunAsync_WithAgentTaskResponse_ReturnsTaskResponseCorrectlyAsy public async Task RunAsync_WithVariousTaskStates_ReturnsCorrectTokenAsync(TaskState taskState) { // Arrange - this._handler.ResponseToReturn = new AgentTask + this._handler.ResponseToReturn = new SendMessageResponse { - Id = "task-123", - ContextId = "context-123", - Status = new() { State = taskState } + Task = new AgentTask + { + Id = "task-123", + ContextId = "context-123", + Status = new() { State = taskState } + } }; // Act @@ -583,15 +615,76 @@ await Assert.ThrowsAsync(async () => }); } + [Fact] + public async Task RunStreamingAsync_WithContinuationToken_UsesSubscribeToTaskMethodAsync() + { + // Arrange + this._handler.StreamingResponseToReturn = new StreamResponse + { + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Continuation response" }] + } + }; + + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken("task-456") }; + + // Act + await foreach (var _ in this._agent.RunStreamingAsync([], null, options)) + { + // Just iterate through to trigger the logic + } + + // Assert - verify SubscribeToTask was called (not SendStreamingMessage) + Assert.Single(this._handler.CapturedJsonRpcRequests); + Assert.Equal("SubscribeToTask", this._handler.CapturedJsonRpcRequests[0].Method); + } + + [Fact] + public async Task RunStreamingAsync_WithContinuationToken_PassesCorrectTaskIdAsync() + { + // Arrange + this._handler.StreamingResponseToReturn = new StreamResponse + { + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Continuation response" }] + } + }; + + const string expectedTaskId = "my-task-789"; + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken(expectedTaskId) }; + + // Act + await foreach (var _ in this._agent.RunStreamingAsync([], null, options)) + { + // Just iterate through to trigger the logic + } + + // Assert - verify the task ID was passed correctly + Assert.NotEmpty(this._handler.CapturedJsonRpcRequests); + var subscribeRequest = this._handler.CapturedJsonRpcRequests[0]; + var subscribeParams = subscribeRequest.Params?.Deserialize(A2AJsonUtilities.DefaultOptions); + Assert.NotNull(subscribeParams); + Assert.Equal(expectedTaskId, subscribeParams.Id); + } + [Fact] public async Task RunStreamingAsync_WithTaskInSessionAndMessage_AddTaskAsReferencesToMessageAsync() { // Arrange - this._handler.StreamingResponseToReturn = new AgentMessage + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response to task" }] + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Response to task" }] + } }; var session = (A2AAgentSession)await this._agent.CreateSessionAsync(); @@ -604,7 +697,7 @@ public async Task RunStreamingAsync_WithTaskInSessionAndMessage_AddTaskAsReferen } // Assert - var message = this._handler.CapturedMessageSendParams?.Message; + var message = this._handler.CapturedSendMessageRequest?.Message; Assert.Null(message?.TaskId); Assert.NotNull(message?.ReferenceTaskIds); Assert.Contains("task-123", message.ReferenceTaskIds); @@ -614,11 +707,14 @@ public async Task RunStreamingAsync_WithTaskInSessionAndMessage_AddTaskAsReferen public async Task RunStreamingAsync_WithAgentTask_UpdatesSessionTaskIdAsync() { // Arrange - this._handler.StreamingResponseToReturn = new AgentTask + this._handler.StreamingResponseToReturn = new StreamResponse { - Id = "task-456", - ContextId = "context-789", - Status = new() { State = TaskState.Submitted } + Task = new AgentTask + { + Id = "task-456", + ContextId = "context-789", + Status = new() { State = TaskState.Submitted } + } }; var session = await this._agent.CreateSessionAsync(); @@ -642,15 +738,18 @@ public async Task RunStreamingAsync_WithAgentMessage_YieldsResponseUpdateAsync() const string ContextId = "ctx-456"; const string MessageText = "Hello from agent!"; - this._handler.StreamingResponseToReturn = new AgentMessage + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = MessageId, - Role = MessageRole.Agent, - ContextId = ContextId, - Parts = - [ - new TextPart { Text = MessageText } - ] + Message = new Message + { + MessageId = MessageId, + Role = Role.Agent, + ContextId = ContextId, + Parts = + [ + new Part { Text = MessageText } + ] + } }; // Act @@ -670,8 +769,8 @@ public async Task RunStreamingAsync_WithAgentMessage_YieldsResponseUpdateAsync() Assert.Equal(this._agent.Id, update0.AgentId); Assert.Equal(MessageText, update0.Text); Assert.Equal(ChatFinishReason.Stop, update0.FinishReason); - Assert.IsType(update0.RawRepresentation); - Assert.Equal(MessageId, ((AgentMessage)update0.RawRepresentation!).MessageId); + Assert.IsType(update0.RawRepresentation); + Assert.Equal(MessageId, ((Message)update0.RawRepresentation!).MessageId); } [Fact] @@ -681,18 +780,21 @@ public async Task RunStreamingAsync_WithAgentTask_YieldsResponseUpdateAsync() const string TaskId = "task-789"; const string ContextId = "ctx-012"; - this._handler.StreamingResponseToReturn = new AgentTask + this._handler.StreamingResponseToReturn = new StreamResponse { - Id = TaskId, - ContextId = ContextId, - Status = new() { State = TaskState.Submitted }, - Artifacts = [ + Task = new AgentTask + { + Id = TaskId, + ContextId = ContextId, + Status = new() { State = TaskState.Submitted }, + Artifacts = [ new() { ArtifactId = "art-123", - Parts = [new TextPart { Text = "Task artifact content" }] + Parts = [new Part { Text = "Task artifact content" }] } ] + } }; var session = await this._agent.CreateSessionAsync(); @@ -728,11 +830,14 @@ public async Task RunStreamingAsync_WithTaskStatusUpdateEvent_YieldsResponseUpda const string TaskId = "task-status-123"; const string ContextId = "ctx-status-456"; - this._handler.StreamingResponseToReturn = new TaskStatusUpdateEvent + this._handler.StreamingResponseToReturn = new StreamResponse { - TaskId = TaskId, - ContextId = ContextId, - Status = new() { State = TaskState.Working } + StatusUpdate = new TaskStatusUpdateEvent + { + TaskId = TaskId, + ContextId = ContextId, + Status = new() { State = TaskState.Working } + } }; var session = await this._agent.CreateSessionAsync(); @@ -768,14 +873,17 @@ public async Task RunStreamingAsync_WithTaskArtifactUpdateEvent_YieldsResponseUp const string ContextId = "ctx-artifact-456"; const string ArtifactContent = "Task artifact data"; - this._handler.StreamingResponseToReturn = new TaskArtifactUpdateEvent + this._handler.StreamingResponseToReturn = new StreamResponse { - TaskId = TaskId, - ContextId = ContextId, - Artifact = new() + ArtifactUpdate = new TaskArtifactUpdateEvent { - ArtifactId = "artifact-789", - Parts = [new TextPart { Text = ArtifactContent }] + TaskId = TaskId, + ContextId = ContextId, + Artifact = new() + { + ArtifactId = "artifact-789", + Parts = [new Part { Text = ArtifactContent }] + } } }; @@ -848,15 +956,18 @@ await Assert.ThrowsAsync(async () => public async Task RunAsync_WithAgentMessageResponseMetadata_ReturnsMetadataAsAdditionalPropertiesAsync() { // Arrange - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response with metadata" }], - Metadata = new Dictionary + Message = new Message { - { "responseKey1", JsonSerializer.SerializeToElement("responseValue1") }, - { "responseCount", JsonSerializer.SerializeToElement(99) } + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Response with metadata" }], + Metadata = new Dictionary + { + { "responseKey1", JsonSerializer.SerializeToElement("responseValue1") }, + { "responseCount", JsonSerializer.SerializeToElement(99) } + } } }; @@ -877,14 +988,17 @@ public async Task RunAsync_WithAgentMessageResponseMetadata_ReturnsMetadataAsAdd } [Fact] - public async Task RunAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMessageSendParamsAsync() + public async Task RunAsync_WithAdditionalProperties_PropagatesThemAsMetadataToSendMessageRequestAsync() { // Arrange - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }] + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Response" }] + } }; var inputMessages = new List @@ -906,22 +1020,25 @@ public async Task RunAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMe await this._agent.RunAsync(inputMessages, null, options); // Assert - Assert.NotNull(this._handler.CapturedMessageSendParams); - Assert.NotNull(this._handler.CapturedMessageSendParams.Metadata); - Assert.Equal("value1", this._handler.CapturedMessageSendParams.Metadata["key1"].GetString()); - Assert.Equal(42, this._handler.CapturedMessageSendParams.Metadata["key2"].GetInt32()); - Assert.True(this._handler.CapturedMessageSendParams.Metadata["key3"].GetBoolean()); + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.NotNull(this._handler.CapturedSendMessageRequest.Metadata); + Assert.Equal("value1", this._handler.CapturedSendMessageRequest.Metadata["key1"].GetString()); + Assert.Equal(42, this._handler.CapturedSendMessageRequest.Metadata["key2"].GetInt32()); + Assert.True(this._handler.CapturedSendMessageRequest.Metadata["key3"].GetBoolean()); } [Fact] public async Task RunAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync() { // Arrange - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }] + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Response" }] + } }; var inputMessages = new List @@ -938,19 +1055,22 @@ public async Task RunAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync( await this._agent.RunAsync(inputMessages, null, options); // Assert - Assert.NotNull(this._handler.CapturedMessageSendParams); - Assert.Null(this._handler.CapturedMessageSendParams.Metadata); + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.Null(this._handler.CapturedSendMessageRequest.Metadata); } [Fact] - public async Task RunStreamingAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMessageSendParamsAsync() + public async Task RunStreamingAsync_WithAdditionalProperties_PropagatesThemAsMetadataToSendMessageRequestAsync() { // Arrange - this._handler.StreamingResponseToReturn = new AgentMessage + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = "stream-123", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Streaming response" }] + Message = new Message + { + MessageId = "stream-123", + Role = Role.Agent, + Parts = [new Part { Text = "Streaming response" }] + } }; var inputMessages = new List @@ -974,22 +1094,25 @@ public async Task RunStreamingAsync_WithAdditionalProperties_PropagatesThemAsMet } // Assert - Assert.NotNull(this._handler.CapturedMessageSendParams); - Assert.NotNull(this._handler.CapturedMessageSendParams.Metadata); - Assert.Equal("streamValue1", this._handler.CapturedMessageSendParams.Metadata["streamKey1"].GetString()); - Assert.Equal(100, this._handler.CapturedMessageSendParams.Metadata["streamKey2"].GetInt32()); - Assert.False(this._handler.CapturedMessageSendParams.Metadata["streamKey3"].GetBoolean()); + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.NotNull(this._handler.CapturedSendMessageRequest.Metadata); + Assert.Equal("streamValue1", this._handler.CapturedSendMessageRequest.Metadata["streamKey1"].GetString()); + Assert.Equal(100, this._handler.CapturedSendMessageRequest.Metadata["streamKey2"].GetInt32()); + Assert.False(this._handler.CapturedSendMessageRequest.Metadata["streamKey3"].GetBoolean()); } [Fact] public async Task RunStreamingAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync() { // Arrange - this._handler.StreamingResponseToReturn = new AgentMessage + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = "stream-123", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Streaming response" }] + Message = new Message + { + MessageId = "stream-123", + Role = Role.Agent, + Parts = [new Part { Text = "Streaming response" }] + } }; var inputMessages = new List @@ -1008,8 +1131,115 @@ public async Task RunStreamingAsync_WithNullAdditionalProperties_DoesNotSetMetad } // Assert - Assert.NotNull(this._handler.CapturedMessageSendParams); - Assert.Null(this._handler.CapturedMessageSendParams.Metadata); + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.Null(this._handler.CapturedSendMessageRequest.Metadata); + } + + [Fact] + public async Task RunAsync_WithDefaultOptions_SetsBlockingToTrueAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + // Act + await this._agent.RunAsync(inputMessages); + + // Assert + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.NotNull(this._handler.CapturedSendMessageRequest.Configuration); + Assert.True(this._handler.CapturedSendMessageRequest.Configuration.Blocking); + } + + [Fact] + public async Task RunAsync_WithAllowBackgroundResponsesTrue_SetsBlockingToFalseAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + var session = await this._agent.CreateSessionAsync(); + var options = new AgentRunOptions { AllowBackgroundResponses = true }; + + // Act + await this._agent.RunAsync(inputMessages, session, options); + + // Assert + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.NotNull(this._handler.CapturedSendMessageRequest.Configuration); + Assert.False(this._handler.CapturedSendMessageRequest.Configuration.Blocking); + } + + [Fact] + public async Task RunAsync_WithAllowBackgroundResponsesFalse_SetsBlockingToTrueAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + var options = new AgentRunOptions { AllowBackgroundResponses = false }; + + // Act + await this._agent.RunAsync(inputMessages, null, options); + + // Assert + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.NotNull(this._handler.CapturedSendMessageRequest.Configuration); + Assert.True(this._handler.CapturedSendMessageRequest.Configuration.Blocking); + } + + [Fact] + public async Task RunAsync_WithNullOptions_SetsBlockingToTrueAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + // Act + await this._agent.RunAsync(inputMessages, null, null); + + // Assert + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.NotNull(this._handler.CapturedSendMessageRequest.Configuration); + Assert.True(this._handler.CapturedSendMessageRequest.Configuration.Blocking); + } + + [Fact] + public async Task RunStreamingAsync_SendMessageRequest_DoesNotSetBlockingConfigurationAsync() + { + // Arrange + this._handler.StreamingResponseToReturn = new StreamResponse + { + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Streaming response" }] + } + }; + + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + // Act + await foreach (var _ in this._agent.RunStreamingAsync(inputMessages)) + { + // Just iterate through to trigger the logic + } + + // Assert + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.Null(this._handler.CapturedSendMessageRequest.Configuration); } [Fact] @@ -1256,6 +1486,7 @@ await Assert.ThrowsAnyAsync(async () => public void Dispose() { + this._a2aClient.Dispose(); this._handler.Dispose(); this._httpClient.Dispose(); } @@ -1269,13 +1500,17 @@ internal sealed class A2AClientHttpMessageHandlerStub : HttpMessageHandler { public JsonRpcRequest? CapturedJsonRpcRequest { get; set; } - public MessageSendParams? CapturedMessageSendParams { get; set; } + public List CapturedJsonRpcRequests { get; } = []; + + public SendMessageRequest? CapturedSendMessageRequest { get; set; } - public TaskIdParams? CapturedTaskIdParams { get; set; } + public GetTaskRequest? CapturedGetTaskRequest { get; set; } - public A2AEvent? ResponseToReturn { get; set; } + public SendMessageResponse? ResponseToReturn { get; set; } - public A2AEvent? StreamingResponseToReturn { get; set; } + public AgentTask? AgentTaskToReturn { get; set; } + + public StreamResponse? StreamingResponseToReturn { get; set; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { @@ -1286,22 +1521,46 @@ protected override async Task SendAsync(HttpRequestMessage this.CapturedJsonRpcRequest = JsonSerializer.Deserialize(content); + if (this.CapturedJsonRpcRequest is not null) + { + this.CapturedJsonRpcRequests.Add(this.CapturedJsonRpcRequest); + } + try { - this.CapturedMessageSendParams = this.CapturedJsonRpcRequest?.Params?.Deserialize(); + this.CapturedSendMessageRequest = this.CapturedJsonRpcRequest?.Params?.Deserialize(A2AJsonUtilities.DefaultOptions); } - catch { /* Ignore deserialization errors for non-MessageSendParams requests */ } + catch { /* Ignore deserialization errors for non-SendMessageRequest requests */ } try { - this.CapturedTaskIdParams = this.CapturedJsonRpcRequest?.Params?.Deserialize(); + this.CapturedGetTaskRequest = this.CapturedJsonRpcRequest?.Params?.Deserialize(A2AJsonUtilities.DefaultOptions); + } + catch { /* Ignore deserialization errors for non-GetTaskRequest requests */ } + + // Return the pre-configured AgentTask response (for tasks/get) + if (this.AgentTaskToReturn is not null && this.CapturedJsonRpcRequest?.Method == "GetTask") + { + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Result = JsonSerializer.SerializeToNode(this.AgentTaskToReturn, A2AJsonUtilities.DefaultOptions) + }; + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json") + }; } - catch { /* Ignore deserialization errors for non-TaskIdParams requests */ } // Return the pre-configured non-streaming response if (this.ResponseToReturn is not null) { - var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("response-id", this.ResponseToReturn); + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Result = JsonSerializer.SerializeToNode(this.ResponseToReturn, A2AJsonUtilities.DefaultOptions) + }; return new HttpResponseMessage(HttpStatusCode.OK) { @@ -1311,22 +1570,18 @@ protected override async Task SendAsync(HttpRequestMessage // 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 - ); + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Result = JsonSerializer.SerializeToNode(this.StreamingResponseToReturn, A2AJsonUtilities.DefaultOptions) + }; + var stream = new MemoryStream(); + var writer = new StreamWriter(stream); + await writer.WriteAsync($"data: {JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions)}\n\n"); +#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods; overload doesn't exist downlevel + await writer.FlushAsync(); +#pragma warning restore CA2016 stream.Position = 0; return new HttpResponseMessage(HttpStatusCode.OK) @@ -1339,7 +1594,11 @@ await SseFormatter.WriteAsync( } else { - var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("response-id", new AgentMessage()); + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Result = JsonSerializer.SerializeToNode(new SendMessageResponse { Message = new Message() }, A2AJsonUtilities.DefaultOptions) + }; return new HttpResponseMessage(HttpStatusCode.OK) { diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAIContentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAIContentExtensionsTests.cs index 358bdfb152..c2e704833a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAIContentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAIContentExtensionsTests.cs @@ -42,14 +42,14 @@ public void ToA2AParts_WithMultipleContents_ReturnsListWithAllParts() Assert.NotNull(result); Assert.Equal(3, result.Count); - var firstTextPart = Assert.IsType(result[0]); - Assert.Equal("First text", firstTextPart.Text); + Assert.Equal(PartContentCase.Text, result[0].ContentCase); + Assert.Equal("First text", result[0].Text); - var filePart = Assert.IsType(result[1]); - Assert.Equal("https://example.com/file1.txt", filePart.File.Uri?.ToString()); + Assert.Equal(PartContentCase.Url, result[1].ContentCase); + Assert.Equal("https://example.com/file1.txt", result[1].Url); - var secondTextPart = Assert.IsType(result[2]); - Assert.Equal("Second text", secondTextPart.Text); + Assert.Equal(PartContentCase.Text, result[2].ContentCase); + Assert.Equal("Second text", result[2].Text); } [Fact] @@ -72,14 +72,14 @@ public void ToA2AParts_WithMixedSupportedAndUnsupportedContent_IgnoresUnsupporte Assert.NotNull(result); Assert.Equal(3, result.Count); - var firstTextPart = Assert.IsType(result[0]); - Assert.Equal("First text", firstTextPart.Text); + Assert.Equal(PartContentCase.Text, result[0].ContentCase); + Assert.Equal("First text", result[0].Text); - var filePart = Assert.IsType(result[1]); - Assert.Equal("https://example.com/file.txt", filePart.File.Uri?.ToString()); + Assert.Equal(PartContentCase.Url, result[1].ContentCase); + Assert.Equal("https://example.com/file.txt", result[1].Url); - var secondTextPart = Assert.IsType(result[2]); - Assert.Equal("Second text", secondTextPart.Text); + Assert.Equal(PartContentCase.Text, result[2].ContentCase); + Assert.Equal("Second text", result[2].Text); } // Mock class for testing unsupported scenarios diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs index f644109b38..abf1aa2325 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Net.Http; using System.Text; @@ -26,7 +27,7 @@ public A2AAgentCardExtensionsTests() { Name = "Test Agent", Description = "A test agent for unit testing", - Url = "http://test-endpoint/agent" + SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }] }; } @@ -50,10 +51,10 @@ public async Task RunIAgentAsync_SendsRequestToTheUrlSpecifiedInAgentCardAsync() using var handler = new HttpMessageHandlerStub(); using var httpClient = new HttpClient(handler, false); - handler.ResponsesToReturn.Enqueue(new AgentMessage + handler.ResponsesToReturn.Enqueue(new Message { - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }], + Role = Role.Agent, + Parts = [Part.FromText("Response")], }); var agent = this._agentCard.AsAIAgent(httpClient); @@ -66,6 +67,42 @@ public async Task RunIAgentAsync_SendsRequestToTheUrlSpecifiedInAgentCardAsync() Assert.Equal(new Uri("http://test-endpoint/agent"), handler.CapturedUris[0]); } + [Fact] + public async Task AsAIAgent_WithInterfaceSelector_UsesSelectedInterfaceAsync() + { + // Arrange + var card = new AgentCard + { + Name = "Multi-Interface Agent", + Description = "An agent with multiple interfaces", + SupportedInterfaces = + [ + new AgentInterface { Url = "http://first/agent" }, + new AgentInterface { Url = "http://second/agent", ProtocolBinding = "grpc" }, + new AgentInterface { Url = "http://third/agent", ProtocolBinding = "http" }, + ] + }; + + using var handler = new HttpMessageHandlerStub(); + using var httpClient = new HttpClient(handler, false); + + handler.ResponsesToReturn.Enqueue(new Message + { + Role = Role.Agent, + Parts = [Part.FromText("Response")], + }); + + var agent = card.AsAIAgent(httpClient, interfaceSelector: interfaces => + interfaces.First(i => i.ProtocolBinding == "http")); + + // Act + await agent.RunAsync("Test input"); + + // Assert + Assert.Single(handler.CapturedUris); + Assert.Equal(new Uri("http://third/agent"), handler.CapturedUris[0]); + } + internal sealed class HttpMessageHandlerStub : HttpMessageHandler { public Queue ResponsesToReturn { get; } = new(); @@ -86,13 +123,18 @@ protected override async Task SendAsync(HttpRequestMessage Content = new StringContent(json, Encoding.UTF8, "application/json") }; } - else if (response is AgentMessage message) + else if (response is Message message) { - var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("response-id", message); + var sendMessageResponse = new SendMessageResponse { Message = message }; + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Result = JsonSerializer.SerializeToNode(sendMessageResponse, A2AJsonUtilities.DefaultOptions) + }; return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json") + Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions), Encoding.UTF8, "application/json") }; } diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs index 97c9ca7c05..5fdfb1ff89 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs @@ -40,7 +40,7 @@ public void ToChatMessages_WithEmptyArtifactsAndNoUserInputRequests_ReturnsNull( { Id = "task1", Artifacts = [], - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act @@ -58,7 +58,7 @@ public void ToChatMessages_WithNullArtifactsAndNoUserInputRequests_ReturnsNull() { Id = "task1", Artifacts = null, - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act @@ -76,7 +76,7 @@ public void ToAIContents_WithEmptyArtifactsAndNoUserInputRequests_ReturnsNull() { Id = "task1", Artifacts = [], - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act @@ -94,7 +94,7 @@ public void ToAIContents_WithNullArtifactsAndNoUserInputRequests_ReturnsNull() { Id = "task1", Artifacts = null, - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act @@ -110,14 +110,14 @@ public void ToChatMessages_WithValidArtifact_ReturnsChatMessages() // Arrange var artifact = new Artifact { - Parts = [new TextPart { Text = "response" }], + Parts = [Part.FromText("response")], }; var agentTask = new AgentTask { Id = "task1", Artifacts = [artifact], - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act @@ -136,15 +136,15 @@ public void ToAIContents_WithMultipleArtifacts_FlattenAllContents() // Arrange var artifact1 = new Artifact { - Parts = [new TextPart { Text = "content1" }], + Parts = [Part.FromText("content1")], }; var artifact2 = new Artifact { Parts = [ - new TextPart { Text = "content2" }, - new TextPart { Text = "content3" } + Part.FromText("content2"), + Part.FromText("content3") ], }; @@ -152,7 +152,7 @@ public void ToAIContents_WithMultipleArtifacts_FlattenAllContents() { Id = "task1", Artifacts = [artifact1, artifact2], - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs index b18abd4485..1f6cfa65f0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs @@ -22,9 +22,9 @@ public void ToChatMessage_WithMultiplePartsMetadataAndRawRepresentation_ReturnsC Name = "comprehensive-artifact", Parts = [ - new TextPart { Text = "First part" }, - new TextPart { Text = "Second part" }, - new TextPart { Text = "Third part" } + Part.FromText("First part"), + Part.FromText("Second part"), + Part.FromText("Third part") ], Metadata = new Dictionary { @@ -66,9 +66,9 @@ public void ToAIContents_WithMultipleParts_ReturnsCorrectList() Name = "test", Parts = [ - new TextPart { Text = "Part 1" }, - new TextPart { Text = "Part 2" }, - new TextPart { Text = "Part 3" } + Part.FromText("Part 1"), + Part.FromText("Part 2"), + Part.FromText("Part 3") ], Metadata = null }; diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs index dcc45e8fce..95cb2a67d2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs @@ -37,7 +37,7 @@ public async Task GetAIAgentAsync_WithValidAgentCard_ReturnsAIAgentAsync() { Name = "Test Agent", Description = "A test agent for unit testing", - Url = "http://test-endpoint/agent" + SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }] }); // Act @@ -60,12 +60,12 @@ public async Task RunIAgentAsync_WithUrlFromAgentCard_SendsRequestToTheUrlAsync( // Arrange this._handler.ResponsesToReturn.Enqueue(new AgentCard { - Url = "http://test-endpoint/agent" + SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }] }); - this._handler.ResponsesToReturn.Enqueue(new AgentMessage + this._handler.ResponsesToReturn.Enqueue(new Message { - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }], + Role = Role.Agent, + Parts = [Part.FromText("Response")], }); var agent = await this._resolver.GetAIAgentAsync(this._httpClient); @@ -104,13 +104,18 @@ protected override async Task SendAsync(HttpRequestMessage Content = new StringContent(json, Encoding.UTF8, "application/json") }; } - else if (response is AgentMessage message) + else if (response is Message message) { - var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("response-id", message); + var sendMessageResponse = new SendMessageResponse { Message = message }; + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Result = JsonSerializer.SerializeToNode(sendMessageResponse, A2AJsonUtilities.DefaultOptions) + }; return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json") + Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions), Encoding.UTF8, "application/json") }; } diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/ChatMessageExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/ChatMessageExtensionsTests.cs index 8d771c679c..bb502bbea0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/ChatMessageExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/ChatMessageExtensionsTests.cs @@ -32,20 +32,19 @@ public void ToA2AMessage_WithMessageContainingMultipleContents_AddsAllContentsAs Assert.NotNull(a2aMessage.MessageId); Assert.NotEmpty(a2aMessage.MessageId); - Assert.Equal(MessageRole.User, a2aMessage.Role); + Assert.Equal(Role.User, a2aMessage.Role); Assert.NotNull(a2aMessage.Parts); Assert.Equal(3, a2aMessage.Parts.Count); - var filePart = Assert.IsType(a2aMessage.Parts[0]); - Assert.NotNull(filePart.File); - Assert.Equal("https://example.com/report.pdf", filePart.File.Uri?.ToString()); + Assert.Equal(PartContentCase.Url, a2aMessage.Parts[0].ContentCase); + Assert.Equal("https://example.com/report.pdf", a2aMessage.Parts[0].Url); - var secondTextPart = Assert.IsType(a2aMessage.Parts[1]); - Assert.Equal("please summarize the file content", secondTextPart.Text); + Assert.Equal(PartContentCase.Text, a2aMessage.Parts[1].ContentCase); + Assert.Equal("please summarize the file content", a2aMessage.Parts[1].Text); - var thirdTextPart = Assert.IsType(a2aMessage.Parts[2]); - Assert.Equal("and send it to me over email", thirdTextPart.Text); + Assert.Equal(PartContentCase.Text, a2aMessage.Parts[2].ContentCase); + Assert.Equal("and send it to me over email", a2aMessage.Parts[2].Text); } [Fact] @@ -71,19 +70,18 @@ public void ToA2AMessage_WithMixedMessages_AddsAllContentsAsParts() Assert.NotNull(a2aMessage.MessageId); Assert.NotEmpty(a2aMessage.MessageId); - Assert.Equal(MessageRole.User, a2aMessage.Role); + Assert.Equal(Role.User, a2aMessage.Role); Assert.NotNull(a2aMessage.Parts); Assert.Equal(3, a2aMessage.Parts.Count); - var filePart = Assert.IsType(a2aMessage.Parts[0]); - Assert.NotNull(filePart.File); - Assert.Equal("https://example.com/report.pdf", filePart.File.Uri?.ToString()); + Assert.Equal(PartContentCase.Url, a2aMessage.Parts[0].ContentCase); + Assert.Equal("https://example.com/report.pdf", a2aMessage.Parts[0].Url); - var secondTextPart = Assert.IsType(a2aMessage.Parts[1]); - Assert.Equal("please summarize the file content", secondTextPart.Text); + Assert.Equal(PartContentCase.Text, a2aMessage.Parts[1].ContentCase); + Assert.Equal("please summarize the file content", a2aMessage.Parts[1].Text); - var thirdTextPart = Assert.IsType(a2aMessage.Parts[2]); - Assert.Equal("and send it to me over email", thirdTextPart.Text); + Assert.Equal(PartContentCase.Text, a2aMessage.Parts[2].ContentCase); + Assert.Equal("and send it to me over email", a2aMessage.Parts[2].Text); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj index d33de0613b..97541f6a94 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj @@ -1,5 +1,9 @@ + + $(TargetFrameworksCore) + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AIntegrationTests.cs index f8604c7eac..e7d77fed38 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AIntegrationTests.cs @@ -16,9 +16,17 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; public sealed class A2AIntegrationTests { /// - /// Verifies that calling the A2A card endpoint with MapA2A returns an agent card with a URL populated. + /// Verifies that calling the A2A well-known agent card endpoint returns the configured agent card. /// + /// + /// Skipped on .NET 8 because the A2A.AspNetCore SDK's MapWellKnownAgentCard uses + /// PipeWriter.UnflushedBytes which requires .NET 9+. + /// +#if NET9_0_OR_GREATER [Fact] +#else + [Fact(Skip = "A2A.AspNetCore MapWellKnownAgentCard requires .NET 9+ (PipeWriter.UnflushedBytes)")] +#endif public async Task MapA2A_WithAgentCard_CardEndpointReturnsCardWithUrlAsync() { // Arrange @@ -36,7 +44,11 @@ public async Task MapA2A_WithAgentCard_CardEndpointReturnsCardWithUrlAsync() { Name = "Test Agent", Description = "A test agent for A2A communication", - Version = "1.0" + Version = "1.0", + SupportedInterfaces = + [ + new AgentInterface { Url = "http://localhost/a2a/test-agent" } + ] }; // Map A2A with the agent card @@ -51,10 +63,11 @@ public async Task MapA2A_WithAgentCard_CardEndpointReturnsCardWithUrlAsync() ?? throw new InvalidOperationException("TestServer not found"); var httpClient = testServer.CreateClient(); - // Act - Query the agent card endpoint - var requestUri = new Uri("/a2a/test-agent/v1/card", UriKind.Relative); + // Act - Query the well-known agent card endpoint + var requestUri = new Uri("/.well-known/agent-card.json", UriKind.Relative); var response = await httpClient.GetAsync(requestUri); + // Assert // Assert Assert.True(response.IsSuccessStatusCode, $"Expected successful response but got {response.StatusCode}"); @@ -69,17 +82,17 @@ public async Task MapA2A_WithAgentCard_CardEndpointReturnsCardWithUrlAsync() Assert.True(root.TryGetProperty("description", out var descProperty)); Assert.Equal("A test agent for A2A communication", descProperty.GetString()); - // Verify the card has a URL property and it's not null/empty - Assert.True(root.TryGetProperty("url", out var urlProperty)); - Assert.NotEqual(JsonValueKind.Null, urlProperty.ValueKind); + // Verify the card has a supportedInterfaces property with a URL + Assert.True(root.TryGetProperty("supportedInterfaces", out var interfacesProp)); + Assert.NotEqual(JsonValueKind.Null, interfacesProp.ValueKind); + Assert.True(interfacesProp.GetArrayLength() > 0); + var firstInterface = interfacesProp[0]; + Assert.True(firstInterface.TryGetProperty("url", out var urlProperty)); var url = urlProperty.GetString(); Assert.NotNull(url); Assert.NotEmpty(url); - Assert.StartsWith("http", url, StringComparison.OrdinalIgnoreCase); - - // agentCard's URL matches the agent endpoint - Assert.Equal($"{testServer.BaseAddress.ToString().TrimEnd('/')}/a2a/test-agent", url); + Assert.Equal("http://localhost/a2a/test-agent", url); } finally { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs index 87de6e52cd..5ce7a0f7c9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs @@ -19,7 +19,7 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; public sealed class AIAgentExtensionsTests { /// - /// Verifies that when messageSendParams.Metadata is null, the options passed to RunAsync have + /// Verifies that when sendMessageRequest.Metadata is null, the options passed to RunAsync have /// AllowBackgroundResponses enabled and no AdditionalProperties. /// [Fact] @@ -27,14 +27,14 @@ public async Task MapA2A_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropert { // Arrange AgentRunOptions? capturedOptions = null; - ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A(); + IA2ARequestHandler handler = CreateAgentMock(options => capturedOptions = options).Object.MapA2A(); // Act - await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }, + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Hello")] }, Metadata = null - }); + }, CancellationToken.None); // Assert Assert.NotNull(capturedOptions); @@ -43,25 +43,25 @@ public async Task MapA2A_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropert } /// - /// Verifies that when messageSendParams.Metadata has values, the options.AdditionalProperties contains the converted values. + /// Verifies that when sendMessageRequest.Metadata has values, the options.AdditionalProperties contains the converted values. /// [Fact] public async Task MapA2A_WhenMetadataHasValues_PassesOptionsWithAdditionalPropertiesToRunAsync() { // Arrange AgentRunOptions? capturedOptions = null; - ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A(); + IA2ARequestHandler handler = CreateAgentMock(options => capturedOptions = options).Object.MapA2A(); // Act - await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }, + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Hello")] }, Metadata = new Dictionary { ["key1"] = JsonSerializer.SerializeToElement("value1"), ["key2"] = JsonSerializer.SerializeToElement(42) } - }); + }, CancellationToken.None); // Assert Assert.NotNull(capturedOptions); @@ -72,7 +72,7 @@ public async Task MapA2A_WhenMetadataHasValues_PassesOptionsWithAdditionalProper } /// - /// Verifies that when messageSendParams.Metadata is an empty dictionary, the options passed to RunAsync have + /// Verifies that when sendMessageRequest.Metadata is an empty dictionary, the options passed to RunAsync have /// AllowBackgroundResponses enabled and no AdditionalProperties. /// [Fact] @@ -80,14 +80,14 @@ public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesOptionsWithNoAdditi { // Arrange AgentRunOptions? capturedOptions = null; - ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A(); + IA2ARequestHandler handler = CreateAgentMock(options => capturedOptions = options).Object.MapA2A(); // Act - await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }, + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Hello")] }, Metadata = [] - }); + }, CancellationToken.None); // Assert Assert.NotNull(capturedOptions); @@ -96,10 +96,10 @@ public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesOptionsWithNoAdditi } /// - /// Verifies that when the agent response has AdditionalProperties, the returned AgentMessage.Metadata contains the converted values. + /// Verifies that when the agent response has AdditionalProperties, the returned Message.Metadata contains the converted values. /// [Fact] - public async Task MapA2A_WhenResponseHasAdditionalProperties_ReturnsAgentMessageWithMetadataAsync() + public async Task MapA2A_WhenResponseHasAdditionalProperties_ReturnsMessageWithMetadataAsync() { // Arrange AdditionalPropertiesDictionary additionalProps = new() @@ -111,16 +111,18 @@ public async Task MapA2A_WhenResponseHasAdditionalProperties_ReturnsAgentMessage { AdditionalProperties = additionalProps }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + IA2ARequestHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Hello")] } + }, CancellationToken.None); // Assert - AgentMessage agentMessage = Assert.IsType(a2aResponse); + Assert.Equal(SendMessageResponseCase.Message, a2aResponse.PayloadCase); + Message agentMessage = a2aResponse.Message!; + Assert.NotNull(agentMessage); Assert.NotNull(agentMessage.Metadata); Assert.Equal(2, agentMessage.Metadata.Count); Assert.True(agentMessage.Metadata.ContainsKey("responseKey1")); @@ -130,121 +132,128 @@ public async Task MapA2A_WhenResponseHasAdditionalProperties_ReturnsAgentMessage } /// - /// Verifies that when the agent response has null AdditionalProperties, the returned AgentMessage.Metadata is null. + /// Verifies that when the agent response has null AdditionalProperties, the returned Message.Metadata is null. /// [Fact] - public async Task MapA2A_WhenResponseHasNullAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync() + public async Task MapA2A_WhenResponseHasNullAdditionalProperties_ReturnsMessageWithNullMetadataAsync() { // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")]) { AdditionalProperties = null }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + IA2ARequestHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Hello")] } + }, CancellationToken.None); // Assert - AgentMessage agentMessage = Assert.IsType(a2aResponse); + Assert.Equal(SendMessageResponseCase.Message, a2aResponse.PayloadCase); + Message agentMessage = a2aResponse.Message!; + Assert.NotNull(agentMessage); Assert.Null(agentMessage.Metadata); } /// - /// Verifies that when the agent response has empty AdditionalProperties, the returned AgentMessage.Metadata is null. + /// Verifies that when the agent response has empty AdditionalProperties, the returned Message.Metadata is null. /// [Fact] - public async Task MapA2A_WhenResponseHasEmptyAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync() + public async Task MapA2A_WhenResponseHasEmptyAdditionalProperties_ReturnsMessageWithNullMetadataAsync() { // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")]) { AdditionalProperties = [] }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + IA2ARequestHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Hello")] } + }, CancellationToken.None); // Assert - AgentMessage agentMessage = Assert.IsType(a2aResponse); + Assert.Equal(SendMessageResponseCase.Message, a2aResponse.PayloadCase); + Message agentMessage = a2aResponse.Message!; + Assert.NotNull(agentMessage); Assert.Null(agentMessage.Metadata); } /// - /// Verifies that when runMode is Message, the result is always an AgentMessage even when + /// Verifies that when runMode is Message, the result is always a Message even when /// the agent would otherwise support background responses. /// [Fact] - public async Task MapA2A_MessageMode_AlwaysReturnsAgentMessageAsync() + public async Task MapA2A_MessageMode_AlwaysReturnsMessageAsync() { // Arrange AgentRunOptions? capturedOptions = null; - ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options) + IA2ARequestHandler handler = CreateAgentMock(options => capturedOptions = options) .Object.MapA2A(runMode: AgentRunMode.DisallowBackground); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Hello")] } + }, CancellationToken.None); // Assert - Assert.IsType(a2aResponse); + Assert.Equal(SendMessageResponseCase.Message, a2aResponse.PayloadCase); + Assert.NotNull(a2aResponse.Message); Assert.NotNull(capturedOptions); Assert.False(capturedOptions.AllowBackgroundResponses); } /// /// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken), - /// the result is an AgentMessage because the response type is determined solely by ContinuationToken presence. + /// the result is a Message because the response type is determined solely by ContinuationToken presence. /// [Fact] - public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageAsync() + public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsMessageAsync() { // Arrange AgentRunOptions? capturedOptions = null; - ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options) + IA2ARequestHandler handler = CreateAgentMock(options => capturedOptions = options) .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Hello")] } + }, CancellationToken.None); // Assert - Assert.IsType(a2aResponse); + Assert.Equal(SendMessageResponseCase.Message, a2aResponse.PayloadCase); + Assert.NotNull(a2aResponse.Message); Assert.NotNull(capturedOptions); Assert.True(capturedOptions.AllowBackgroundResponses); } /// - /// Verifies that a custom Dynamic delegate returning false produces an AgentMessage + /// Verifies that a custom Dynamic delegate returning false produces a Message /// even when the agent completes immediately (no ContinuationToken). /// [Fact] - public async Task MapA2A_DynamicMode_WithFalseCallback_ReturnsAgentMessageAsync() + public async Task MapA2A_DynamicMode_WithFalseCallback_ReturnsMessageAsync() { // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Quick reply")]); - ITaskManager taskManager = CreateAgentMockWithResponse(response) + IA2ARequestHandler handler = CreateAgentMockWithResponse(response) .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false))); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Hello")] } + }, CancellationToken.None); // Assert - Assert.IsType(a2aResponse); + Assert.Equal(SendMessageResponseCase.Message, a2aResponse.PayloadCase); + Assert.NotNull(a2aResponse.Message); } #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -260,16 +269,18 @@ public async Task MapA2A_WhenResponseHasContinuationToken_ReturnsAgentTaskInWork { ContinuationToken = CreateTestContinuationToken() }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + IA2ARequestHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Hello")] } + }, CancellationToken.None); // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); + Assert.Equal(SendMessageResponseCase.Task, a2aResponse.PayloadCase); + AgentTask agentTask = a2aResponse.Task!; + Assert.NotNull(agentTask); Assert.Equal(TaskState.Working, agentTask.Status.State); } @@ -285,19 +296,21 @@ public async Task MapA2A_WhenResponseHasContinuationToken_TaskStatusHasIntermedi { ContinuationToken = CreateTestContinuationToken() }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + IA2ARequestHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Hello")] } + }, CancellationToken.None); // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); + AgentTask agentTask = a2aResponse.Task!; + Assert.NotNull(agentTask); Assert.NotNull(agentTask.Status.Message); - TextPart textPart = Assert.IsType(Assert.Single(agentTask.Status.Message.Parts)); - Assert.Equal("Starting work...", textPart.Text); + Part part = Assert.Single(agentTask.Status.Message.Parts); + Assert.Equal(PartContentCase.Text, part.ContentCase); + Assert.Equal("Starting work...", part.Text); } /// @@ -312,18 +325,20 @@ public async Task MapA2A_WhenResponseHasContinuationToken_StoresTokenInTaskMetad { ContinuationToken = CreateTestContinuationToken() }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + IA2ARequestHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Hello")] } + }, CancellationToken.None); // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.NotNull(agentTask.Metadata); - Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); + AgentTask agentTask = a2aResponse.Task!; + Assert.NotNull(agentTask); + AgentTask storedTask = (await handler.GetTaskAsync(new GetTaskRequest { Id = agentTask.Id }, CancellationToken.None))!; + Assert.NotNull(storedTask.Metadata); + Assert.True(storedTask.Metadata.ContainsKey("__a2a__continuationToken")); } /// @@ -338,51 +353,55 @@ public async Task MapA2A_WhenTaskIsCreated_OriginalMessageIsInHistoryAsync() { ContinuationToken = CreateTestContinuationToken() }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - AgentMessage originalMessage = new() { MessageId = "user-msg-1", Role = MessageRole.User, Parts = [new TextPart { Text = "Do something" }] }; + IA2ARequestHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); + Message originalMessage = new() { MessageId = "user-msg-1", Role = Role.User, Parts = [Part.FromText("Do something")] }; // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest { Message = originalMessage - }); + }, CancellationToken.None); // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.NotNull(agentTask.History); - Assert.Contains(agentTask.History, m => m.MessageId == "user-msg-1" && m.Role == MessageRole.User); + AgentTask agentTask = a2aResponse.Task!; + Assert.NotNull(agentTask); + AgentTask storedTask = (await handler.GetTaskAsync(new GetTaskRequest { Id = agentTask.Id }, CancellationToken.None))!; + Assert.NotNull(storedTask.History); + Assert.Contains(storedTask.History, m => m.MessageId == "user-msg-1" && m.Role == Role.User); } /// /// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken), - /// the returned AgentMessage preserves the original context ID. + /// the returned Message preserves the original context ID. /// [Fact] - public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageWithContextIdAsync() + public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsMessageWithContextIdAsync() { // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]); - ITaskManager taskManager = CreateAgentMockWithResponse(response) + IA2ARequestHandler handler = CreateAgentMockWithResponse(response) .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported); - AgentMessage originalMessage = new() { MessageId = "user-msg-2", ContextId = "ctx-123", Role = MessageRole.User, Parts = [new TextPart { Text = "Quick task" }] }; + Message originalMessage = new() { MessageId = "user-msg-2", ContextId = "ctx-123", Role = Role.User, Parts = [Part.FromText("Quick task")] }; // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest { Message = originalMessage - }); + }, CancellationToken.None); // Assert - AgentMessage agentMessage = Assert.IsType(a2aResponse); + Assert.Equal(SendMessageResponseCase.Message, a2aResponse.PayloadCase); + Message agentMessage = a2aResponse.Message!; + Assert.NotNull(agentMessage); Assert.Equal("ctx-123", agentMessage.ContextId); } /// - /// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token + /// Verifies that when a continuation is triggered on a task with a pending continuation token /// and the agent returns a completed response (null ContinuationToken), the task is updated to Completed. /// [Fact] - public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationCompletes_TaskIsCompletedAsync() + public async Task MapA2A_OnContinuation_WhenBackgroundOperationCompletes_TaskIsCompletedAsync() { // Arrange int callCount = 0; @@ -392,38 +411,40 @@ public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationCompletes_TaskIsCo { ContinuationToken = CreateTestContinuationToken() }, - // Second call (via OnTaskUpdated): return completed response + // Second call (via continuation): return completed response new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]), ref callCount); - ITaskManager taskManager = agentMock.Object.MapA2A(); + IA2ARequestHandler handler = agentMock.Object.MapA2A(); - // Act — trigger OnMessageReceived to create the task - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + // Act — send initial message to create the task + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Hello")] } + }, CancellationToken.None); + AgentTask agentTask = a2aResponse.Task!; + Assert.NotNull(agentTask); Assert.Equal(TaskState.Working, agentTask.Status.State); - // Act — invoke OnTaskUpdated to check on the background operation - await InvokeOnTaskUpdatedAsync(taskManager, agentTask); + // Act — send continuation message to check on the background operation + await SendTaskContinuationAsync(handler, agentTask); // Assert — task should now be completed - AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); + AgentTask updatedTask = (await handler.GetTaskAsync(new GetTaskRequest { Id = agentTask.Id }, CancellationToken.None))!; Assert.NotNull(updatedTask); Assert.Equal(TaskState.Completed, updatedTask.Status.State); Assert.NotNull(updatedTask.Artifacts); Artifact artifact = Assert.Single(updatedTask.Artifacts); - TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts)); - Assert.Equal("Done!", textPart.Text); + Part part = Assert.Single(artifact.Parts); + Assert.Equal(PartContentCase.Text, part.ContentCase); + Assert.Equal("Done!", part.Text); } /// - /// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token + /// Verifies that when a continuation is triggered on a task with a pending continuation token /// and the agent returns another ContinuationToken, the task stays in Working state. /// [Fact] - public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationStillWorking_TaskRemainsWorkingAsync() + public async Task MapA2A_OnContinuation_WhenBackgroundOperationStillWorking_TaskRemainsWorkingAsync() { // Arrange int callCount = 0; @@ -433,26 +454,27 @@ public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationStillWorking_TaskR { ContinuationToken = CreateTestContinuationToken() }, - // Second call (via OnTaskUpdated): still working, return another token + // Second call (via continuation): still working, return another token new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")]) { ContinuationToken = CreateTestContinuationToken() }, ref callCount); - ITaskManager taskManager = agentMock.Object.MapA2A(); + IA2ARequestHandler handler = agentMock.Object.MapA2A(); - // Act — trigger OnMessageReceived to create the task - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + // Act — send initial message to create the task + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Hello")] } + }, CancellationToken.None); + AgentTask agentTask = a2aResponse.Task!; + Assert.NotNull(agentTask); - // Act — invoke OnTaskUpdated; agent still working - await InvokeOnTaskUpdatedAsync(taskManager, agentTask); + // Act — send continuation; agent still working + await SendTaskContinuationAsync(handler, agentTask); // Assert — task should still be in Working state - AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); + AgentTask updatedTask = (await handler.GetTaskAsync(new GetTaskRequest { Id = agentTask.Id }, CancellationToken.None))!; Assert.NotNull(updatedTask); Assert.Equal(TaskState.Working, updatedTask.Status.State); } @@ -462,7 +484,7 @@ public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationStillWorking_TaskR /// second poll returns completed. /// [Fact] - public async Task MapA2A_OnTaskUpdated_MultiplePolls_EventuallyCompletesAsync() + public async Task MapA2A_OnContinuation_MultiplePolls_EventuallyCompletesAsync() { // Arrange int callCount = 0; @@ -484,35 +506,35 @@ public async Task MapA2A_OnTaskUpdated_MultiplePolls_EventuallyCompletesAsync() _ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "All done!")]) }; }); - ITaskManager taskManager = agentMock.Object.MapA2A(); + IA2ARequestHandler handler = agentMock.Object.MapA2A(); // Act — create the task - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Do work" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Do work")] } + }, CancellationToken.None); + AgentTask agentTask = a2aResponse.Task!; + Assert.NotNull(agentTask); Assert.Equal(TaskState.Working, agentTask.Status.State); // Act — first poll: still working - AgentTask? currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(currentTask); - await InvokeOnTaskUpdatedAsync(taskManager, currentTask); - currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); + await SendTaskContinuationAsync(handler, agentTask); + AgentTask currentTask = (await handler.GetTaskAsync(new GetTaskRequest { Id = agentTask.Id }, CancellationToken.None))!; Assert.NotNull(currentTask); Assert.Equal(TaskState.Working, currentTask.Status.State); // Act — second poll: completed - await InvokeOnTaskUpdatedAsync(taskManager, currentTask); - currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); + await SendTaskContinuationAsync(handler, agentTask); + currentTask = (await handler.GetTaskAsync(new GetTaskRequest { Id = agentTask.Id }, CancellationToken.None))!; Assert.NotNull(currentTask); Assert.Equal(TaskState.Completed, currentTask.Status.State); // Assert — final output as artifact Assert.NotNull(currentTask.Artifacts); Artifact artifact = Assert.Single(currentTask.Artifacts); - TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts)); - Assert.Equal("All done!", textPart.Text); + Part part = Assert.Single(artifact.Parts); + Assert.Equal(PartContentCase.Text, part.ContentCase); + Assert.Equal("All done!", part.Text); } /// @@ -520,7 +542,7 @@ public async Task MapA2A_OnTaskUpdated_MultiplePolls_EventuallyCompletesAsync() /// the task is updated to Failed state. /// [Fact] - public async Task MapA2A_OnTaskUpdated_WhenAgentThrows_TaskIsFailedAsync() + public async Task MapA2A_OnContinuation_WhenAgentThrows_TaskIsFailedAsync() { // Arrange int callCount = 0; @@ -536,20 +558,21 @@ public async Task MapA2A_OnTaskUpdated_WhenAgentThrows_TaskIsFailedAsync() throw new InvalidOperationException("Agent failed"); }); - ITaskManager taskManager = agentMock.Object.MapA2A(); + IA2ARequestHandler handler = agentMock.Object.MapA2A(); // Act — create the task - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Hello")] } + }, CancellationToken.None); + AgentTask agentTask = a2aResponse.Task!; + Assert.NotNull(agentTask); // Act — poll the task; agent throws - await Assert.ThrowsAsync(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask)); + await Assert.ThrowsAsync(() => SendTaskContinuationAsync(handler, agentTask)); // Assert — task should be Failed - AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); + AgentTask updatedTask = (await handler.GetTaskAsync(new GetTaskRequest { Id = agentTask.Id }, CancellationToken.None))!; Assert.NotNull(updatedTask); Assert.Equal(TaskState.Failed, updatedTask.Status.State); } @@ -565,20 +588,23 @@ public async Task MapA2A_TaskMode_WhenContinuationToken_ReturnsWorkingAgentTaskA { ContinuationToken = CreateTestContinuationToken() }; - ITaskManager taskManager = CreateAgentMockWithResponse(response) + IA2ARequestHandler handler = CreateAgentMockWithResponse(response) .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Hello")] } + }, CancellationToken.None); // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); + Assert.Equal(SendMessageResponseCase.Task, a2aResponse.PayloadCase); + AgentTask agentTask = a2aResponse.Task!; + Assert.NotNull(agentTask); Assert.Equal(TaskState.Working, agentTask.Status.State); - Assert.NotNull(agentTask.Metadata); - Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); + AgentTask storedTask = (await handler.GetTaskAsync(new GetTaskRequest { Id = agentTask.Id }, CancellationToken.None))!; + Assert.NotNull(storedTask.Metadata); + Assert.True(storedTask.Metadata.ContainsKey("__a2a__continuationToken")); } /// @@ -593,26 +619,28 @@ public async Task MapA2A_WhenContinuationTokenWithNoMessages_TaskStatusHasNullMe { ContinuationToken = CreateTestContinuationToken() }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + IA2ARequestHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Hello")] } + }, CancellationToken.None); // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); + Assert.Equal(SendMessageResponseCase.Task, a2aResponse.PayloadCase); + AgentTask agentTask = a2aResponse.Task!; + Assert.NotNull(agentTask); Assert.Equal(TaskState.Working, agentTask.Status.State); Assert.Null(agentTask.Status.Message); } /// - /// Verifies that when OnTaskUpdated is invoked on a completed task with a follow-up message - /// and no continuation token in metadata, the task processes history and completes with a new artifact. + /// Verifies that when a continuation completes a task, the task transitions to Completed state + /// with an artifact, and sending a follow-up to the terminal task is rejected by A2AServer. /// [Fact] - public async Task MapA2A_OnTaskUpdated_WhenNoContinuationToken_ProcessesHistoryAndCompletesAsync() + public async Task MapA2A_OnContinuation_WhenCompleted_FollowUpToTerminalTaskThrowsAsync() { // Arrange int callCount = 0; @@ -625,71 +653,64 @@ public async Task MapA2A_OnTaskUpdated_WhenNoContinuationToken_ProcessesHistoryA { ContinuationToken = CreateTestContinuationToken() }, - // Second call (via OnTaskUpdated): complete the background operation - 2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]), - // Third call (follow-up via OnTaskUpdated): complete follow-up - _ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Follow-up done!")]) + // Second call (via continuation): complete the background operation + _ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]) }; }); - ITaskManager taskManager = agentMock.Object.MapA2A(); + IA2ARequestHandler handler = agentMock.Object.MapA2A(); // Act — create a working task (with continuation token) - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - - // Act — first OnTaskUpdated: completes the background operation - await InvokeOnTaskUpdatedAsync(taskManager, agentTask); - agentTask = (await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None))!; - Assert.Equal(TaskState.Completed, agentTask.Status.State); - - // Simulate a follow-up message by adding it to history and re-submitting via OnTaskUpdated - agentTask.History ??= []; - agentTask.History.Add(new AgentMessage { MessageId = "follow-up", Role = MessageRole.User, Parts = [new TextPart { Text = "Follow up" }] }); - - // Act — invoke OnTaskUpdated without a continuation token in metadata - await InvokeOnTaskUpdatedAsync(taskManager, agentTask); - - // Assert - AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(updatedTask); - Assert.Equal(TaskState.Completed, updatedTask.Status.State); - Assert.NotNull(updatedTask.Artifacts); - Assert.Equal(2, updatedTask.Artifacts.Count); - Artifact artifact = updatedTask.Artifacts[1]; - TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts)); - Assert.Equal("Follow-up done!", textPart.Text); + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest + { + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Hello")] } + }, CancellationToken.None); + AgentTask agentTask = a2aResponse.Task!; + Assert.NotNull(agentTask); + + // Act — first continuation: completes the background operation + await SendTaskContinuationAsync(handler, agentTask); + AgentTask storedTask = (await handler.GetTaskAsync(new GetTaskRequest { Id = agentTask.Id }, CancellationToken.None))!; + Assert.Equal(TaskState.Completed, storedTask.Status.State); + Assert.NotNull(storedTask.Artifacts); + Artifact artifact = Assert.Single(storedTask.Artifacts); + Part part = Assert.Single(artifact.Parts); + Assert.Equal(PartContentCase.Text, part.ContentCase); + Assert.Equal("Done!", part.Text); + + // Assert — follow-up to a terminal (Completed) task is rejected + A2AException ex = await Assert.ThrowsAsync(() => SendTaskContinuationAsync(handler, agentTask, "Follow up")); + Assert.Contains("terminal state", ex.Message, StringComparison.OrdinalIgnoreCase); } /// - /// Verifies that when a task is cancelled, the continuation token is removed from metadata. + /// Verifies that when a task is cancelled via CancelTaskAsync, the A2AServer + /// invokes the handler's CancelAsync and returns the task from the store. /// [Fact] - public async Task MapA2A_OnTaskCancelled_RemovesContinuationTokenFromMetadataAsync() + public async Task MapA2A_OnTaskCancelled_CancelTaskAsyncReturnsTaskAsync() { // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting...")]) { ContinuationToken = CreateTestContinuationToken() }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + IA2ARequestHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); // Act — create a working task with a continuation token - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.NotNull(agentTask.Metadata); - Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Hello")] } + }, CancellationToken.None); + AgentTask agentTask = a2aResponse.Task!; + Assert.NotNull(agentTask); + Assert.Equal(TaskState.Working, agentTask.Status.State); // Act — cancel the task - await taskManager.CancelTaskAsync(new TaskIdParams { Id = agentTask.Id }, CancellationToken.None); + AgentTask cancelledTask = await handler.CancelTaskAsync(new CancelTaskRequest { Id = agentTask.Id }, CancellationToken.None); - // Assert — continuation token should be removed from metadata - Assert.False(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); + // Assert — CancelTaskAsync returns the task from the store + Assert.NotNull(cancelledTask); + Assert.Equal(agentTask.Id, cancelledTask.Id); } /// @@ -697,7 +718,7 @@ public async Task MapA2A_OnTaskCancelled_RemovesContinuationTokenFromMetadataAsy /// it is re-thrown without marking the task as Failed. /// [Fact] - public async Task MapA2A_OnTaskUpdated_WhenOperationCancelled_DoesNotMarkFailedAsync() + public async Task MapA2A_OnContinuation_WhenOperationCancelled_DoesNotMarkFailedAsync() { // Arrange int callCount = 0; @@ -713,20 +734,21 @@ public async Task MapA2A_OnTaskUpdated_WhenOperationCancelled_DoesNotMarkFailedA throw new OperationCanceledException("Cancelled"); }); - ITaskManager taskManager = agentMock.Object.MapA2A(); + IA2ARequestHandler handler = agentMock.Object.MapA2A(); // Act — create the task - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [Part.FromText("Hello")] } + }, CancellationToken.None); + AgentTask agentTask = a2aResponse.Task!; + Assert.NotNull(agentTask); // Act — poll the task; agent throws OperationCanceledException - await Assert.ThrowsAsync(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask)); + await Assert.ThrowsAsync(() => SendTaskContinuationAsync(handler, agentTask)); // Assert — task should still be Working, not Failed - AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); + AgentTask updatedTask = (await handler.GetTaskAsync(new GetTaskRequest { Id = agentTask.Id }, CancellationToken.None))!; Assert.NotNull(updatedTask); Assert.Equal(TaskState.Working, updatedTask.Status.State); } @@ -740,22 +762,24 @@ public async Task MapA2A_WhenMessageHasContextId_UsesProvidedContextIdAsync() { // Arrange AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); + IA2ARequestHandler handler = CreateAgentMockWithResponse(response).Object.MapA2A(); // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams + SendMessageResponse a2aResponse = await handler.SendMessageAsync(new SendMessageRequest { - Message = new AgentMessage + Message = new Message { MessageId = "test-id", ContextId = "my-context-123", - Role = MessageRole.User, - Parts = [new TextPart { Text = "Hello" }] + Role = Role.User, + Parts = [Part.FromText("Hello")] } - }); + }, CancellationToken.None); // Assert - AgentMessage agentMessage = Assert.IsType(a2aResponse); + Assert.Equal(SendMessageResponseCase.Message, a2aResponse.PayloadCase); + Message agentMessage = a2aResponse.Message!; + Assert.NotNull(agentMessage); Assert.Equal("my-context-123", agentMessage.ContextId); } @@ -803,18 +827,28 @@ private static Mock CreateAgentMockWithResponse(AgentResponse response) return agentMock; } - private static async Task InvokeOnMessageReceivedAsync(ITaskManager taskManager, MessageSendParams messageSendParams) - { - Func>? handler = taskManager.OnMessageReceived; - Assert.NotNull(handler); - return await handler.Invoke(messageSendParams, CancellationToken.None); - } - - private static async Task InvokeOnTaskUpdatedAsync(ITaskManager taskManager, AgentTask agentTask) + /// + /// Sends a continuation message for an existing task using the streaming API. + /// The streaming path is required because HandleTaskUpdateAsync produces only + /// TaskStatusUpdateEvent and TaskArtifactUpdateEvent events, which are not + /// recognized by A2AServer.MaterializeResponseAsync (used by SendMessageAsync). + /// + private static async Task SendTaskContinuationAsync(IA2ARequestHandler handler, AgentTask agentTask, string text = "continue") { - Func? handler = taskManager.OnTaskUpdated; - Assert.NotNull(handler); - await handler.Invoke(agentTask, CancellationToken.None); + await foreach (StreamResponse _ in handler.SendStreamingMessageAsync(new SendMessageRequest + { + Message = new Message + { + MessageId = Guid.NewGuid().ToString("N"), + ContextId = agentTask.ContextId, + TaskId = agentTask.Id, + Role = Role.User, + Parts = [Part.FromText(text)] + } + }, CancellationToken.None)) + { + // Consume all events to ensure they are applied to the task store. + } } #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs index 69eaf3a535..198f9f6446 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs @@ -10,66 +10,66 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests.Converters; public class MessageConverterTests { [Fact] - public void ToChatMessages_MessageSendParams_Null_ReturnsEmptyCollection() + public void ToChatMessages_SendMessageRequest_Null_ReturnsEmptyCollection() { - MessageSendParams? messageSendParams = null; + SendMessageRequest? sendMessageRequest = null; - var result = messageSendParams!.ToChatMessages(); + var result = sendMessageRequest!.ToChatMessages(); Assert.NotNull(result); Assert.Empty(result); } [Fact] - public void ToChatMessages_MessageSendParams_WithNullMessage_ReturnsEmptyCollection() + public void ToChatMessages_SendMessageRequest_WithNullMessage_ReturnsEmptyCollection() { - var messageSendParams = new MessageSendParams + var sendMessageRequest = new SendMessageRequest { Message = null! }; - var result = messageSendParams.ToChatMessages(); + var result = sendMessageRequest.ToChatMessages(); Assert.NotNull(result); Assert.Empty(result); } [Fact] - public void ToChatMessages_MessageSendParams_WithMessageWithoutParts_ReturnsEmptyCollection() + public void ToChatMessages_SendMessageRequest_WithMessageWithoutParts_ReturnsEmptyCollection() { - var messageSendParams = new MessageSendParams + var sendMessageRequest = new SendMessageRequest { - Message = new AgentMessage + Message = new Message { MessageId = "test-id", - Role = MessageRole.User, + Role = Role.User, Parts = null! } }; - var result = messageSendParams.ToChatMessages(); + var result = sendMessageRequest.ToChatMessages(); Assert.NotNull(result); Assert.Empty(result); } [Fact] - public void ToChatMessages_MessageSendParams_WithValidTextMessage_ReturnsCorrectChatMessage() + public void ToChatMessages_SendMessageRequest_WithValidTextMessage_ReturnsCorrectChatMessage() { - var messageSendParams = new MessageSendParams + var sendMessageRequest = new SendMessageRequest { - Message = new AgentMessage + Message = new Message { MessageId = "test-id", - Role = MessageRole.User, + Role = Role.User, Parts = [ - new TextPart { Text = "Hello, world!" } + Part.FromText("Hello, world!") ] } }; - var result = messageSendParams.ToChatMessages(); + var result = sendMessageRequest.ToChatMessages(); Assert.NotNull(result); Assert.Single(result); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs index a848528888..3aae126c5e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs @@ -57,7 +57,7 @@ public void MapA2A_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException } /// - /// Verifies that MapA2A with IHostedAgentBuilder correctly maps the agent with default task manager configuration. + /// Verifies that MapA2A with IHostedAgentBuilder correctly maps the agent with default configuration. /// [Fact] public void MapA2A_WithAgentBuilder_DefaultConfiguration_Succeeds() @@ -76,26 +76,6 @@ public void MapA2A_WithAgentBuilder_DefaultConfiguration_Succeeds() Assert.NotNull(app); } - /// - /// Verifies that MapA2A with IHostedAgentBuilder and custom task manager configuration succeeds. - /// - [Fact] - public void MapA2A_WithAgentBuilder_CustomTaskManagerConfiguration_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", taskManager => { }); - Assert.NotNull(result); - Assert.NotNull(app); - } - /// /// Verifies that MapA2A with IHostedAgentBuilder and agent card succeeds. /// @@ -123,10 +103,10 @@ public void MapA2A_WithAgentBuilder_WithAgentCard_Succeeds() } /// - /// Verifies that MapA2A with IHostedAgentBuilder, agent card, and custom task manager configuration succeeds. + /// Verifies that MapA2A with IHostedAgentBuilder, agent card, and custom run mode succeeds. /// [Fact] - public void MapA2A_WithAgentBuilder_WithAgentCardAndCustomConfiguration_Succeeds() + public void MapA2A_WithAgentBuilder_WithAgentCardAndRunMode_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); @@ -143,7 +123,7 @@ public void MapA2A_WithAgentBuilder_WithAgentCardAndCustomConfiguration_Succeeds }; // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", agentCard, taskManager => { }); + var result = app.MapA2A(agentBuilder, "/a2a", agentCard, AgentRunMode.AllowBackgroundIfSupported); Assert.NotNull(result); Assert.NotNull(app); } @@ -184,26 +164,6 @@ public void MapA2A_WithAgentName_DefaultConfiguration_Succeeds() Assert.NotNull(app); } - /// - /// Verifies that MapA2A with string agent name and custom task manager configuration succeeds. - /// - [Fact] - public void MapA2A_WithAgentName_CustomTaskManagerConfiguration_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a", taskManager => { }); - Assert.NotNull(result); - Assert.NotNull(app); - } - /// /// Verifies that MapA2A with string agent name and agent card succeeds. /// @@ -231,10 +191,10 @@ public void MapA2A_WithAgentName_WithAgentCard_Succeeds() } /// - /// Verifies that MapA2A with string agent name, agent card, and custom task manager configuration succeeds. + /// Verifies that MapA2A with string agent name, agent card, and custom run mode succeeds. /// [Fact] - public void MapA2A_WithAgentName_WithAgentCardAndCustomConfiguration_Succeeds() + public void MapA2A_WithAgentName_WithAgentCardAndRunMode_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); @@ -251,7 +211,7 @@ public void MapA2A_WithAgentName_WithAgentCardAndCustomConfiguration_Succeeds() }; // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a", agentCard, taskManager => { }); + var result = app.MapA2A("agent", "/a2a", agentCard, AgentRunMode.DisallowBackground); Assert.NotNull(result); Assert.NotNull(app); } @@ -293,27 +253,6 @@ public void MapA2A_WithAIAgent_DefaultConfiguration_Succeeds() Assert.NotNull(app); } - /// - /// Verifies that MapA2A with AIAgent and custom task manager configuration succeeds. - /// - [Fact] - public void MapA2A_WithAIAgent_CustomTaskManagerConfiguration_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - AIAgent agent = app.Services.GetRequiredKeyedService("agent"); - - // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a", taskManager => { }); - Assert.NotNull(result); - Assert.NotNull(app); - } - /// /// Verifies that MapA2A with AIAgent and agent card succeeds. /// @@ -342,10 +281,10 @@ public void MapA2A_WithAIAgent_WithAgentCard_Succeeds() } /// - /// Verifies that MapA2A with AIAgent, agent card, and custom task manager configuration succeeds. + /// Verifies that MapA2A with AIAgent, agent card, and custom run mode succeeds. /// [Fact] - public void MapA2A_WithAIAgent_WithAgentCardAndCustomConfiguration_Succeeds() + public void MapA2A_WithAIAgent_WithAgentCardAndRunMode_Succeeds() { // Arrange WebApplicationBuilder builder = WebApplication.CreateBuilder(); @@ -363,26 +302,24 @@ public void MapA2A_WithAIAgent_WithAgentCardAndCustomConfiguration_Succeeds() }; // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a", agentCard, taskManager => { }); + var result = app.MapA2A(agent, "/a2a", agentCard, AgentRunMode.AllowBackgroundIfSupported); Assert.NotNull(result); Assert.NotNull(app); } /// - /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using ITaskManager. + /// Verifies that MapA2A with IA2ARequestHandler correctly maps the handler. /// [Fact] - public void MapA2A_WithTaskManager_NullEndpoints_ThrowsArgumentNullException() + public void MapA2A_WithHandler_NullEndpoints_ThrowsArgumentNullException() { // Arrange AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; - ITaskManager taskManager = null!; + IA2ARequestHandler handler = null!; // Act & Assert - ArgumentNullException exception = Assert.Throws(() => - endpoints.MapA2A(taskManager, "/a2a")); - - Assert.Equal("endpoints", exception.ParamName); + Assert.Throws(() => + endpoints.MapA2A(handler, "/a2a")); } /// @@ -425,33 +362,6 @@ public void MapA2A_WithCustomPath_AcceptsValidPath() Assert.NotNull(app); } - /// - /// Verifies that task manager configuration callback is invoked correctly. - /// - [Fact] - public void MapA2A_WithAgentBuilder_TaskManagerConfigurationCallbackInvoked() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - bool configureCallbackInvoked = false; - - // Act - app.MapA2A(agentBuilder, "/a2a", taskManager => - { - configureCallbackInvoked = true; - Assert.NotNull(taskManager); - }); - - // Assert - Assert.True(configureCallbackInvoked); - } - /// /// Verifies that agent card with all properties is accepted. /// From e30e7c3370e12f23dfed2e7065344b2938f9cf57 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Tue, 24 Mar 2026 23:39:18 +0000 Subject: [PATCH 02/11] fix format issues --- .../AIAgentExtensions.cs | 48 +++++++++---------- .../A2AAgentTests.cs | 6 +-- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index 53f0ef4bbf..0035e74a22 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -125,39 +125,39 @@ private sealed class MetadataInjectingTaskStore : ITaskStore private readonly ITaskStore _inner; private readonly ConcurrentDictionary> _pendingModifications = new(); - internal MetadataInjectingTaskStore(ITaskStore inner) => _inner = inner; + internal MetadataInjectingTaskStore(ITaskStore inner) => this._inner = inner; internal void RegisterModification(string taskId, Action modification) - => _pendingModifications.AddOrUpdate(taskId, modification, (_, existing) => task => + => this._pendingModifications.AddOrUpdate(taskId, modification, (_, existing) => task => { existing(task); modification(task); }); internal void ClearModification(string taskId) - => _pendingModifications.TryRemove(taskId, out _); + => this._pendingModifications.TryRemove(taskId, out _); public async Task SaveTaskAsync(string taskId, AgentTask task, CancellationToken cancellationToken) { - if (_pendingModifications.TryRemove(taskId, out var modification)) + if (this._pendingModifications.TryRemove(taskId, out var modification)) { modification(task); } - await _inner.SaveTaskAsync(taskId, task, cancellationToken).ConfigureAwait(false); + await this._inner.SaveTaskAsync(taskId, task, cancellationToken).ConfigureAwait(false); } public Task GetTaskAsync(string taskId, CancellationToken cancellationToken) - => _inner.GetTaskAsync(taskId, cancellationToken); + => this._inner.GetTaskAsync(taskId, cancellationToken); public Task DeleteTaskAsync(string taskId, CancellationToken cancellationToken) { - _pendingModifications.TryRemove(taskId, out _); - return _inner.DeleteTaskAsync(taskId, cancellationToken); + this._pendingModifications.TryRemove(taskId, out _); + return this._inner.DeleteTaskAsync(taskId, cancellationToken); } public Task ListTasksAsync(ListTasksRequest request, CancellationToken cancellationToken) - => _inner.ListTasksAsync(request, cancellationToken); + => this._inner.ListTasksAsync(request, cancellationToken); } /// @@ -176,10 +176,10 @@ internal AIAgentA2AHandler( MetadataInjectingTaskStore taskStore, JsonSerializerOptions continuationTokenJsonOptions) { - _hostAgent = hostAgent; - _runMode = runMode; - _taskStore = taskStore; - _continuationTokenJsonOptions = continuationTokenJsonOptions; + this._hostAgent = hostAgent; + this._runMode = runMode; + this._taskStore = taskStore; + this._continuationTokenJsonOptions = continuationTokenJsonOptions; } public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) @@ -212,11 +212,11 @@ private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue } var contextId = context.ContextId; - var session = await _hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); + var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); var sendRequest = new SendMessageRequest { Message = context.Message!, Metadata = context.Metadata }; var decisionContext = new A2ARunDecisionContext(sendRequest); - var allowBackgroundResponses = await _runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false); + var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false); var options = context.Metadata is not { Count: > 0 } ? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses } @@ -228,13 +228,13 @@ private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue chatMessages.Add(context.Message.ToChatMessage()); } - var response = await _hostAgent.RunAsync( + var response = await this._hostAgent.RunAsync( chatMessages, session: session, options: options, cancellationToken: cancellationToken).ConfigureAwait(false); - await _hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); + await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); if (response.ContinuationToken is null) { @@ -249,9 +249,9 @@ private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue // Register a pending modification so that when A2AServer materializes // the task, the continuation token and original message are injected. var continuationToken = response.ContinuationToken; - var continuationJsonOptions = _continuationTokenJsonOptions; + var continuationJsonOptions = this._continuationTokenJsonOptions; var originalMessage = context.Message; - _taskStore.RegisterModification(context.TaskId, task => + this._taskStore.RegisterModification(context.TaskId, task => { StoreContinuationToken(task, continuationToken, continuationJsonOptions); task.History ??= []; @@ -269,7 +269,7 @@ private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) { var contextId = context.ContextId; - var session = await _hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); + var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId); try @@ -289,20 +289,20 @@ private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue await taskUpdater.StartWorkAsync(null, cancellationToken).ConfigureAwait(false); - var response = await _hostAgent.RunAsync( + var response = await this._hostAgent.RunAsync( ExtractChatMessagesFromTaskHistory(agentTask), session: session, options: new AgentRunOptions { AllowBackgroundResponses = true }, cancellationToken: cancellationToken).ConfigureAwait(false); - await _hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); + await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); if (response.ContinuationToken is not null) { // Register continuation token injection for the next task save. var continuationToken = response.ContinuationToken; - var continuationJsonOptions = _continuationTokenJsonOptions; - _taskStore.RegisterModification(context.TaskId, task => + var continuationJsonOptions = this._continuationTokenJsonOptions; + this._taskStore.RegisterModification(context.TaskId, task => StoreContinuationToken(task, continuationToken, continuationJsonOptions)); Message? progressMessage = response.Messages.Count > 0 ? CreateMessageFromResponse(contextId, response) : null; diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs index 69d3d90c0f..3e984db83a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs @@ -656,8 +656,8 @@ public async Task RunStreamingAsync_WithContinuationToken_PassesCorrectTaskIdAsy } }; - const string expectedTaskId = "my-task-789"; - var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken(expectedTaskId) }; + const string ExpectedTaskId = "my-task-789"; + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken(ExpectedTaskId) }; // Act await foreach (var _ in this._agent.RunStreamingAsync([], null, options)) @@ -670,7 +670,7 @@ public async Task RunStreamingAsync_WithContinuationToken_PassesCorrectTaskIdAsy var subscribeRequest = this._handler.CapturedJsonRpcRequests[0]; var subscribeParams = subscribeRequest.Params?.Deserialize(A2AJsonUtilities.DefaultOptions); Assert.NotNull(subscribeParams); - Assert.Equal(expectedTaskId, subscribeParams.Id); + Assert.Equal(ExpectedTaskId, subscribeParams.Id); } [Fact] From 81e7d07c6b62f520a20c3ff583df5c7bb00c8743 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Wed, 25 Mar 2026 09:39:20 +0000 Subject: [PATCH 03/11] fix formatting issues --- .../src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs index 0035e74a22..8ad7dab668 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs @@ -186,11 +186,11 @@ public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueu { if (context.IsContinuation) { - await HandleTaskUpdateAsync(context, eventQueue, cancellationToken).ConfigureAwait(false); + await this.HandleTaskUpdateAsync(context, eventQueue, cancellationToken).ConfigureAwait(false); } else { - await HandleNewMessageAsync(context, eventQueue, cancellationToken).ConfigureAwait(false); + await this.HandleNewMessageAsync(context, eventQueue, cancellationToken).ConfigureAwait(false); } } From 9077ee130128b88f2e150a5c0b30ad29cc7c19fb Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Wed, 25 Mar 2026 11:36:55 +0000 Subject: [PATCH 04/11] address encoding issue --- .../samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs index dc57825e6c..a7680a2f88 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using A2A; using A2A.AspNetCore; using A2AServer; From 95e2f386147bb81435b7cc065ac2c6f35bc53513 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Thu, 26 Mar 2026 12:13:08 +0000 Subject: [PATCH 05/11] enable background responses for a2a agent --- .../A2A/A2AAgent_PollingForTaskCompletion/Program.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/Program.cs b/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/Program.cs index e1731604a9..9410785c39 100644 --- a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/Program.cs +++ b/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/Program.cs @@ -18,8 +18,12 @@ AgentSession session = await agent.CreateSessionAsync(); +// AllowBackgroundResponses must be true so the server returns immediately with a continuation token +// instead of blocking until the task is complete. +AgentRunOptions options = new() { AllowBackgroundResponses = true }; + // Start the initial run with a long-running task. -AgentResponse response = await agent.RunAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session); +AgentResponse response = await agent.RunAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session, options: options); // Poll until the response is complete. while (response.ContinuationToken is { } token) From 24e2dc2fc8031d3d69962aae93d7e13b979ccb75 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Thu, 26 Mar 2026 14:10:31 +0000 Subject: [PATCH 06/11] add sample showing stream reconnection for a2a agent --- dotnet/agent-framework-dotnet.slnx | 1 + .../A2AAgent_StreamReconnection.csproj | 23 ++++++++ .../A2AAgent_StreamReconnection/Program.cs | 55 +++++++++++++++++++ .../A2A/A2AAgent_StreamReconnection/README.md | 29 ++++++++++ dotnet/samples/04-hosting/A2A/README.md | 1 + .../src/Microsoft.Agents.AI.A2A/A2AAgent.cs | 15 ++--- 6 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 dotnet/samples/04-hosting/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj create mode 100644 dotnet/samples/04-hosting/A2A/A2AAgent_StreamReconnection/Program.cs create mode 100644 dotnet/samples/04-hosting/A2A/A2AAgent_StreamReconnection/README.md diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index af7ca9f0be..e6c842494a 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -281,6 +281,7 @@ + diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj b/dotnet/samples/04-hosting/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj new file mode 100644 index 0000000000..e75368ea99 --- /dev/null +++ b/dotnet/samples/04-hosting/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_StreamReconnection/Program.cs b/dotnet/samples/04-hosting/A2A/A2AAgent_StreamReconnection/Program.cs new file mode 100644 index 0000000000..0829faf8ed --- /dev/null +++ b/dotnet/samples/04-hosting/A2A/A2AAgent_StreamReconnection/Program.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens, +// allowing recovery from stream interruptions without losing progress. + +using A2A; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +var a2aAgentHost = Environment.GetEnvironmentVariable("A2A_AGENT_HOST") ?? throw new InvalidOperationException("A2A_AGENT_HOST is not set."); + +// Initialize an A2ACardResolver to get an A2A agent card. +A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost)); + +// Get the agent card +AgentCard agentCard = await agentCardResolver.GetAgentCardAsync(); + +// Create an instance of the AIAgent for an existing A2A agent specified by the agent card. +AIAgent agent = agentCard.AsAIAgent(); + +AgentSession session = await agent.CreateSessionAsync(); + +ResponseContinuationToken? continuationToken = null; + +await foreach (var update in agent.RunStreamingAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session)) +{ + // Saving the continuation token to be able to reconnect to the same response stream later. + // Note: Continuation tokens are only returned for long-running tasks. If the underlying A2A agent + // returns a message instead of a task, the continuation token will not be initialized. + // A2A agents do not support stream resumption from a specific point in the stream, + // but only reconnection to obtain the same response stream from the beginning. + // So, A2A agents will return an initialized continuation token in the first update + // representing the beginning of the stream, and it will be null in all subsequent updates. + if (update.ContinuationToken is { } token) + { + continuationToken = token; + } + + // Imitating stream interruption + break; +} + +// Reconnect to the same response stream using the continuation token obtained from the previous stream. +// As a first update, the agent will return an update representing the current state of the response at the moment of calling +// RunStreamingAsync with the same continuation token, followed by other updates until the end of the stream is reached. +if (continuationToken is not null) +{ + await foreach (var update in agent.RunStreamingAsync(session, options: new() { ContinuationToken = continuationToken })) + { + if (!string.IsNullOrEmpty(update.Text)) + { + Console.WriteLine(update); + } + } +} diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_StreamReconnection/README.md b/dotnet/samples/04-hosting/A2A/A2AAgent_StreamReconnection/README.md new file mode 100644 index 0000000000..ca5b0b66ad --- /dev/null +++ b/dotnet/samples/04-hosting/A2A/A2AAgent_StreamReconnection/README.md @@ -0,0 +1,29 @@ +# A2A Agent Stream Reconnection + +This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens, allowing recovery from stream interruptions without losing progress. + +The sample: + +- Connects to an A2A agent server specified in the `A2A_AGENT_HOST` environment variable +- Sends a request to the agent and begins streaming the response +- Captures a continuation token from the stream for later reconnection +- Simulates a stream interruption by breaking out of the streaming loop +- Reconnects to the same response stream using the captured continuation token +- Displays the response received after reconnection + +This pattern is useful when network interruptions or other failures may disrupt an ongoing streaming response, and you need to recover and continue processing. + +> **Note:** Continuation tokens are only available when the underlying A2A agent returns a task. If the agent returns a message instead, the continuation token will not be initialized and stream reconnection is not applicable. + +# Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 10.0 SDK or later +- An A2A agent server running and accessible via HTTP + +Set the following environment variable: + +```powershell +$env:A2A_AGENT_HOST="http://localhost:5000" # Replace with your A2A agent server host +``` diff --git a/dotnet/samples/04-hosting/A2A/README.md b/dotnet/samples/04-hosting/A2A/README.md index 55539a8322..2f161748df 100644 --- a/dotnet/samples/04-hosting/A2A/README.md +++ b/dotnet/samples/04-hosting/A2A/README.md @@ -15,6 +15,7 @@ See the README.md for each sample for the prerequisites for that sample. |---|---| |[A2A Agent As Function Tools](./A2AAgent_AsFunctionTools/)|This sample demonstrates how to represent an A2A agent as a set of function tools, where each function tool corresponds to a skill of the A2A agent, and register these function tools with another AI agent so it can leverage the A2A agent's skills.| |[A2A Agent Polling For Task Completion](./A2AAgent_PollingForTaskCompletion/)|This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A agent.| +|[A2A Agent Stream Reconnection](./A2AAgent_StreamReconnection/)|This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens, allowing recovery from stream interruptions.| ## Running the samples from the console diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs index 6822140ded..f2eaada02f 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs @@ -321,17 +321,17 @@ private AgentResponse ConvertToAgentResponse(Message message) }; } - private AgentResponse ConvertToAgentResponse(AgentTask agentTask) + private AgentResponse ConvertToAgentResponse(AgentTask task) { return new AgentResponse { AgentId = this.Id, - ResponseId = agentTask.Id, - FinishReason = MapTaskStateToFinishReason(agentTask.Status.State), - RawRepresentation = agentTask, - Messages = agentTask.ToChatMessages() ?? [], - ContinuationToken = CreateContinuationToken(agentTask.Id, agentTask.Status.State), - AdditionalProperties = agentTask.Metadata?.ToAdditionalProperties(), + ResponseId = task.Id, + FinishReason = MapTaskStateToFinishReason(task.Status.State), + RawRepresentation = task, + Messages = task.ToChatMessages() ?? [], + ContinuationToken = CreateContinuationToken(task.Id, task.Status.State), + AdditionalProperties = task.Metadata?.ToAdditionalProperties(), }; } @@ -360,6 +360,7 @@ private AgentResponseUpdate ConvertToAgentResponseUpdate(AgentTask task) RawRepresentation = task, Role = ChatRole.Assistant, Contents = task.ToAIContents(), + ContinuationToken = CreateContinuationToken(task.Id, task.Status.State), AdditionalProperties = task.Metadata?.ToAdditionalProperties(), }; } From 178c42ec35e9cdd5865bde52dc890329d2de6e58 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:08:52 +0000 Subject: [PATCH 07/11] fallback to GetTask if SubscribeToTask fails --- .../src/Microsoft.Agents.AI.A2A/A2AAgent.cs | 63 +++++++++- .../A2AAgentLogMessages.cs | 13 ++ .../A2AAgentTests.cs | 113 ++++++++++++++++++ 3 files changed, 188 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs index f2eaada02f..1d49fc1211 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs @@ -155,7 +155,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA if (GetContinuationToken(messages, options) is { } token) { - streamEvents = this._a2aClient.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = token.TaskId }, cancellationToken).ConfigureAwait(false); + streamEvents = this.SubscribeToTaskWithFallbackAsync(token.TaskId, cancellationToken).ConfigureAwait(false); } else { @@ -248,6 +248,67 @@ private async ValueTask GetA2ASessionAsync(AgentSession? sessio return typedSession; } + /// + /// Subscribes to task updates, falling back to + /// when the task has already reached a terminal state and the server responds with + /// . + /// + /// + /// Per A2A spec §3.1.6, subscribing to a task in a terminal state (completed, failed, + /// canceled, or rejected) results in an UnsupportedOperationError. + /// See: . + /// + private async IAsyncEnumerable SubscribeToTaskWithFallbackAsync( + string taskId, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var subscribeStream = this._a2aClient.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = taskId }, cancellationToken); + + var enumerator = subscribeStream.GetAsyncEnumerator(cancellationToken); + + // yield return cannot appear inside a try block that has catch clauses, + // so we manually advance the enumerator within try/catch and yield outside it. + // The outer try/finally (no catch) is allowed to contain yield return in C#. + StreamResponse? fallbackResponse = null; + + try + { + while (true) + { + bool hasNext; + try + { + hasNext = await enumerator.MoveNextAsync().ConfigureAwait(false); + } + catch (A2AException ex) when (ex.ErrorCode == A2AErrorCode.UnsupportedOperation) + { + this._logger.LogA2ASubscribeToTaskFallback(this.Id, this.Name, taskId, ex.Message); + + AgentTask agentTask = await this._a2aClient.GetTaskAsync(new GetTaskRequest { Id = taskId }, cancellationToken).ConfigureAwait(false); + + fallbackResponse = new StreamResponse { Task = agentTask }; + break; + } + + if (!hasNext) + { + break; + } + + yield return enumerator.Current; + } + + if (fallbackResponse is not null) + { + yield return fallbackResponse; + } + } + finally + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } + } + private static void UpdateSession(A2AAgentSession? session, string? contextId, string? taskId = null) { if (session is null) diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentLogMessages.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentLogMessages.cs index 96d0ba0f9f..7d72013ba3 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentLogMessages.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentLogMessages.cs @@ -34,4 +34,17 @@ public static partial void LogAgentChatClientInvokedAgent( string methodName, string agentId, string? agentName); + + /// + /// Logs falling back to GetTaskAsync after SubscribeToTaskAsync failed with UnsupportedOperation. + /// + [LoggerMessage( + Level = LogLevel.Warning, + Message = "A2AAgent {AgentId}/{AgentName} SubscribeToTask for task '{TaskId}' failed with UnsupportedOperation: {ErrorMessage}. Falling back to GetTaskAsync.")] + public static partial void LogA2ASubscribeToTaskFallback( + this ILogger logger, + string agentId, + string? agentName, + string taskId, + string errorMessage); } diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs index 3e984db83a..dc4bfa31e8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs @@ -673,6 +673,78 @@ public async Task RunStreamingAsync_WithContinuationToken_PassesCorrectTaskIdAsy Assert.Equal(ExpectedTaskId, subscribeParams.Id); } + [Fact] + public async Task RunStreamingAsync_WithContinuationToken_WhenSubscribeFailsWithUnsupportedOperation_FallsBackToGetTaskAsync() + { + // Arrange + const string TaskId = "completed-task-123"; + const string ContextId = "ctx-completed"; + + this._handler.StreamingErrorCodeToReturn = A2AErrorCode.UnsupportedOperation; + this._handler.AgentTaskToReturn = new AgentTask + { + Id = TaskId, + ContextId = ContextId, + Status = new() { State = TaskState.Completed }, + Artifacts = + [ + new() { ArtifactId = "art-1", Parts = [new Part { Text = "Final result" }] } + ] + }; + + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken(TaskId) }; + + // Act + var updates = new List(); + await foreach (var update in this._agent.RunStreamingAsync([], null, options)) + { + updates.Add(update); + } + + // Assert - should yield one update from GetTaskAsync fallback + Assert.Single(updates); + var update0 = updates[0]; + Assert.Equal(TaskId, update0.ResponseId); + Assert.Equal(ChatFinishReason.Stop, update0.FinishReason); + Assert.IsType(update0.RawRepresentation); + Assert.Equal(TaskId, ((AgentTask)update0.RawRepresentation!).Id); + + // Assert - both SubscribeToTask and GetTask were called + Assert.Equal(2, this._handler.CapturedJsonRpcRequests.Count); + Assert.Equal("SubscribeToTask", this._handler.CapturedJsonRpcRequests[0].Method); + Assert.Equal("GetTask", this._handler.CapturedJsonRpcRequests[1].Method); + } + + [Fact] + public async Task RunStreamingAsync_WithContinuationToken_WhenSubscribeFailsWithUnsupportedOperation_UpdatesSessionAsync() + { + // Arrange + const string TaskId = "completed-task-456"; + const string ContextId = "ctx-completed-456"; + + this._handler.StreamingErrorCodeToReturn = A2AErrorCode.UnsupportedOperation; + this._handler.AgentTaskToReturn = new AgentTask + { + Id = TaskId, + ContextId = ContextId, + Status = new() { State = TaskState.Completed } + }; + + var session = await this._agent.CreateSessionAsync(); + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken(TaskId) }; + + // Act + await foreach (var _ in this._agent.RunStreamingAsync([], session, options)) + { + // Just iterate through to trigger the logic + } + + // Assert - session should be updated with the task state from GetTaskAsync + var a2aSession = (A2AAgentSession)session; + Assert.Equal(ContextId, a2aSession.ContextId); + Assert.Equal(TaskId, a2aSession.TaskId); + } + [Fact] public async Task RunStreamingAsync_WithTaskInSessionAndMessage_AddTaskAsReferencesToMessageAsync() { @@ -1512,6 +1584,17 @@ internal sealed class A2AClientHttpMessageHandlerStub : HttpMessageHandler public StreamResponse? StreamingResponseToReturn { get; set; } + /// + /// When set, streaming requests for SubscribeToTask will return a JSON-RPC error + /// with this error code. Used to simulate UnsupportedOperation errors. + /// + public A2AErrorCode? StreamingErrorCodeToReturn { get; set; } + + /// + /// Error message to include when is set. + /// + public string StreamingErrorMessage { get; set; } = "Task is in a terminal state and cannot be subscribed to."; + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { // Capture the request content @@ -1567,6 +1650,36 @@ protected override async Task SendAsync(HttpRequestMessage Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json") }; } + // Return a streaming JSON-RPC error (e.g., UnsupportedOperation for SubscribeToTask) + else if (this.StreamingErrorCodeToReturn is not null + && this.CapturedJsonRpcRequest?.Method is "SubscribeToTask") + { + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Error = new JsonRpcError + { + Code = (int)this.StreamingErrorCodeToReturn.Value, + Message = this.StreamingErrorMessage + } + }; + + var stream = new MemoryStream(); + var writer = new StreamWriter(stream); + await writer.WriteAsync($"data: {JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions)}\n\n"); +#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods; overload doesn't exist downlevel + await writer.FlushAsync(); +#pragma warning restore CA2016 + stream.Position = 0; + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(stream) + { + Headers = { { "Content-Type", "text/event-stream" } } + } + }; + } // Return the pre-configured streaming response else if (this.StreamingResponseToReturn is not null) { From 731cb3637146b9c2846dcc051d75a9aa02ea78f2 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Thu, 9 Apr 2026 22:08:39 +0100 Subject: [PATCH 08/11] update to the latest A2A SDK package --- dotnet/Directory.Packages.props | 20 +++++++++---------- .../src/Microsoft.Agents.AI.A2A/A2AAgent.cs | 2 +- .../A2AAgentTests.cs | 18 ++++++++--------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 1678e4b2ae..f1c6b36940 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -32,18 +32,18 @@ - + - - + + - - + + @@ -64,7 +64,7 @@ - + @@ -76,11 +76,11 @@ - + - + @@ -101,8 +101,8 @@ - - + + diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs index 1d49fc1211..029d7f241f 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs @@ -114,7 +114,7 @@ protected override async Task RunCoreAsync(IEnumerable @@ -1222,11 +1222,11 @@ public async Task RunAsync_WithDefaultOptions_SetsBlockingToTrueAsync() // Assert Assert.NotNull(this._handler.CapturedSendMessageRequest); Assert.NotNull(this._handler.CapturedSendMessageRequest.Configuration); - Assert.True(this._handler.CapturedSendMessageRequest.Configuration.Blocking); + Assert.False(this._handler.CapturedSendMessageRequest.Configuration.ReturnImmediately); } [Fact] - public async Task RunAsync_WithAllowBackgroundResponsesTrue_SetsBlockingToFalseAsync() + public async Task RunAsync_WithAllowBackgroundResponsesTrue_SetsReturnImmediatelyToTrueAsync() { // Arrange var inputMessages = new List @@ -1243,11 +1243,11 @@ public async Task RunAsync_WithAllowBackgroundResponsesTrue_SetsBlockingToFalseA // Assert Assert.NotNull(this._handler.CapturedSendMessageRequest); Assert.NotNull(this._handler.CapturedSendMessageRequest.Configuration); - Assert.False(this._handler.CapturedSendMessageRequest.Configuration.Blocking); + Assert.True(this._handler.CapturedSendMessageRequest.Configuration.ReturnImmediately); } [Fact] - public async Task RunAsync_WithAllowBackgroundResponsesFalse_SetsBlockingToTrueAsync() + public async Task RunAsync_WithAllowBackgroundResponsesFalse_SetsReturnImmediatelyToFalseAsync() { // Arrange var inputMessages = new List @@ -1263,11 +1263,11 @@ public async Task RunAsync_WithAllowBackgroundResponsesFalse_SetsBlockingToTrueA // Assert Assert.NotNull(this._handler.CapturedSendMessageRequest); Assert.NotNull(this._handler.CapturedSendMessageRequest.Configuration); - Assert.True(this._handler.CapturedSendMessageRequest.Configuration.Blocking); + Assert.False(this._handler.CapturedSendMessageRequest.Configuration.ReturnImmediately); } [Fact] - public async Task RunAsync_WithNullOptions_SetsBlockingToTrueAsync() + public async Task RunAsync_WithNullOptions_SetsReturnImmediatelyToFalseAsync() { // Arrange var inputMessages = new List @@ -1281,11 +1281,11 @@ public async Task RunAsync_WithNullOptions_SetsBlockingToTrueAsync() // Assert Assert.NotNull(this._handler.CapturedSendMessageRequest); Assert.NotNull(this._handler.CapturedSendMessageRequest.Configuration); - Assert.True(this._handler.CapturedSendMessageRequest.Configuration.Blocking); + Assert.False(this._handler.CapturedSendMessageRequest.Configuration.ReturnImmediately); } [Fact] - public async Task RunStreamingAsync_SendMessageRequest_DoesNotSetBlockingConfigurationAsync() + public async Task RunStreamingAsync_SendMessageRequest_DoesNotSetReturnImmediatelyConfigurationAsync() { // Arrange this._handler.StreamingResponseToReturn = new StreamResponse From 1b73d56ac83c3afabe003a4586ac45db207b87b2 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 13 Apr 2026 19:48:28 +0100 Subject: [PATCH 09/11] refactor AsAIAgent to delegate client creation to A2AClientFactory.Create --- .../src/Microsoft.Agents.AI.A2A/A2AAgent.cs | 6 +- .../Extensions/A2AAgentCardExtensions.cs | 26 ++---- .../Extensions/A2ACardResolverExtensions.cs | 8 +- .../Extensions/A2AClientExtensions.cs | 6 +- .../A2AAgentTests.cs | 41 ++++++++-- .../Extensions/A2AAgentCardExtensionsTests.cs | 79 +++++++++++++++++-- .../A2ACardResolverExtensionsTests.cs | 37 ++++++++- .../Extensions/A2AClientExtensionsTests.cs | 36 +++++++++ 8 files changed, 195 insertions(+), 44 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs index 029d7f241f..b2ff250610 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs @@ -27,7 +27,7 @@ public sealed class A2AAgent : AIAgent { private static readonly AIAgentMetadata s_agentMetadata = new("a2a"); - private readonly A2AClient _a2aClient; + private readonly IA2AClient _a2aClient; private readonly string? _id; private readonly string? _name; private readonly string? _description; @@ -41,7 +41,7 @@ public sealed class A2AAgent : AIAgent /// The the name of the agent. /// The description of the agent. /// Optional logger factory to use for logging. - public A2AAgent(A2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) + public A2AAgent(IA2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) { _ = Throw.IfNull(a2aClient); @@ -224,7 +224,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA /// public override object? GetService(Type serviceType, object? serviceKey = null) => base.GetService(serviceType, serviceKey) - ?? (serviceType == typeof(A2AClient) ? this._a2aClient + ?? (serviceType == typeof(IA2AClient) ? this._a2aClient : serviceType == typeof(AIAgentMetadata) ? s_agentMetadata : null); diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs index bda0290af5..6f66bea716 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs @@ -1,8 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; -using System.Collections.Generic; -using System.Linq; using System.Net.Http; using Microsoft.Agents.AI; using Microsoft.Extensions.Logging; @@ -28,27 +25,14 @@ public static class A2AAgentCardExtensions /// The to use for the agent creation. /// The to use for HTTP requests. /// The logger factory for enabling logging within the agent. - /// - /// An optional callback to select which to use from the card's - /// . When not provided, the first interface is used. + /// + /// Optional controlling protocol binding preference. + /// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback. /// /// An instance backed by the A2A agent. - public static AIAgent AsAIAgent(this AgentCard card, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null, Func, AgentInterface>? interfaceSelector = null) + public static AIAgent AsAIAgent(this AgentCard card, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null, A2AClientOptions? options = null) { - var interfaces = card.SupportedInterfaces - ?? throw new InvalidOperationException("The AgentCard does not have any SupportedInterfaces."); - - // Use the provided selector or default to the first interface. - var selectedInterface = interfaceSelector is not null - ? interfaceSelector(interfaces) - : interfaces.FirstOrDefault() - ?? throw new InvalidOperationException("The AgentCard does not have any SupportedInterfaces with a URL."); - - var url = selectedInterface.Url - ?? throw new InvalidOperationException("The selected AgentInterface does not have a URL."); - - // Create the A2A client using the agent URL from the card. - var a2aClient = new A2AClient(new Uri(url), httpClient); + var a2aClient = A2AClientFactory.Create(card, httpClient, options); return a2aClient.AsAIAgent(name: card.Name, description: card.Description, loggerFactory: loggerFactory); } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs index 6a32822fea..4d4f3ec811 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs @@ -35,13 +35,17 @@ public static class A2ACardResolverExtensions /// The to use for the agent creation. /// The to use for HTTP requests. /// The logger factory for enabling logging within the agent. + /// + /// Optional controlling protocol binding preference. + /// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback. + /// /// The to monitor for cancellation requests. The default is . /// An instance backed by the A2A agent. - public static async Task GetAIAgentAsync(this A2ACardResolver resolver, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default) + public static async Task GetAIAgentAsync(this A2ACardResolver resolver, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null, A2AClientOptions? options = null, CancellationToken cancellationToken = default) { // Obtain the agent card from the resolver. var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false); - return agentCard.AsAIAgent(httpClient, loggerFactory); + return agentCard.AsAIAgent(httpClient, loggerFactory, options); } } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs index cd93ca0bac..c7386309d3 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs @@ -7,7 +7,7 @@ namespace A2A; /// -/// Provides extension methods for +/// Provides extension methods for /// to simplify the creation of A2A agents. /// /// @@ -29,12 +29,12 @@ public static class A2AClientExtensions /// Direct Configuration / Private Discovery /// discovery mechanism. /// - /// The to use for the agent. + /// The to use for the agent. /// The unique identifier for the agent. /// The the name of the agent. /// The description of the agent. /// Optional logger factory for enabling logging within the agent. /// An instance backed by the A2A agent. - public static AIAgent AsAIAgent(this A2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) => + public static AIAgent AsAIAgent(this IA2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) => new A2AAgent(client, id, name, description, loggerFactory); } diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs index a2061f4e0f..0af7f57071 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs @@ -55,6 +55,21 @@ public void Constructor_WithNullA2AClient_ThrowsArgumentNullException() => // Act & Assert Assert.Throws(() => new A2AAgent(null!)); + [Fact] + public void Constructor_WithIA2AClient_InitializesCorrectly() + { + // Arrange + IA2AClient ia2aClient = this._a2aClient; + + // Act + var agent = new A2AAgent(ia2aClient, "ia2a-id", "IA2A Agent", "An agent from IA2AClient"); + + // Assert + Assert.Equal("ia2a-id", agent.Id); + Assert.Equal("IA2A Agent", agent.Name); + Assert.Equal("An agent from IA2AClient", agent.Description); + } + [Fact] public void Constructor_WithDefaultParameters_UsesBaseProperties() { @@ -1344,19 +1359,33 @@ public async Task RunStreamingAsync_WithInvalidSessionType_ThrowsInvalidOperatio #region GetService Method Tests /// - /// Verify that GetService returns A2AClient when requested. + /// Verify that GetService returns IA2AClient when requested. /// [Fact] - public void GetService_RequestingA2AClient_ReturnsA2AClient() + public void GetService_RequestingIA2AClient_ReturnsA2AClient() { // Arrange & Act - var result = this._agent.GetService(typeof(A2AClient)); + var result = this._agent.GetService(typeof(IA2AClient)); // Assert Assert.NotNull(result); Assert.Same(this._a2aClient, result); } + /// + /// Verify that GetService returns null when requesting the concrete A2AClient type + /// since the agent now exposes IA2AClient instead. + /// + [Fact] + public void GetService_RequestingConcreteA2AClient_ReturnsNull() + { + // Arrange & Act + var result = this._agent.GetService(typeof(A2AClient)); + + // Assert + Assert.Null(result); + } + /// /// Verify that GetService returns AIAgentMetadata when requested. /// @@ -1431,10 +1460,10 @@ public void GetService_RequestingAIAgentType_ReturnsBaseImplementation() /// Verify that GetService calls base.GetService() first but continues to derived logic when base returns null. /// [Fact] - public void GetService_RequestingA2AClientWithServiceKey_CallsBaseFirstThenDerivedLogic() + public void GetService_RequestingIA2AClientWithServiceKey_CallsBaseFirstThenDerivedLogic() { - // Arrange & Act - Request A2AClient with a service key (base.GetService will return null due to serviceKey) - var result = this._agent.GetService(typeof(A2AClient), "some-key"); + // Arrange & Act - Request IA2AClient with a service key (base.GetService will return null due to serviceKey) + var result = this._agent.GetService(typeof(IA2AClient), "some-key"); // Assert Assert.NotNull(result); diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs index abf1aa2325..93ee20ab0d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs @@ -57,7 +57,7 @@ public async Task RunIAgentAsync_SendsRequestToTheUrlSpecifiedInAgentCardAsync() Parts = [Part.FromText("Response")], }); - var agent = this._agentCard.AsAIAgent(httpClient); + var agent = this._agentCard.AsAIAgent(httpClient: httpClient); // Act await agent.RunAsync("Test input"); @@ -68,7 +68,7 @@ public async Task RunIAgentAsync_SendsRequestToTheUrlSpecifiedInAgentCardAsync() } [Fact] - public async Task AsAIAgent_WithInterfaceSelector_UsesSelectedInterfaceAsync() + public async Task AsAIAgent_WithPreferredBindings_UsesMatchingInterfaceAsync() { // Arrange var card = new AgentCard @@ -77,9 +77,8 @@ public async Task AsAIAgent_WithInterfaceSelector_UsesSelectedInterfaceAsync() Description = "An agent with multiple interfaces", SupportedInterfaces = [ - new AgentInterface { Url = "http://first/agent" }, - new AgentInterface { Url = "http://second/agent", ProtocolBinding = "grpc" }, - new AgentInterface { Url = "http://third/agent", ProtocolBinding = "http" }, + new AgentInterface { Url = "http://first/agent", ProtocolBinding = ProtocolBindingNames.HttpJson }, + new AgentInterface { Url = "http://second/agent", ProtocolBinding = ProtocolBindingNames.JsonRpc }, ] }; @@ -92,15 +91,79 @@ public async Task AsAIAgent_WithInterfaceSelector_UsesSelectedInterfaceAsync() Parts = [Part.FromText("Response")], }); - var agent = card.AsAIAgent(httpClient, interfaceSelector: interfaces => - interfaces.First(i => i.ProtocolBinding == "http")); + var options = new A2AClientOptions + { + PreferredBindings = [ProtocolBindingNames.JsonRpc] + }; + + var agent = card.AsAIAgent(httpClient, options: options); // Act await agent.RunAsync("Test input"); // Assert Assert.Single(handler.CapturedUris); - Assert.Equal(new Uri("http://third/agent"), handler.CapturedUris[0]); + Assert.Equal(new Uri("http://second/agent"), handler.CapturedUris[0]); + } + + [Fact] + public void AsAIAgent_WithNullOptions_UsesDefaultBindingPreference() + { + // Arrange + var card = new AgentCard + { + Name = "Default Options Agent", + Description = "Tests default A2AClientOptions behavior", + SupportedInterfaces = + [ + new AgentInterface { Url = "http://default/agent" }, + ] + }; + + // Act - null options should use defaults (HTTP+JSON first, JSON-RPC as fallback) + var agent = card.AsAIAgent(options: null); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + Assert.Equal("Default Options Agent", agent.Name); + } + + [Fact] + public void AsAIAgent_WithNoMatchingBinding_ThrowsInvalidOperationException() + { + // Arrange + var card = new AgentCard + { + Name = "Unmatched Binding Agent", + Description = "Agent with unsupported binding only", + SupportedInterfaces = + [ + new AgentInterface { Url = "http://grpc/agent", ProtocolBinding = "GRPC" }, + ] + }; + + var options = new A2AClientOptions + { + PreferredBindings = [ProtocolBindingNames.JsonRpc] + }; + + // Act & Assert - factory should throw when no matching binding exists + Assert.ThrowsAny(() => card.AsAIAgent(options: options)); + } + + [Fact] + public void AsAIAgent_WithNoSupportedInterfaces_ThrowsException() + { + // Arrange + var card = new AgentCard + { + Name = "No Interfaces Agent", + Description = "Agent with no supported interfaces", + }; + + // Act & Assert + Assert.ThrowsAny(() => card.AsAIAgent()); } internal sealed class HttpMessageHandlerStub : HttpMessageHandler diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs index 95cb2a67d2..8a664b7fc9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs @@ -68,7 +68,7 @@ public async Task RunIAgentAsync_WithUrlFromAgentCard_SendsRequestToTheUrlAsync( Parts = [Part.FromText("Response")], }); - var agent = await this._resolver.GetAIAgentAsync(this._httpClient); + var agent = await this._resolver.GetAIAgentAsync(httpClient: this._httpClient); // Act await agent.RunAsync("Test input"); @@ -78,6 +78,41 @@ public async Task RunIAgentAsync_WithUrlFromAgentCard_SendsRequestToTheUrlAsync( Assert.Equal(new Uri("http://test-endpoint/agent"), this._handler.CapturedUris[1]); } + [Fact] + public async Task GetAIAgentAsync_WithOptions_PassesOptionsToFactoryAsync() + { + // Arrange + this._handler.ResponsesToReturn.Enqueue(new AgentCard + { + Name = "Options Agent", + Description = "Agent with multiple interfaces", + SupportedInterfaces = + [ + new AgentInterface { Url = "http://httpjson/agent", ProtocolBinding = ProtocolBindingNames.HttpJson }, + new AgentInterface { Url = "http://jsonrpc/agent", ProtocolBinding = ProtocolBindingNames.JsonRpc }, + ] + }); + this._handler.ResponsesToReturn.Enqueue(new Message + { + Role = Role.Agent, + Parts = [Part.FromText("Response")], + }); + + var options = new A2AClientOptions + { + PreferredBindings = [ProtocolBindingNames.JsonRpc] + }; + + var agent = await this._resolver.GetAIAgentAsync(httpClient: this._httpClient, options: options); + + // Act + await agent.RunAsync("Test input"); + + // Assert + Assert.Equal(2, this._handler.CapturedUris.Count); + Assert.Equal(new Uri("http://jsonrpc/agent"), this._handler.CapturedUris[1]); + } + public void Dispose() { this._handler.Dispose(); diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AClientExtensionsTests.cs index 9ad4d982a9..80b5107bf1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AClientExtensionsTests.cs @@ -30,4 +30,40 @@ public void GetAIAgent_WithAllParameters_ReturnsA2AAgentWithSpecifiedProperties( Assert.Equal(TestName, agent.Name); Assert.Equal(TestDescription, agent.Description); } + + [Fact] + public void GetAIAgent_WithIA2AClient_ReturnsA2AAgentWithSpecifiedProperties() + { + // Arrange - use IA2AClient reference type to verify the extension method works with the interface + IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint")); + + const string TestId = "ia2a-agent-id"; + const string TestName = "IA2A Agent"; + const string TestDescription = "Agent created from IA2AClient"; + + // Act + var agent = a2aClient.AsAIAgent(TestId, TestName, TestDescription); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + Assert.Equal(TestId, agent.Id); + Assert.Equal(TestName, agent.Name); + Assert.Equal(TestDescription, agent.Description); + } + + [Fact] + public void GetAIAgent_WithIA2AClient_ExposesClientViaGetService() + { + // Arrange + IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint")); + + // Act + var agent = a2aClient.AsAIAgent(); + + // Assert + var service = agent.GetService(typeof(IA2AClient)); + Assert.NotNull(service); + Assert.Same(a2aClient, service); + } } From e0e741c32ddc147515af69a3e97251b3ff19648b Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 13 Apr 2026 20:34:06 +0100 Subject: [PATCH 10/11] move A2A agent samples from 04-hosting to 02-agents --- dotnet/agent-framework-dotnet.slnx | 10 +++++----- .../A2AAgent_AsFunctionTools.csproj | 0 .../A2A/A2AAgent_AsFunctionTools/Program.cs | 0 .../A2A/A2AAgent_AsFunctionTools/README.md | 0 .../A2AAgent_PollingForTaskCompletion.csproj | 0 .../A2A/A2AAgent_PollingForTaskCompletion/Program.cs | 0 .../A2A/A2AAgent_PollingForTaskCompletion/README.md | 0 .../A2AAgent_StreamReconnection.csproj | 0 .../A2A/A2AAgent_StreamReconnection/Program.cs | 0 .../A2A/A2AAgent_StreamReconnection/README.md | 0 dotnet/samples/{04-hosting => 02-agents}/A2A/README.md | 2 +- dotnet/samples/02-agents/README.md | 1 + dotnet/samples/README.md | 2 +- 13 files changed, 8 insertions(+), 7 deletions(-) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj (100%) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_AsFunctionTools/Program.cs (100%) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_AsFunctionTools/README.md (100%) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj (100%) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_PollingForTaskCompletion/Program.cs (100%) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_PollingForTaskCompletion/README.md (100%) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj (100%) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_StreamReconnection/Program.cs (100%) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_StreamReconnection/README.md (100%) rename dotnet/samples/{04-hosting => 02-agents}/A2A/README.md (95%) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 502cab61fd..22460827ef 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -284,11 +284,11 @@ - - - - - + + + + + diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj b/dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj similarity index 100% rename from dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj rename to dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/Program.cs b/dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/Program.cs similarity index 100% rename from dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/Program.cs rename to dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/Program.cs diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/README.md b/dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/README.md similarity index 100% rename from dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/README.md rename to dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/README.md diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj b/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj similarity index 100% rename from dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj rename to dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/Program.cs b/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/Program.cs similarity index 100% rename from dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/Program.cs rename to dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/Program.cs diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/README.md b/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/README.md similarity index 100% rename from dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/README.md rename to dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/README.md diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj similarity index 100% rename from dotnet/samples/04-hosting/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj rename to dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_StreamReconnection/Program.cs b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/Program.cs similarity index 100% rename from dotnet/samples/04-hosting/A2A/A2AAgent_StreamReconnection/Program.cs rename to dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/Program.cs diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_StreamReconnection/README.md b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/README.md similarity index 100% rename from dotnet/samples/04-hosting/A2A/A2AAgent_StreamReconnection/README.md rename to dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/README.md diff --git a/dotnet/samples/04-hosting/A2A/README.md b/dotnet/samples/02-agents/A2A/README.md similarity index 95% rename from dotnet/samples/04-hosting/A2A/README.md rename to dotnet/samples/02-agents/A2A/README.md index 2f161748df..c804d0d71c 100644 --- a/dotnet/samples/04-hosting/A2A/README.md +++ b/dotnet/samples/02-agents/A2A/README.md @@ -3,7 +3,7 @@ These samples demonstrate how to work with Agent-to-Agent (A2A) specific features in the Agent Framework. For other samples that demonstrate how to use AIAgent instances, -see the [Getting Started With Agents](../../02-agents/Agents/README.md) samples. +see the [Getting Started With Agents](../Agents/README.md) samples. ## Prerequisites diff --git a/dotnet/samples/02-agents/README.md b/dotnet/samples/02-agents/README.md index 5ff0db416d..69f649c9b4 100644 --- a/dotnet/samples/02-agents/README.md +++ b/dotnet/samples/02-agents/README.md @@ -19,3 +19,4 @@ The getting started samples demonstrate the fundamental concepts and functionali | [Declarative Agents](./DeclarativeAgents) | Loading and executing AI agents from YAML configuration files | | [AG-UI](./AGUI/README.md) | Getting started with AG-UI (Agent UI Protocol) servers and clients | | [Dev UI](./DevUI/README.md) | Interactive web interface for testing and debugging AI agents during development | +| [A2A Agents](./A2A/README.md) | Working with Agent-to-Agent (A2A) specific features | diff --git a/dotnet/samples/README.md b/dotnet/samples/README.md index 577b8bccbd..063e5cfc3f 100644 --- a/dotnet/samples/README.md +++ b/dotnet/samples/README.md @@ -16,7 +16,7 @@ were local agents. These are supported using various `AIAgent` subclasses. | [`01-get-started/`](./01-get-started/) | Progressive tutorial: hello agent → hosting | | [`02-agents/`](./02-agents/) | Deep-dive by concept: tools, middleware, providers, orchestrations | | [`03-workflows/`](./03-workflows/) | Workflow patterns: sequential, concurrent, state, declarative | -| [`04-hosting/`](./04-hosting/) | Deployment: Azure Functions, Durable Tasks, A2A | +| [`04-hosting/`](./04-hosting/) | Deployment: Azure Functions, Durable Tasks | | [`05-end-to-end/`](./05-end-to-end/) | Full applications, evaluation, demos | ## Getting Started From 22d74cde695492d456c1b19c0b0c321d45e33e3a Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Tue, 14 Apr 2026 12:31:42 +0100 Subject: [PATCH 11/11] add A2A protocol selection sample demonstrating A2AClientOptions.PreferredBindings --- dotnet/agent-framework-dotnet.slnx | 1 + .../A2AAgent_ProtocolSelection.csproj | 19 ++++++++++ .../A2A/A2AAgent_ProtocolSelection/Program.cs | 36 +++++++++++++++++++ .../A2A/A2AAgent_ProtocolSelection/README.md | 27 ++++++++++++++ dotnet/samples/02-agents/A2A/README.md | 1 + 5 files changed, 84 insertions(+) create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/Program.cs create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/README.md diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 22460827ef..127b065220 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -289,6 +289,7 @@ + diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj new file mode 100644 index 0000000000..d21ac952b3 --- /dev/null +++ b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/Program.cs b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/Program.cs new file mode 100644 index 0000000000..415a48b2fc --- /dev/null +++ b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/Program.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to select the A2A protocol binding (HTTP+JSON vs JSON-RPC) when +// creating an AIAgent from an A2A agent card using A2AClientOptions.PreferredBindings. + +using A2A; +using Microsoft.Agents.AI; + +var a2aAgentHost = "http://localhost:5048"; + +// Initialize an A2ACardResolver to get an A2A agent card. +A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost)); + +// Get the agent card +AgentCard agentCard = await agentCardResolver.GetAgentCardAsync(); + +// Use A2AClientOptions to explicitly select the HTTP+JSON protocol binding. +// This tells the A2A client factory to prefer the HTTP+JSON interface when the agent card +// advertises multiple supported interfaces. +A2AClientOptions options = new() +{ + PreferredBindings = [ProtocolBindingNames.HttpJson] +}; + +// To prefer JSON-RPC instead, use: +// A2AClientOptions options = new() +// { +// PreferredBindings = [ProtocolBindingNames.JsonRpc] +// }; + +// Create an instance of the AIAgent for an existing A2A agent, using the specified protocol binding. +AIAgent agent = agentCard.AsAIAgent(options: options); + +// Invoke the agent and output the text result. +AgentResponse response = await agent.RunAsync("Tell me a joke about a pirate."); +Console.WriteLine(response); diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/README.md b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/README.md new file mode 100644 index 0000000000..b50a76240c --- /dev/null +++ b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/README.md @@ -0,0 +1,27 @@ +# A2A Agent Protocol Selection + +This sample demonstrates how to select the A2A protocol binding when creating an `AIAgent` from an A2A agent card. + +A2A agents can expose multiple interfaces with different protocol bindings (e.g., HTTP+JSON, JSON-RPC). By default, `AsAIAgent()` prefers HTTP+JSON with JSON-RPC as a fallback. This sample shows how to use `A2AClientOptions.PreferredBindings` to explicitly control which protocol binding is used. + +The sample: + +- Connects to an A2A agent server specified in the `A2A_AGENT_HOST` environment variable +- Configures `A2AClientOptions` to prefer the HTTP+JSON protocol binding +- Creates an `AIAgent` from the resolved agent card using the specified binding +- Sends a message to the agent and displays the response + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 10.0 SDK or later +- An A2A agent server running and accessible via HTTP + +**Note**: These samples need to be run against a valid A2A server. If no A2A server is available, they can be run against the echo-agent that can be spun up locally by following the guidelines at: https://github.com/a2aproject/a2a-dotnet/blob/main/samples/AgentServer/README.md + +Set the following environment variable: + +```powershell +$env:A2A_AGENT_HOST="http://localhost:5000" # Replace with your A2A agent server host +``` diff --git a/dotnet/samples/02-agents/A2A/README.md b/dotnet/samples/02-agents/A2A/README.md index c804d0d71c..28f2c0a910 100644 --- a/dotnet/samples/02-agents/A2A/README.md +++ b/dotnet/samples/02-agents/A2A/README.md @@ -16,6 +16,7 @@ See the README.md for each sample for the prerequisites for that sample. |[A2A Agent As Function Tools](./A2AAgent_AsFunctionTools/)|This sample demonstrates how to represent an A2A agent as a set of function tools, where each function tool corresponds to a skill of the A2A agent, and register these function tools with another AI agent so it can leverage the A2A agent's skills.| |[A2A Agent Polling For Task Completion](./A2AAgent_PollingForTaskCompletion/)|This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A agent.| |[A2A Agent Stream Reconnection](./A2AAgent_StreamReconnection/)|This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens, allowing recovery from stream interruptions.| +|[A2A Agent Protocol Selection](./A2AAgent_ProtocolSelection/)|This sample demonstrates how to select the A2A protocol binding (HTTP+JSON vs JSON-RPC) when creating an AIAgent from an A2A agent card using A2AClientOptions.| ## Running the samples from the console