diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 9c5f1a81f3..7f4a6eb4d1 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -40,6 +40,7 @@
+
@@ -389,4 +390,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..1f36cef576
--- /dev/null
+++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj
@@ -0,0 +1,25 @@
+
+
+
+ Exe
+ net10.0
+
+ 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..7b5934575c
--- /dev/null
+++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/Program.cs
@@ -0,0 +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);
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..3e1160b510
--- /dev/null
+++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md
@@ -0,0 +1,25 @@
+# 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, following the background responses pattern.
+
+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 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/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..e4491970ad 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
{
@@ -101,21 +108,30 @@ public override async Task RunAsync(IEnumerable m
ResponseId = message.MessageId,
RawRepresentation = message,
Messages = [message.ToChatMessage()],
- AdditionalProperties = message.Metadata.ToAdditionalProperties(),
+ AdditionalProperties = message.Metadata?.ToAdditionalProperties(),
};
}
+
if (a2aResponse is AgentTask agentTask)
{
- UpdateThreadConversationId(typedThread, agentTask.ContextId);
+ UpdateThread(typedThread, agentTask.ContextId, agentTask.Id);
- return new AgentRunResponse
+ var response = new AgentRunResponse
{
AgentId = this.Id,
ResponseId = agentTask.Id,
RawRepresentation = agentTask,
- Messages = agentTask.ToChatMessages(),
- AdditionalProperties = agentTask.Metadata.ToAdditionalProperties(),
+ Messages = agentTask.ToChatMessages() ?? [],
+ ContinuationToken = CreateContinuationToken(agentTask.Id, agentTask.Status.State),
+ 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"}");
@@ -126,43 +142,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);
+ }
+ 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 +217,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 +234,93 @@ 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 is null ? null : [typedThread.TaskId];
+
+ return a2aMessage;
+ }
+
+ private static A2AContinuationToken? GetContinuationToken(IEnumerable messages, AgentRunOptions? options = null)
+ {
+ 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(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)
+ {
+ AgentRunResponseUpdate responseUpdate = new()
+ {
+ AgentId = this.Id,
+ ResponseId = taskUpdateEvent.TaskId,
+ RawRepresentation = taskUpdateEvent,
+ Role = ChatRole.Assistant,
+ AdditionalProperties = taskUpdateEvent.Metadata?.ToAdditionalProperties() ?? [],
+ };
+
+ 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..5233adb88f
--- /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() ?? throw new JsonException("Failed to read property name from continuation token.");
+
+ 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..a577ad9364 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)
+ if (agentTask?.Artifacts is { Count: > 0 })
{
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 3346066bd6..d36e551d42 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
@@ -2,12 +2,14 @@
preview
+ $(NoWarn);MEAI001
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..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
}
}
@@ -396,15 +397,422 @@ 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([], options: 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))
+ {
+ // Just iterate through to trigger the exception
+ }
+ });
+ }
+
+ [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))
+ {
+ // Just iterate through to trigger the logic
+ }
+
+ // 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))
+ {
+ // Just iterate through to trigger the logic
+ }
+
+ // 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 +824,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);
+ }
+}