From d151b9beb40fa5cc26df3ee880e880c26365955e Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Fri, 21 Nov 2025 16:41:25 +0000 Subject: [PATCH 01/10] add support for baackground responses to a2a agent --- dotnet/agent-framework-dotnet.slnx | 3 +- .../A2AAgent_PollingForTaskCompletion.csproj | 26 ++ .../Program.cs | 1 + .../README.md | 1 + dotnet/samples/GettingStarted/A2A/README.md | 1 + .../src/Microsoft.Agents.AI.A2A/A2AAgent.cs | 181 ++++++-- .../Microsoft.Agents.AI.A2A/A2AAgentThread.cs | 48 +- .../A2AContinuationToken.cs | 81 ++++ .../A2AJsonUtilities.cs | 80 ++++ .../Extensions/A2AAgentTaskExtensions.cs | 23 +- .../Extensions/A2AArtifactExtensions.cs | 18 +- .../Microsoft.Agents.AI.A2A.csproj | 5 +- .../A2AAgentTests.cs | 418 +++++++++++++++++- .../A2AAgentThreadTests.cs | 30 ++ .../A2AContinuationTokenTests.cs | 152 +++++++ .../Extensions/A2AAgentTaskExtensionsTests.cs | 169 +++++++ .../Extensions/A2AArtifactExtensionsTests.cs | 107 +++++ .../Microsoft.Agents.AI.A2A.UnitTests.csproj | 5 +- 18 files changed, 1294 insertions(+), 55 deletions(-) create mode 100644 dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj create mode 100644 dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/Program.cs create mode 100644 dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md create mode 100644 dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.A2A/A2AJsonUtilities.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentThreadTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AContinuationTokenTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 908884476d..2c0976b918 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -40,6 +40,7 @@ + @@ -384,4 +385,4 @@ - + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj new file mode 100644 index 0000000000..3f82ba012c --- /dev/null +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj @@ -0,0 +1,26 @@ + + + + Exe + net9.0 + A2AAgent_PollingForTaskCompletion + + enable + enable + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/Program.cs b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/Program.cs new file mode 100644 index 0000000000..5e7b3619fc --- /dev/null +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/Program.cs @@ -0,0 +1 @@ +// Copyright (c) Microsoft. All rights reserved. // This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A AI agent. using A2A; using Microsoft.Agents.AI; var a2aAgentHost = Environment.GetEnvironmentVariable("A2A_AGENT_HOST") ?? throw new InvalidOperationException("A2A_AGENT_HOST is not set."); // Initialize an A2ACardResolver to get an A2A agent card. A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost)); // Get the agent card AgentCard agentCard = await agentCardResolver.GetAgentCardAsync(); // Create an instance of the AIAgent for an existing A2A agent specified by the agent card. AIAgent agent = agentCard.GetAIAgent(); AgentThread thread = agent.GetNewThread(); // Start the initial run with a long-running task. AgentRunResponse 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.", thread); // Poll until the response is complete. while (response.ContinuationToken is { } token) { // Wait before polling again. await Task.Delay(TimeSpan.FromSeconds(2)); // Continue with the token. response = await agent.RunAsync(thread, options: new AgentRunOptions { ContinuationToken = token }); } // Display the result Console.WriteLine(response.Text); \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md new file mode 100644 index 0000000000..1d78314be0 --- /dev/null +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md @@ -0,0 +1 @@ +# What This Sample Shows This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A AI agent. The sample: - Connects to an A2A agent server specified in the `A2A_AGENT_HOST` environment variable - Sends a request to the agent that may take time to complete - Polls the agent at regular intervals using continuation tokens until a final response is received - Displays the final result This pattern is useful when an AI model cannot complete a complex task in a single response and needs multiple rounds of processing. # Prerequisites Before you begin, ensure you have the following prerequisites: - .NET 9.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 ``` \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/A2A/README.md b/dotnet/samples/GettingStarted/A2A/README.md index 3ddac95996..b513ffa929 100644 --- a/dotnet/samples/GettingStarted/A2A/README.md +++ b/dotnet/samples/GettingStarted/A2A/README.md @@ -14,6 +14,7 @@ See the README.md for each sample for the prerequisites for that sample. |Sample|Description| |---|---| |[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.| ## Running the samples from the console diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs index cafbf90b87..2e9209e498 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.ServerSentEvents; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; @@ -74,26 +75,32 @@ public override async Task RunAsync(IEnumerable m { _ = Throw.IfNull(messages); - var a2aMessage = messages.ToA2AMessage(); - thread ??= this.GetNewThread(); if (thread is not A2AAgentThread typedThread) { throw new InvalidOperationException("The provided thread is not compatible with the agent. Only threads created by the agent can be used."); } - // Linking the message to the existing conversation, if any. - a2aMessage.ContextId = typedThread.ContextId; - this._logger.LogA2AAgentInvokingAgent(nameof(RunAsync), this.Id, this.Name); - var a2aResponse = await this._a2aClient.SendMessageAsync(new MessageSendParams { Message = a2aMessage }, cancellationToken).ConfigureAwait(false); + A2AResponse? a2aResponse = null; + + if (GetContinuationToken(messages, options) is { } token) + { + a2aResponse = await this._a2aClient.GetTaskAsync(token.TaskId, cancellationToken).ConfigureAwait(false); + } + else + { + var a2aMessage = CreateA2AMessage(typedThread, messages); + + a2aResponse = await this._a2aClient.SendMessageAsync(new MessageSendParams { Message = a2aMessage }, cancellationToken).ConfigureAwait(false); + } this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name); if (a2aResponse is AgentMessage message) { - UpdateThreadConversationId(typedThread, message.ContextId); + UpdateThread(typedThread, message.ContextId); return new AgentRunResponse { @@ -104,16 +111,18 @@ public override async Task RunAsync(IEnumerable m AdditionalProperties = message.Metadata.ToAdditionalProperties(), }; } + if (a2aResponse is AgentTask agentTask) { - UpdateThreadConversationId(typedThread, agentTask.ContextId); + UpdateThread(typedThread, agentTask.ContextId, agentTask.Id); return new AgentRunResponse { AgentId = this.Id, ResponseId = agentTask.Id, RawRepresentation = agentTask, - Messages = agentTask.ToChatMessages(), + Messages = agentTask.ToChatMessages() ?? [], + ContinuationToken = CreateContinuationToken(agentTask.Id, agentTask.Status.State), AdditionalProperties = agentTask.Metadata.ToAdditionalProperties(), }; } @@ -126,43 +135,67 @@ public override async IAsyncEnumerable RunStreamingAsync { _ = Throw.IfNull(messages); - var a2aMessage = messages.ToA2AMessage(); - thread ??= this.GetNewThread(); if (thread is not A2AAgentThread typedThread) { throw new InvalidOperationException("The provided thread is not compatible with the agent. Only threads created by the agent can be used."); } - // Linking the message to the existing conversation, if any. - a2aMessage.ContextId = typedThread.ContextId; - this._logger.LogA2AAgentInvokingAgent(nameof(RunStreamingAsync), this.Id, this.Name); - var a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(new MessageSendParams { Message = a2aMessage }, cancellationToken).ConfigureAwait(false); + ConfiguredCancelableAsyncEnumerable> a2aSseEvents; + + if (options?.ContinuationToken is not null) + { + // 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); + } + + var a2aMessage = CreateA2AMessage(typedThread, messages); + + a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(new MessageSendParams { Message = a2aMessage }, cancellationToken).ConfigureAwait(false); this._logger.LogAgentChatClientInvokedAgent(nameof(RunStreamingAsync), this.Id, this.Name); + string? contextId = null; + string? taskId = null; + await foreach (var sseEvent in a2aSseEvents) { - if (sseEvent.Data is not AgentMessage message) + if (sseEvent.Data is AgentMessage message) { - throw new NotSupportedException($"Only message responses are supported from A2A agents. Received: {sseEvent.Data?.GetType().FullName ?? "null"}"); + contextId = message.ContextId; + + yield return this.ConvertToAgentResponseUpdate(message); } + else if (sseEvent.Data is AgentTask task) + { + contextId = task.ContextId; + taskId = task.Id; - UpdateThreadConversationId(typedThread, message.ContextId); + yield return this.ConvertToAgentResponseUpdate(task); + } + else if (sseEvent.Data is TaskUpdateEvent taskUpdateEvent) + { + contextId = taskUpdateEvent.ContextId; + taskId = taskUpdateEvent.TaskId; - yield return new AgentRunResponseUpdate + yield return this.ConvertToAgentResponseUpdate(taskUpdateEvent, options); + } + else { - AgentId = this.Id, - ResponseId = message.MessageId, - RawRepresentation = message, - Role = ChatRole.Assistant, - MessageId = message.MessageId, - Contents = [.. message.Parts.Select(part => part.ToAIContent()).OfType()], - AdditionalProperties = message.Metadata.ToAdditionalProperties(), - }; + throw new NotSupportedException($"Only message, task, task update events are supported from A2A agents. Received: {sseEvent.Data.GetType().FullName ?? "null"}"); + } } + + UpdateThread(typedThread, contextId, taskId); } /// @@ -177,7 +210,7 @@ public override async IAsyncEnumerable RunStreamingAsync /// public override string? Description => this._description ?? base.Description; - private static void UpdateThreadConversationId(A2AAgentThread? thread, string? contextId) + private static void UpdateThread(A2AAgentThread? thread, string? contextId, string? taskId = null) { if (thread is null) { @@ -194,5 +227,97 @@ private static void UpdateThreadConversationId(A2AAgentThread? thread, string? c // Assign a server-generated context Id to the thread if it's not already set. thread.ContextId ??= contextId; + thread.TaskId = taskId; + } + + private static AgentMessage CreateA2AMessage(A2AAgentThread typedThread, IEnumerable messages) + { + var a2aMessage = messages.ToA2AMessage(); + + // Linking the message to the existing conversation, if any. + // See: https://github.com/a2aproject/A2A/blob/main/docs/topics/life-of-a-task.md#group-related-interactions + a2aMessage.ContextId = typedThread.ContextId; + + // Link the message as a follow-up to an existing task, if any. + // See: https://github.com/a2aproject/A2A/blob/main/docs/topics/life-of-a-task.md#task-refinements + a2aMessage.ReferenceTaskIds = [typedThread.TaskId]; + + return a2aMessage; + } + + private static A2AContinuationToken? GetContinuationToken(IEnumerable messages, AgentRunOptions? options = null) + { + if (options?.ContinuationToken is { } token) + { + if (messages.Any()) + { + throw new InvalidOperationException("Messages are not allowed when continuing a background response using a continuation token."); + } + + return A2AContinuationToken.FromToken((A2AContinuationToken)token); + } + + return null; + } + + private static A2AContinuationToken? CreateContinuationToken(string taskId, TaskState state) + { + if (state == TaskState.Submitted || state == TaskState.Working) + { + return new A2AContinuationToken(taskId); + } + + return null; + } + + private AgentRunResponseUpdate ConvertToAgentResponseUpdate(AgentMessage message) + { + return new AgentRunResponseUpdate + { + AgentId = this.Id, + ResponseId = message.MessageId, + RawRepresentation = message, + Role = ChatRole.Assistant, + MessageId = message.MessageId, + Contents = message.Parts.ConvertAll(part => part.ToAIContent()), + AdditionalProperties = message.Metadata.ToAdditionalProperties(), + }; + } + + private AgentRunResponseUpdate ConvertToAgentResponseUpdate(AgentTask task) + { + return new AgentRunResponseUpdate + { + AgentId = this.Id, + ResponseId = task.Id, + RawRepresentation = task, + Role = ChatRole.Assistant, + Contents = task.ToAIContents(), + AdditionalProperties = task.Metadata.ToAdditionalProperties(), + }; + } + + private AgentRunResponseUpdate ConvertToAgentResponseUpdate(TaskUpdateEvent taskUpdateEvent, AgentRunOptions? options) + { + AgentRunResponseUpdate responseUpdate = new() + { + AgentId = this.Id, + ResponseId = taskUpdateEvent.TaskId, + RawRepresentation = taskUpdateEvent, + Role = ChatRole.Assistant, + AdditionalProperties = taskUpdateEvent.Metadata.ToAdditionalProperties() ?? [], + }; + + if (taskUpdateEvent is TaskStatusUpdateEvent statusUpdateEvent) + { + responseUpdate.RawRepresentation = statusUpdateEvent; + } + else if (taskUpdateEvent is TaskArtifactUpdateEvent artifactUpdateEvent) + { + responseUpdate.Contents = artifactUpdateEvent.Artifact.ToAIContents(); + responseUpdate.RawRepresentation = artifactUpdateEvent; + } + + return responseUpdate; } } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentThread.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentThread.cs index 010df78a02..55942c8dd1 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentThread.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentThread.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Text.Json; namespace Microsoft.Agents.AI.A2A; @@ -7,22 +8,59 @@ namespace Microsoft.Agents.AI.A2A; /// /// Thread for A2A based agents. /// -public sealed class A2AAgentThread : ServiceIdAgentThread +public sealed class A2AAgentThread : AgentThread { internal A2AAgentThread() { } - internal A2AAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null) : base(serializedThreadState, jsonSerializerOptions) + internal A2AAgentThread(JsonElement serializedThreadState, JsonSerializerOptions? jsonSerializerOptions = null) { + if (serializedThreadState.ValueKind != JsonValueKind.Object) + { + throw new ArgumentException("The serialized thread state must be a JSON object.", nameof(serializedThreadState)); + } + + var state = serializedThreadState.Deserialize( + A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(A2AAgentThreadState))) as A2AAgentThreadState; + + if (state?.ContextId is string contextId) + { + this.ContextId = contextId; + } + + if (state?.TaskId is string taskId) + { + this.TaskId = taskId; + } } /// /// Gets the ID for the current conversation with the A2A agent. /// - public string? ContextId + public string? ContextId { get; internal set; } + + /// + /// Gets the ID for the task the agent is currently working on. + /// + public string? TaskId { get; internal set; } + + /// + public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) + { + var state = new A2AAgentThreadState + { + ContextId = this.ContextId, + TaskId = this.TaskId + }; + + return JsonSerializer.SerializeToElement(state, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(A2AAgentThreadState))); + } + + internal sealed class A2AAgentThreadState { - get { return this.ServiceThreadId; } - internal set { this.ServiceThreadId = value; } + public string? ContextId { get; set; } + + public string? TaskId { get; set; } } } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs new file mode 100644 index 0000000000..aa1be63ee4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Text.Json; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.A2A; +#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. +internal class A2AContinuationToken : ResponseContinuationToken +{ + internal A2AContinuationToken(string taskId) + { + _ = Throw.IfNullOrEmpty(taskId); + + this.TaskId = taskId; + } + + internal string TaskId { get; } + + internal static A2AContinuationToken FromToken(ResponseContinuationToken token) + { + if (token is A2AContinuationToken longRunContinuationToken) + { + return longRunContinuationToken; + } + + ReadOnlyMemory data = token.ToBytes(); + + if (data.Length == 0) + { + Throw.ArgumentException(nameof(token), "Failed to create A2AContinuationToken from provided token because it does not contain any data."); + } + + Utf8JsonReader reader = new(data.Span); + + string taskId = null!; + + reader.Read(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + string propertyName = reader.GetString()!; + + switch (propertyName) + { + case "taskId": + reader.Read(); + taskId = reader.GetString()!; + break; + default: + throw new JsonException($"Unrecognized property '{propertyName}'."); + } + } + + return new(taskId); + } + + public override ReadOnlyMemory ToBytes() + { + using MemoryStream stream = new(); + using Utf8JsonWriter writer = new(stream); + + writer.WriteStartObject(); + + writer.WriteString("taskId", this.TaskId); + + writer.WriteEndObject(); + + writer.Flush(); + stream.Position = 0; + + return stream.ToArray(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AJsonUtilities.cs new file mode 100644 index 0000000000..2fbb2e8617 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AJsonUtilities.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Agents.AI.A2A; + +namespace Microsoft.Agents.AI; + +/// +/// Provides utility methods and configurations for JSON serialization operations for A2A agent types. +/// +public static partial class A2AJsonUtilities +{ + /// + /// Gets the default instance used for JSON serialization operations of A2A agent types. + /// + /// + /// + /// For Native AOT or applications disabling , this instance + /// includes source generated contracts for A2A agent types. + /// + /// + /// It additionally turns on the following settings: + /// + /// Enables defaults. + /// Enables as the default ignore condition for properties. + /// Enables as the default number handling for number types. + /// + /// Enables when escaping JSON strings. + /// Consuming applications must ensure that JSON outputs are adequately escaped before embedding in other document formats, such as HTML and XML. + /// + /// + /// + /// + public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions(); + + /// + /// Creates and configures the default JSON serialization options for agent abstraction types. + /// + /// The configured options. + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050:RequiresDynamicCode", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")] + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Converter is guarded by IsReflectionEnabledByDefault check.")] + private static JsonSerializerOptions CreateDefaultOptions() + { + // Copy the configuration from the source generated context. + JsonSerializerOptions options = new(JsonContext.Default.Options) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // same as AIJsonUtilities + }; + + // Chain in the resolvers from both AIJsonUtilities and our source generated context. + // We want AIJsonUtilities first to ensure any M.E.AI types are handled via its resolver. + options.TypeInfoResolverChain.Clear(); + options.TypeInfoResolverChain.Add(AgentAbstractionsJsonUtilities.DefaultOptions.TypeInfoResolver!); + + // If reflection-based serialization is enabled by default, this includes + // the default type info resolver that utilizes reflection, but we need to manually + // apply the same converter AIJsonUtilities adds for string-based enum serialization, + // as that's not propagated as part of the resolver. + if (JsonSerializer.IsReflectionEnabledByDefault) + { + options.Converters.Add(new JsonStringEnumConverter()); + } + + options.MakeReadOnly(); + return options; + } + + [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + NumberHandling = JsonNumberHandling.AllowReadingFromString)] + + // A2A agent types + [JsonSerializable(typeof(A2AAgentThread.A2AAgentThreadState))] + [ExcludeFromCodeCoverage] + private sealed partial class JsonContext : JsonSerializerContext; +} diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentTaskExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentTaskExtensions.cs index 236ecfb174..4d259785ba 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentTaskExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentTaskExtensions.cs @@ -11,20 +11,37 @@ namespace A2A; /// internal static class A2AAgentTaskExtensions { - internal static IList ToChatMessages(this AgentTask agentTask) + internal static IList? ToChatMessages(this AgentTask agentTask) { _ = Throw.IfNull(agentTask); - List messages = []; + List? messages = null; if (agentTask.Artifacts is not null) { foreach (var artifact in agentTask.Artifacts) { - messages.Add(artifact.ToChatMessage()); + (messages ??= []).Add(artifact.ToChatMessage()); } } return messages; } + + internal static IList? ToAIContents(this AgentTask agentTask) + { + _ = Throw.IfNull(agentTask); + + List? aiContents = null; + + if (agentTask.Artifacts is not null) + { + foreach (var artifact in agentTask.Artifacts) + { + (aiContents ??= []).AddRange(artifact.ToAIContents()); + } + } + + return aiContents; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AArtifactExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AArtifactExtensions.cs index 36683d549b..cecd9a8504 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AArtifactExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AArtifactExtensions.cs @@ -12,21 +12,15 @@ internal static class A2AArtifactExtensions { internal static ChatMessage ToChatMessage(this Artifact artifact) { - List? aiContents = null; - - foreach (var part in artifact.Parts) - { - var content = part.ToAIContent(); - if (content is not null) - { - (aiContents ??= []).Add(content); - } - } - - return new ChatMessage(ChatRole.Assistant, aiContents) + return new ChatMessage(ChatRole.Assistant, artifact.ToAIContents()) { AdditionalProperties = artifact.Metadata.ToAdditionalProperties(), RawRepresentation = artifact, }; } + + internal static List ToAIContents(this Artifact artifact) + { + return artifact.Parts.ConvertAll(part => part.ToAIContent()); + } } 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 46e3c97d8f..b16bbb89e3 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 @@ -10,12 +10,13 @@ true + true - - + + diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs index 9399d99528..b35666fe28 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs @@ -396,15 +396,419 @@ public async Task RunAsync_WithHostedFileContent_ConvertsToFilePartAsync() Assert.Equal("https://example.com/file.pdf", ((FilePart)message.Parts[1]).File.Uri?.ToString()); } + [Fact] + public async Task RunAsync_WithContinuationTokenAndMessages_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken("task-123") }; + + // Act & Assert + await Assert.ThrowsAsync(() => this._agent.RunAsync(inputMessages, null, options)); + } + + [Fact] + public async Task RunAsync_WithContinuationToken_CallsGetTaskAsyncAsync() + { + // Arrange + this._handler.ResponseToReturn = new AgentTask + { + Id = "task-123", + ContextId = "context-123" + }; + + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken("task-123") }; + + // Act + await this._agent.RunAsync(null, options); + + // Assert + Assert.Equal("tasks/get", this._handler.CapturedJsonRpcRequest?.Method); + Assert.Equal("task-123", this._handler.CapturedTaskIdParams?.Id); + } + + [Fact] + public async Task RunAsync_WithTaskInThreadAndMessage_AddTaskAsReferencesToMessageAsync() + { + // Arrange + this._handler.ResponseToReturn = new AgentMessage + { + MessageId = "response-123", + Role = MessageRole.Agent, + Parts = [new TextPart { Text = "Response to task" }] + }; + + var thread = (A2AAgentThread)this._agent.GetNewThread(); + thread.TaskId = "task-123"; + + var inputMessage = new ChatMessage(ChatRole.User, "Please make the background transparent"); + + // Act + await this._agent.RunAsync(inputMessage, thread); + + // Assert + var message = this._handler.CapturedMessageSendParams?.Message; + Assert.Null(message?.TaskId); + Assert.NotNull(message?.ReferenceTaskIds); + Assert.Contains("task-123", message.ReferenceTaskIds); + } + + [Fact] + public async Task RunAsync_WithAgentTask_UpdatesThreadTaskIdAsync() + { + // Arrange + this._handler.ResponseToReturn = new AgentTask + { + Id = "task-456", + ContextId = "context-789", + Status = new() { State = TaskState.Submitted } + }; + + var thread = this._agent.GetNewThread(); + + // Act + await this._agent.RunAsync("Start a task", thread); + + // Assert + var a2aThread = (A2AAgentThread)thread; + Assert.Equal("task-456", a2aThread.TaskId); + } + + [Fact] + public async Task RunAsync_WithAgentTaskResponse_ReturnsTaskResponseCorrectlyAsync() + { + // Arrange + this._handler.ResponseToReturn = 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 thread = this._agent.GetNewThread(); + + // Act + var result = await this._agent.RunAsync("Start a long-running task", thread); + + // Assert - verify task is converted correctly + Assert.NotNull(result); + Assert.Equal(this._agent.Id, result.AgentId); + Assert.Equal("task-789", result.ResponseId); + + Assert.NotNull(result.RawRepresentation); + Assert.IsType(result.RawRepresentation); + Assert.Equal("task-789", ((AgentTask)result.RawRepresentation).Id); + + // Assert - verify continuation token is set for submitted task + Assert.NotNull(result.ContinuationToken); + Assert.IsType(result.ContinuationToken); + Assert.Equal("task-789", ((A2AContinuationToken)result.ContinuationToken).TaskId); + + // Assert - verify thread is updated with context and task IDs + var a2aThread = (A2AAgentThread)thread; + Assert.Equal("context-456", a2aThread.ContextId); + Assert.Equal("task-789", a2aThread.TaskId); + + // Assert - verify metadata is preserved + Assert.NotNull(result.AdditionalProperties); + Assert.NotNull(result.AdditionalProperties["key1"]); + Assert.Equal("value1", ((JsonElement)result.AdditionalProperties["key1"]!).GetString()); + Assert.NotNull(result.AdditionalProperties["count"]); + Assert.Equal(42, ((JsonElement)result.AdditionalProperties["count"]!).GetInt32()); + } + + [Theory] + [InlineData(TaskState.Submitted)] + [InlineData(TaskState.Working)] + [InlineData(TaskState.Completed)] + [InlineData(TaskState.Failed)] + [InlineData(TaskState.Canceled)] + public async Task RunAsync_WithVariousTaskStates_ReturnsCorrectTokenAsync(TaskState taskState) + { + // Arrange + this._handler.ResponseToReturn = new AgentTask + { + Id = "task-123", + ContextId = "context-123", + Status = new() { State = taskState } + }; + + // Act + var result = await this._agent.RunAsync("Test message"); + + // Assert + if (taskState == TaskState.Submitted || taskState == TaskState.Working) + { + Assert.NotNull(result.ContinuationToken); + } + else + { + Assert.Null(result.ContinuationToken); + } + } + + [Fact] + public async Task RunStreamingAsync_WithContinuationTokenAndMessages_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken("task-123") }; + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var _ in this._agent.RunStreamingAsync(inputMessages, null, options)) + { + } + }); + } + + [Fact] + public async Task RunStreamingAsync_WithTaskInThreadAndMessage_AddTaskAsReferencesToMessageAsync() + { + // Arrange + this._handler.StreamingResponseToReturn = new AgentMessage + { + MessageId = "response-123", + Role = MessageRole.Agent, + Parts = [new TextPart { Text = "Response to task" }] + }; + + var thread = (A2AAgentThread)this._agent.GetNewThread(); + thread.TaskId = "task-123"; + + // Act + await foreach (var _ in this._agent.RunStreamingAsync("Please make the background transparent", thread)) + { + } + + // Assert + var message = this._handler.CapturedMessageSendParams?.Message; + Assert.Null(message?.TaskId); + Assert.NotNull(message?.ReferenceTaskIds); + Assert.Contains("task-123", message.ReferenceTaskIds); + } + + [Fact] + public async Task RunStreamingAsync_WithAgentTask_UpdatesThreadTaskIdAsync() + { + // Arrange + this._handler.StreamingResponseToReturn = new AgentTask + { + Id = "task-456", + ContextId = "context-789", + Status = new() { State = TaskState.Submitted } + }; + + var thread = this._agent.GetNewThread(); + + // Act + await foreach (var _ in this._agent.RunStreamingAsync("Start a task", thread)) + { + } + + // Assert + var a2aThread = (A2AAgentThread)thread; + Assert.Equal("task-456", a2aThread.TaskId); + } + + [Fact] + public async Task RunStreamingAsync_WithAgentMessage_YieldsResponseUpdateAsync() + { + // Arrange + const string MessageId = "msg-123"; + const string ContextId = "ctx-456"; + const string MessageText = "Hello from agent!"; + + this._handler.StreamingResponseToReturn = new AgentMessage + { + MessageId = MessageId, + Role = MessageRole.Agent, + ContextId = ContextId, + Parts = + [ + new TextPart { Text = MessageText } + ] + }; + + // Act + var updates = new List(); + await foreach (var update in this._agent.RunStreamingAsync("Test message")) + { + updates.Add(update); + } + + // Assert - one update should be yielded + Assert.Single(updates); + + var update0 = updates[0]; + Assert.Equal(ChatRole.Assistant, update0.Role); + Assert.Equal(MessageId, update0.MessageId); + Assert.Equal(MessageId, update0.ResponseId); + Assert.Equal(this._agent.Id, update0.AgentId); + Assert.Equal(MessageText, update0.Text); + Assert.IsType(update0.RawRepresentation); + Assert.Equal(MessageId, ((AgentMessage)update0.RawRepresentation!).MessageId); + } + + [Fact] + public async Task RunStreamingAsync_WithAgentTask_YieldsResponseUpdateAsync() + { + // Arrange + const string TaskId = "task-789"; + const string ContextId = "ctx-012"; + + this._handler.StreamingResponseToReturn = new AgentTask + { + Id = TaskId, + ContextId = ContextId, + Status = new() { State = TaskState.Submitted }, + Artifacts = [ + new() + { + ArtifactId = "art-123", + Parts = [new TextPart { Text = "Task artifact content" }] + } + ] + }; + + var thread = this._agent.GetNewThread(); + + // Act + var updates = new List(); + await foreach (var update in this._agent.RunStreamingAsync("Start long-running task", thread)) + { + updates.Add(update); + } + + // Assert - one update should be yielded from artifact + Assert.Single(updates); + + var update0 = updates[0]; + Assert.Equal(ChatRole.Assistant, update0.Role); + Assert.Equal(TaskId, update0.ResponseId); + Assert.Equal(this._agent.Id, update0.AgentId); + Assert.IsType(update0.RawRepresentation); + Assert.Equal(TaskId, ((AgentTask)update0.RawRepresentation!).Id); + + // Assert - thread should be updated with context and task IDs + var a2aThread = (A2AAgentThread)thread; + Assert.Equal(ContextId, a2aThread.ContextId); + Assert.Equal(TaskId, a2aThread.TaskId); + } + + [Fact] + public async Task RunStreamingAsync_WithTaskStatusUpdateEvent_YieldsResponseUpdateAsync() + { + // Arrange + const string TaskId = "task-status-123"; + const string ContextId = "ctx-status-456"; + + this._handler.StreamingResponseToReturn = new TaskStatusUpdateEvent + { + TaskId = TaskId, + ContextId = ContextId, + Status = new() { State = TaskState.Working } + }; + + var thread = this._agent.GetNewThread(); + + // Act + var updates = new List(); + await foreach (var update in this._agent.RunStreamingAsync("Check task status", thread)) + { + updates.Add(update); + } + + // Assert - one update should be yielded + Assert.Single(updates); + + var update0 = updates[0]; + Assert.Equal(ChatRole.Assistant, update0.Role); + Assert.Equal(TaskId, update0.ResponseId); + Assert.Equal(this._agent.Id, update0.AgentId); + Assert.IsType(update0.RawRepresentation); + + // Assert - thread should be updated with context and task IDs + var a2aThread = (A2AAgentThread)thread; + Assert.Equal(ContextId, a2aThread.ContextId); + Assert.Equal(TaskId, a2aThread.TaskId); + } + + [Fact] + public async Task RunStreamingAsync_WithTaskArtifactUpdateEvent_YieldsResponseUpdateAsync() + { + // Arrange + const string TaskId = "task-artifact-123"; + const string ContextId = "ctx-artifact-456"; + const string ArtifactContent = "Task artifact data"; + + this._handler.StreamingResponseToReturn = new TaskArtifactUpdateEvent + { + TaskId = TaskId, + ContextId = ContextId, + Artifact = new() + { + ArtifactId = "artifact-789", + Parts = [new TextPart { Text = ArtifactContent }] + } + }; + + var thread = this._agent.GetNewThread(); + + // Act + var updates = new List(); + await foreach (var update in this._agent.RunStreamingAsync("Process artifact", thread)) + { + updates.Add(update); + } + + // Assert - one update should be yielded + Assert.Single(updates); + + var update0 = updates[0]; + Assert.Equal(ChatRole.Assistant, update0.Role); + Assert.Equal(TaskId, update0.ResponseId); + Assert.Equal(this._agent.Id, update0.AgentId); + Assert.IsType(update0.RawRepresentation); + + // Assert - artifact content should be in the update + Assert.NotEmpty(update0.Contents); + Assert.Equal(ArtifactContent, update0.Text); + + // Assert - thread should be updated with context and task IDs + var a2aThread = (A2AAgentThread)thread; + Assert.Equal(ContextId, a2aThread.ContextId); + Assert.Equal(TaskId, a2aThread.TaskId); + } + public void Dispose() { this._handler.Dispose(); this._httpClient.Dispose(); } + internal sealed class A2AClientHttpMessageHandlerStub : HttpMessageHandler { + public JsonRpcRequest? CapturedJsonRpcRequest { get; set; } + public MessageSendParams? CapturedMessageSendParams { get; set; } + public TaskIdParams? CapturedTaskIdParams { get; set; } + public A2AEvent? ResponseToReturn { get; set; } public A2AEvent? StreamingResponseToReturn { get; set; } @@ -416,9 +820,19 @@ protected override async Task SendAsync(HttpRequestMessage var content = await request.Content!.ReadAsStringAsync(); #pragma warning restore CA2016 - var jsonRpcRequest = JsonSerializer.Deserialize(content)!; + this.CapturedJsonRpcRequest = JsonSerializer.Deserialize(content); - this.CapturedMessageSendParams = jsonRpcRequest.Params?.Deserialize(); + try + { + this.CapturedMessageSendParams = this.CapturedJsonRpcRequest?.Params?.Deserialize(); + } + catch { /* Ignore deserialization errors for non-MessageSendParams requests */ } + + try + { + this.CapturedTaskIdParams = this.CapturedJsonRpcRequest?.Params?.Deserialize(); + } + catch { /* Ignore deserialization errors for non-TaskIdParams requests */ } // Return the pre-configured non-streaming response if (this.ResponseToReturn is not null) diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentThreadTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentThreadTests.cs new file mode 100644 index 0000000000..90b65aa5ac --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentThreadTests.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; + +namespace Microsoft.Agents.AI.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class A2AAgentThreadTests +{ + [Fact] + public void Constructor_RoundTrip_SerializationPreservesState() + { + // Arrange + const string ContextId = "context-rt-001"; + const string TaskId = "task-rt-002"; + + A2AAgentThread originalThread = new() { ContextId = ContextId, TaskId = TaskId }; + + // Act + JsonElement serialized = originalThread.Serialize(); + + A2AAgentThread deserializedThread = new(serialized); + + // Assert + Assert.Equal(originalThread.ContextId, deserializedThread.ContextId); + Assert.Equal(originalThread.TaskId, deserializedThread.TaskId); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AContinuationTokenTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AContinuationTokenTests.cs new file mode 100644 index 0000000000..1bb0d99e00 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AContinuationTokenTests.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class A2AContinuationTokenTests +{ + [Fact] + public void Constructor_WithValidTaskId_InitializesTaskIdProperty() + { + // Arrange + const string TaskId = "task-123"; + + // Act + var token = new A2AContinuationToken(TaskId); + + // Assert + Assert.Equal(TaskId, token.TaskId); + } + + [Fact] + public void ToBytes_WithValidToken_SerializesToJsonBytes() + { + // Arrange + const string TaskId = "task-456"; + var token = new A2AContinuationToken(TaskId); + + // Act + var bytes = token.ToBytes(); + + // Assert + Assert.NotEqual(0, bytes.Length); + var jsonString = System.Text.Encoding.UTF8.GetString(bytes.ToArray()); + using var jsonDoc = JsonDocument.Parse(jsonString); + var root = jsonDoc.RootElement; + Assert.True(root.TryGetProperty("taskId", out var taskIdElement)); + Assert.Equal(TaskId, taskIdElement.GetString()); + } + + [Fact] + public void FromToken_WithA2AContinuationToken_ReturnsSameInstance() + { + // Arrange + const string TaskId = "task-direct"; + var originalToken = new A2AContinuationToken(TaskId); + + // Act + var resultToken = A2AContinuationToken.FromToken(originalToken); + + // Assert + Assert.Same(originalToken, resultToken); + Assert.Equal(TaskId, resultToken.TaskId); + } + + [Fact] + public void FromToken_WithSerializedToken_DeserializesCorrectly() + { + // Arrange + const string TaskId = "task-deserialized"; + var originalToken = new A2AContinuationToken(TaskId); + var serialized = originalToken.ToBytes(); + + // Create a mock token wrapper to pass to FromToken + var mockToken = new MockResponseContinuationToken(serialized); + + // Act + var resultToken = A2AContinuationToken.FromToken(mockToken); + + // Assert + Assert.Equal(TaskId, resultToken.TaskId); + Assert.IsType(resultToken); + } + + [Fact] + public void FromToken_RoundTrip_PreservesTaskId() + { + // Arrange + const string TaskId = "task-roundtrip-123"; + var originalToken = new A2AContinuationToken(TaskId); + var serialized = originalToken.ToBytes(); + var mockToken = new MockResponseContinuationToken(serialized); + + // Act + var deserializedToken = A2AContinuationToken.FromToken(mockToken); + var reserialized = deserializedToken.ToBytes(); + var mockToken2 = new MockResponseContinuationToken(reserialized); + var deserializedAgain = A2AContinuationToken.FromToken(mockToken2); + + // Assert + Assert.Equal(TaskId, deserializedAgain.TaskId); + } + + [Fact] + public void FromToken_WithEmptyData_ThrowsArgumentException() + { + // Arrange + var emptyToken = new MockResponseContinuationToken(ReadOnlyMemory.Empty); + + // Act & Assert + Assert.Throws(() => A2AContinuationToken.FromToken(emptyToken)); + } + + [Fact] + public void FromToken_WithMissingTaskIdProperty_ThrowsException() + { + // Arrange + var jsonWithoutTaskId = System.Text.Encoding.UTF8.GetBytes("{ \"someOtherProperty\": \"value\" }").AsMemory(); + var mockToken = new MockResponseContinuationToken(jsonWithoutTaskId); + + // Act & Assert + Assert.Throws(() => A2AContinuationToken.FromToken(mockToken)); + } + + [Fact] + public void FromToken_WithValidTaskId_ParsesTaskIdCorrectly() + { + // Arrange + const string TaskId = "task-multi-prop"; + var json = System.Text.Encoding.UTF8.GetBytes($"{{ \"taskId\": \"{TaskId}\" }}").AsMemory(); + var mockToken = new MockResponseContinuationToken(json); + + // Act + var resultToken = A2AContinuationToken.FromToken(mockToken); + + // Assert + Assert.Equal(TaskId, resultToken.TaskId); + } + + /// + /// Mock implementation of ResponseContinuationToken for testing. + /// + private sealed class MockResponseContinuationToken : ResponseContinuationToken + { + private readonly ReadOnlyMemory _data; + + public MockResponseContinuationToken(ReadOnlyMemory data) + { + this._data = data; + } + + public override ReadOnlyMemory ToBytes() + { + return this._data; + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs new file mode 100644 index 0000000000..97c9ca7c05 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using A2A; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class A2AAgentTaskExtensionsTests +{ + [Fact] + public void ToChatMessages_WithNullAgentTask_ThrowsArgumentNullException() + { + // Arrange + AgentTask agentTask = null!; + + // Act & Assert + Assert.Throws(() => agentTask.ToChatMessages()); + } + + [Fact] + public void ToAIContents_WithNullAgentTask_ThrowsArgumentNullException() + { + // Arrange + AgentTask agentTask = null!; + + // Act & Assert + Assert.Throws(() => agentTask.ToAIContents()); + } + + [Fact] + public void ToChatMessages_WithEmptyArtifactsAndNoUserInputRequests_ReturnsNull() + { + // Arrange + var agentTask = new AgentTask + { + Id = "task1", + Artifacts = [], + Status = new AgentTaskStatus { State = TaskState.Completed }, + }; + + // Act + IList? result = agentTask.ToChatMessages(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ToChatMessages_WithNullArtifactsAndNoUserInputRequests_ReturnsNull() + { + // Arrange + var agentTask = new AgentTask + { + Id = "task1", + Artifacts = null, + Status = new AgentTaskStatus { State = TaskState.Completed }, + }; + + // Act + IList? result = agentTask.ToChatMessages(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ToAIContents_WithEmptyArtifactsAndNoUserInputRequests_ReturnsNull() + { + // Arrange + var agentTask = new AgentTask + { + Id = "task1", + Artifacts = [], + Status = new AgentTaskStatus { State = TaskState.Completed }, + }; + + // Act + IList? result = agentTask.ToAIContents(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ToAIContents_WithNullArtifactsAndNoUserInputRequests_ReturnsNull() + { + // Arrange + var agentTask = new AgentTask + { + Id = "task1", + Artifacts = null, + Status = new AgentTaskStatus { State = TaskState.Completed }, + }; + + // Act + IList? result = agentTask.ToAIContents(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void ToChatMessages_WithValidArtifact_ReturnsChatMessages() + { + // Arrange + var artifact = new Artifact + { + Parts = [new TextPart { Text = "response" }], + }; + + var agentTask = new AgentTask + { + Id = "task1", + Artifacts = [artifact], + Status = new AgentTaskStatus { State = TaskState.Completed }, + }; + + // Act + IList? result = agentTask.ToChatMessages(); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.All(result, msg => Assert.Equal(ChatRole.Assistant, msg.Role)); + Assert.Equal("response", result[0].Contents[0].ToString()); + } + + [Fact] + public void ToAIContents_WithMultipleArtifacts_FlattenAllContents() + { + // Arrange + var artifact1 = new Artifact + { + Parts = [new TextPart { Text = "content1" }], + }; + + var artifact2 = new Artifact + { + Parts = + [ + new TextPart { Text = "content2" }, + new TextPart { Text = "content3" } + ], + }; + + var agentTask = new AgentTask + { + Id = "task1", + Artifacts = [artifact1, artifact2], + Status = new AgentTaskStatus { State = TaskState.Completed }, + }; + + // Act + IList? result = agentTask.ToAIContents(); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Equal(3, result.Count); + Assert.Equal("content1", result[0].ToString()); + Assert.Equal("content2", result[1].ToString()); + Assert.Equal("content3", result[2].ToString()); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs new file mode 100644 index 0000000000..659c678034 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using A2A; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class A2AArtifactExtensionsTests +{ + [Fact] + public void ToChatMessage_WithMultiplePartsMetadataAndRawRepresentation_ReturnsCorrectChatMessage() + { + // Arrange + var artifact = new Artifact + { + ArtifactId = "artifact-comprehensive", + Name = "comprehensive-artifact", + Parts = + [ + new TextPart { Text = "First part" }, + new TextPart { Text = "Second part" }, + new TextPart { Text = "Third part" } + ], + Metadata = new Dictionary + { + { "key1", JsonSerializer.SerializeToElement("value1") }, + { "key2", JsonSerializer.SerializeToElement(42) } + } + }; + + // Act + var result = artifact.ToChatMessage(); + + // Assert - Verify multiple parts + Assert.NotNull(result); + Assert.Equal(ChatRole.Assistant, result.Role); + Assert.Equal(3, result.Contents.Count); + Assert.All(result.Contents, content => Assert.IsType(content)); + Assert.Equal("First part", ((TextContent)result.Contents[0]).Text); + Assert.Equal("Second part", ((TextContent)result.Contents[1]).Text); + Assert.Equal("Third part", ((TextContent)result.Contents[2]).Text); + + // Assert - Verify metadata conversion to AdditionalProperties + Assert.NotNull(result.AdditionalProperties); + Assert.Equal(2, result.AdditionalProperties.Count); + Assert.True(result.AdditionalProperties.ContainsKey("key1")); + Assert.True(result.AdditionalProperties.ContainsKey("key2")); + + // Assert - Verify RawRepresentation is set to artifact + Assert.NotNull(result.RawRepresentation); + Assert.Same(artifact, result.RawRepresentation); + } + + [Fact] + public void ToAIContents_WithMultipleParts_ReturnsCorrectList() + { + // Arrange + var artifact = new Artifact + { + ArtifactId = "artifact-ai-multi", + Name = "test", + Parts = new List + { + new TextPart { Text = "Part 1" }, + new TextPart { Text = "Part 2" }, + new TextPart { Text = "Part 3" } + }, + Metadata = null + }; + + // Act + var result = artifact.ToAIContents(); + + // Assert + Assert.NotNull(result); + Assert.Equal(3, result.Count); + Assert.All(result, content => Assert.IsType(content)); + Assert.Equal("Part 1", ((TextContent)result[0]).Text); + Assert.Equal("Part 2", ((TextContent)result[1]).Text); + Assert.Equal("Part 3", ((TextContent)result[2]).Text); + } + + [Fact] + public void ToAIContents_WithEmptyParts_ReturnsEmptyList() + { + // Arrange + var artifact = new Artifact + { + ArtifactId = "artifact-empty", + Name = "test", + Parts = new List(), + Metadata = null + }; + + // Act + var result = artifact.ToAIContents(); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } +} 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 f654f3eeec..a7593ac0cf 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 @@ -2,11 +2,12 @@ $(ProjectsTargetFrameworks) + $(NoWarn);MEAI001 - - + + From 5e11c467f267391badd211485c319989577f5dda Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Fri, 21 Nov 2025 16:53:55 +0000 Subject: [PATCH 02/10] fix line endings --- .../Program.cs | 36 ++++++++++++++++++- .../README.md | 26 +++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/Program.cs b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/Program.cs index 5e7b3619fc..f16517a107 100644 --- a/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/Program.cs +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/Program.cs @@ -1 +1,35 @@ -// Copyright (c) Microsoft. All rights reserved. // This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A AI agent. using A2A; using Microsoft.Agents.AI; var a2aAgentHost = Environment.GetEnvironmentVariable("A2A_AGENT_HOST") ?? throw new InvalidOperationException("A2A_AGENT_HOST is not set."); // Initialize an A2ACardResolver to get an A2A agent card. A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost)); // Get the agent card AgentCard agentCard = await agentCardResolver.GetAgentCardAsync(); // Create an instance of the AIAgent for an existing A2A agent specified by the agent card. AIAgent agent = agentCard.GetAIAgent(); AgentThread thread = agent.GetNewThread(); // Start the initial run with a long-running task. AgentRunResponse 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.", thread); // Poll until the response is complete. while (response.ContinuationToken is { } token) { // Wait before polling again. await Task.Delay(TimeSpan.FromSeconds(2)); // Continue with the token. response = await agent.RunAsync(thread, options: new AgentRunOptions { ContinuationToken = token }); } // Display the result Console.WriteLine(response.Text); \ No newline at end of file +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A AI agent. + +using A2A; +using Microsoft.Agents.AI; + +var a2aAgentHost = Environment.GetEnvironmentVariable("A2A_AGENT_HOST") ?? throw new InvalidOperationException("A2A_AGENT_HOST is not set."); + +// Initialize an A2ACardResolver to get an A2A agent card. +A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost)); + +// Get the agent card +AgentCard agentCard = await agentCardResolver.GetAgentCardAsync(); + +// Create an instance of the AIAgent for an existing A2A agent specified by the agent card. +AIAgent agent = agentCard.GetAIAgent(); + +AgentThread thread = agent.GetNewThread(); + +// Start the initial run with a long-running task. +AgentRunResponse 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.", thread); + +// Poll until the response is complete. +while (response.ContinuationToken is { } token) +{ + // Wait before polling again. + await Task.Delay(TimeSpan.FromSeconds(2)); + + // Continue with the token. + response = await agent.RunAsync(thread, options: new AgentRunOptions { ContinuationToken = token }); +} + +// Display the result +Console.WriteLine(response.Text); diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md index 1d78314be0..ea9adc264f 100644 --- a/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md @@ -1 +1,25 @@ -# What This Sample Shows This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A AI agent. The sample: - Connects to an A2A agent server specified in the `A2A_AGENT_HOST` environment variable - Sends a request to the agent that may take time to complete - Polls the agent at regular intervals using continuation tokens until a final response is received - Displays the final result This pattern is useful when an AI model cannot complete a complex task in a single response and needs multiple rounds of processing. # Prerequisites Before you begin, ensure you have the following prerequisites: - .NET 9.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 ``` \ No newline at end of file +# What This Sample Shows + +This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A AI agent. + +The sample: + +- Connects to an A2A agent server specified in the `A2A_AGENT_HOST` environment variable +- Sends a request to the agent that may take time to complete +- Polls the agent at regular intervals using continuation tokens until a final response is received +- Displays the final result + +This pattern is useful when an AI model cannot complete a complex task in a single response and needs multiple rounds of processing. + +# Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 9.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 +``` From 2ca601cf16f4a3780d6baabdfe18a6ac4a8f021d Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Fri, 21 Nov 2025 18:44:35 +0000 Subject: [PATCH 03/10] address pr review comments --- dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs | 12 ++++-------- .../Microsoft.Agents.AI.A2A.csproj | 1 + .../A2AAgentTests.cs | 6 +++++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs index 2e9209e498..31fede39c7 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs @@ -240,21 +240,21 @@ private static AgentMessage CreateA2AMessage(A2AAgentThread typedThread, IEnumer // Link the message as a follow-up to an existing task, if any. // See: https://github.com/a2aproject/A2A/blob/main/docs/topics/life-of-a-task.md#task-refinements - a2aMessage.ReferenceTaskIds = [typedThread.TaskId]; + a2aMessage.ReferenceTaskIds = typedThread.TaskId is null ? null : [typedThread.TaskId]; return a2aMessage; } private static A2AContinuationToken? GetContinuationToken(IEnumerable messages, AgentRunOptions? options = null) { - if (options?.ContinuationToken is { } token) + if (options?.ContinuationToken is ResponseContinuationToken token) { if (messages.Any()) { throw new InvalidOperationException("Messages are not allowed when continuing a background response using a continuation token."); } - return A2AContinuationToken.FromToken((A2AContinuationToken)token); + return A2AContinuationToken.FromToken(token); } return null; @@ -308,11 +308,7 @@ private AgentRunResponseUpdate ConvertToAgentResponseUpdate(TaskUpdateEvent task AdditionalProperties = taskUpdateEvent.Metadata.ToAdditionalProperties() ?? [], }; - if (taskUpdateEvent is TaskStatusUpdateEvent statusUpdateEvent) - { - responseUpdate.RawRepresentation = statusUpdateEvent; - } - else if (taskUpdateEvent is TaskArtifactUpdateEvent artifactUpdateEvent) + if (taskUpdateEvent is TaskArtifactUpdateEvent artifactUpdateEvent) { responseUpdate.Contents = artifactUpdateEvent.Artifact.ToAIContents(); responseUpdate.RawRepresentation = artifactUpdateEvent; 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 b16bbb89e3..cc82524997 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 @@ -4,6 +4,7 @@ $(ProjectsTargetFrameworks) $(ProjectsDebugTargetFrameworks) preview + $(NoWarn);MEAI001 diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs index b35666fe28..05c7f5ba08 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs @@ -367,6 +367,7 @@ public async Task RunStreamingAsync_AllowsNonUserRoleMessagesAsync() // Act & Assert await foreach (var _ in this._agent.RunStreamingAsync(inputMessages)) { + // Just iterate through to trigger the logic } } @@ -424,7 +425,7 @@ public async Task RunAsync_WithContinuationToken_CallsGetTaskAsyncAsync() var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken("task-123") }; // Act - await this._agent.RunAsync(null, options); + await this._agent.RunAsync([], options: options); // Assert Assert.Equal("tasks/get", this._handler.CapturedJsonRpcRequest?.Method); @@ -572,6 +573,7 @@ await Assert.ThrowsAsync(async () => { await foreach (var _ in this._agent.RunStreamingAsync(inputMessages, null, options)) { + // Just iterate through to trigger the exception } }); } @@ -593,6 +595,7 @@ public async Task RunStreamingAsync_WithTaskInThreadAndMessage_AddTaskAsReferenc // Act await foreach (var _ in this._agent.RunStreamingAsync("Please make the background transparent", thread)) { + // Just iterate through to trigger the logic } // Assert @@ -618,6 +621,7 @@ public async Task RunStreamingAsync_WithAgentTask_UpdatesThreadTaskIdAsync() // Act await foreach (var _ in this._agent.RunStreamingAsync("Start a task", thread)) { + // Just iterate through to trigger the logic } // Assert From d745349815354248edb0626a5825f54171acb62b Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Fri, 21 Nov 2025 18:52:25 +0000 Subject: [PATCH 04/10] address pr review comments --- dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs index 31fede39c7..51b8d1e36a 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs @@ -187,7 +187,7 @@ public override async IAsyncEnumerable RunStreamingAsync contextId = taskUpdateEvent.ContextId; taskId = taskUpdateEvent.TaskId; - yield return this.ConvertToAgentResponseUpdate(taskUpdateEvent, options); + yield return this.ConvertToAgentResponseUpdate(taskUpdateEvent); } else { @@ -297,7 +297,7 @@ private AgentRunResponseUpdate ConvertToAgentResponseUpdate(AgentTask task) }; } - private AgentRunResponseUpdate ConvertToAgentResponseUpdate(TaskUpdateEvent taskUpdateEvent, AgentRunOptions? options) + private AgentRunResponseUpdate ConvertToAgentResponseUpdate(TaskUpdateEvent taskUpdateEvent) { AgentRunResponseUpdate responseUpdate = new() { From a55b0063e0610b03a6c263c03238abd0f3dcc7ef Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 24 Nov 2025 12:29:57 +0000 Subject: [PATCH 05/10] update sample to net10.0 --- .../A2AAgent_PollingForTaskCompletion.csproj | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj index 3f82ba012c..1f36cef576 100644 --- a/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj @@ -2,8 +2,7 @@ Exe - net9.0 - A2AAgent_PollingForTaskCompletion + net10.0 enable enable From f4cff99a3849271e556f47c87f2351d5c6a782d7 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:10:38 +0000 Subject: [PATCH 06/10] Update dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> --- .../A2A/A2AAgent_PollingForTaskCompletion/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md index ea9adc264f..f9f11cdec5 100644 --- a/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md @@ -15,7 +15,7 @@ This pattern is useful when an AI model cannot complete a complex task in a sing Before you begin, ensure you have the following prerequisites: -- .NET 9.0 SDK or later +- .NET 10.0 SDK or later - An A2A agent server running and accessible via HTTP Set the following environment variable: From 53659912c38a0d602056465c20a138d17ffaaa4d Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:11:56 +0000 Subject: [PATCH 07/10] Update dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentTaskExtensions.cs Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> --- .../Extensions/A2AAgentTaskExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentTaskExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentTaskExtensions.cs index 4d259785ba..a577ad9364 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentTaskExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentTaskExtensions.cs @@ -17,7 +17,7 @@ internal static class A2AAgentTaskExtensions List? messages = null; - if (agentTask.Artifacts is not null) + if (agentTask?.Artifacts is { Count: > 0 }) { foreach (var artifact in agentTask.Artifacts) { From 8377a3b1e81becc267b67f3ca946d7fe99b9956a Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:16:20 +0000 Subject: [PATCH 08/10] Update dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/Program.cs Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> --- .../A2A/A2AAgent_PollingForTaskCompletion/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/Program.cs b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/Program.cs index f16517a107..7b5934575c 100644 --- a/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/Program.cs +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/Program.cs @@ -32,4 +32,4 @@ } // Display the result -Console.WriteLine(response.Text); +Console.WriteLine(response); From 25474736104ec6c9a01cd8724c4bbe10accd1f24 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 24 Nov 2025 17:30:24 +0000 Subject: [PATCH 09/10] address pr review feedback --- .../README.md | 2 +- .../src/Microsoft.Agents.AI.A2A/A2AAgent.cs | 19 +++++++++++++------ .../A2AContinuationToken.cs | 2 +- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md index ea9adc264f..a9b99003f8 100644 --- a/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md @@ -1,4 +1,4 @@ -# What This Sample Shows +# Polling for A2A Agent Task Completion This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A AI agent. diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs index 51b8d1e36a..e4491970ad 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs @@ -108,7 +108,7 @@ public override async Task RunAsync(IEnumerable m ResponseId = message.MessageId, RawRepresentation = message, Messages = [message.ToChatMessage()], - AdditionalProperties = message.Metadata.ToAdditionalProperties(), + AdditionalProperties = message.Metadata?.ToAdditionalProperties(), }; } @@ -116,15 +116,22 @@ public override async Task RunAsync(IEnumerable m { UpdateThread(typedThread, agentTask.ContextId, agentTask.Id); - return new AgentRunResponse + var response = new AgentRunResponse { AgentId = this.Id, ResponseId = agentTask.Id, RawRepresentation = agentTask, Messages = agentTask.ToChatMessages() ?? [], ContinuationToken = CreateContinuationToken(agentTask.Id, agentTask.Status.State), - AdditionalProperties = agentTask.Metadata.ToAdditionalProperties(), + AdditionalProperties = agentTask.Metadata?.ToAdditionalProperties(), }; + + if (agentTask.ToChatMessages() is { Count: > 0 } taskMessages) + { + response.Messages = taskMessages; + } + + return response; } throw new NotSupportedException($"Only Message and AgentTask responses are supported from A2A agents. Received: {a2aResponse.GetType().FullName ?? "null"}"); @@ -280,7 +287,7 @@ private AgentRunResponseUpdate ConvertToAgentResponseUpdate(AgentMessage message Role = ChatRole.Assistant, MessageId = message.MessageId, Contents = message.Parts.ConvertAll(part => part.ToAIContent()), - AdditionalProperties = message.Metadata.ToAdditionalProperties(), + AdditionalProperties = message.Metadata?.ToAdditionalProperties(), }; } @@ -293,7 +300,7 @@ private AgentRunResponseUpdate ConvertToAgentResponseUpdate(AgentTask task) RawRepresentation = task, Role = ChatRole.Assistant, Contents = task.ToAIContents(), - AdditionalProperties = task.Metadata.ToAdditionalProperties(), + AdditionalProperties = task.Metadata?.ToAdditionalProperties(), }; } @@ -305,7 +312,7 @@ private AgentRunResponseUpdate ConvertToAgentResponseUpdate(TaskUpdateEvent task ResponseId = taskUpdateEvent.TaskId, RawRepresentation = taskUpdateEvent, Role = ChatRole.Assistant, - AdditionalProperties = taskUpdateEvent.Metadata.ToAdditionalProperties() ?? [], + AdditionalProperties = taskUpdateEvent.Metadata?.ToAdditionalProperties() ?? [], }; if (taskUpdateEvent is TaskArtifactUpdateEvent artifactUpdateEvent) diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs index aa1be63ee4..5233adb88f 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs @@ -46,7 +46,7 @@ internal static A2AContinuationToken FromToken(ResponseContinuationToken token) break; } - string propertyName = reader.GetString()!; + string propertyName = reader.GetString() ?? throw new JsonException("Failed to read property name from continuation token."); switch (propertyName) { From d477996c08f440e5f4ebc3b24a3f19912f882ff7 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Mon, 24 Nov 2025 17:48:33 +0000 Subject: [PATCH 10/10] add clarification regarding background responses --- .../A2A/A2AAgent_PollingForTaskCompletion/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md index d9c37840a9..3e1160b510 100644 --- a/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md @@ -1,6 +1,6 @@ # Polling for A2A Agent Task Completion -This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A AI agent. +This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A AI agent, following the background responses pattern. The sample: