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/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_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/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) 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/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..a7680a2f88 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs @@ -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..f2eaada02f 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 task) + { + return new AgentResponse + { + AgentId = this.Id, + ResponseId = task.Id, + FinishReason = MapTaskStateToFinishReason(task.Status.State), + RawRepresentation = task, + Messages = task.ToChatMessages() ?? [], + ContinuationToken = CreateContinuationToken(task.Id, task.Status.State), + AdditionalProperties = task.Metadata?.ToAdditionalProperties(), + }; + } + + private AgentResponseUpdate ConvertToAgentResponseUpdate(Message message) { return new AgentResponseUpdate { @@ -349,32 +360,35 @@ private AgentResponseUpdate ConvertToAgentResponseUpdate(AgentTask task) RawRepresentation = task, Role = ChatRole.Assistant, Contents = task.ToAIContents(), + ContinuationToken = CreateContinuationToken(task.Id, task.Status.State), AdditionalProperties = task.Metadata?.ToAdditionalProperties(), }; } - private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskUpdateEvent taskUpdateEvent) + private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskStatusUpdateEvent statusUpdateEvent) { - AgentResponseUpdate responseUpdate = new() + return new AgentResponseUpdate { AgentId = this.Id, - ResponseId = taskUpdateEvent.TaskId, - RawRepresentation = taskUpdateEvent, + ResponseId = statusUpdateEvent.TaskId, + RawRepresentation = statusUpdateEvent, Role = ChatRole.Assistant, - AdditionalProperties = taskUpdateEvent.Metadata?.ToAdditionalProperties() ?? [], + FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State), + AdditionalProperties = statusUpdateEvent.Metadata?.ToAdditionalProperties() ?? [], }; + } - if (taskUpdateEvent is TaskArtifactUpdateEvent artifactUpdateEvent) - { - 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..8ad7dab668 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) => this._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) + => this._pendingModifications.AddOrUpdate(taskId, modification, (_, existing) => task => + { + existing(task); + modification(task); + }); - await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); + internal void ClearModification(string taskId) + => this._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 (this._pendingModifications.TryRemove(taskId, out var modification)) { - await CompleteWithArtifactAsync(agentTask.Id, response, taskManager, cancellationToken).ConfigureAwait(false); + modification(task); } + + await this._inner.SaveTaskAsync(taskId, task, cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException) - { - throw; - } - catch (Exception) + + public Task GetTaskAsync(string taskId, CancellationToken cancellationToken) + => this._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; + this._pendingModifications.TryRemove(taskId, out _); + return this._inner.DeleteTaskAsync(taskId, cancellationToken); } + + public Task ListTasksAsync(ListTasksRequest request, CancellationToken cancellationToken) + => this._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) + { + this._hostAgent = hostAgent; + this._runMode = runMode; + this._taskStore = taskStore; + this._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 this.HandleTaskUpdateAsync(context, eventQueue, cancellationToken).ConfigureAwait(false); + } + else + { + await this.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 this._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 this._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 this._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 this._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 = this._continuationTokenJsonOptions; + var originalMessage = context.Message; + this._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 this._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 this._hostAgent.RunAsync( + ExtractChatMessagesFromTaskHistory(agentTask), + session: session, + options: new AgentRunOptions { AllowBackgroundResponses = true }, + cancellationToken: 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 = this._continuationTokenJsonOptions; + this._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..3e984db83a 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. ///