mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Add support for background responses to A2A agent (#2381)
* add support for baackground responses to a2a agent * fix line endings * address pr review comments * address pr review comments * update sample to net10.0 * Update dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/README.md Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> * Update dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentTaskExtensions.cs Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> * Update dotnet/samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/Program.cs Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> * address pr review feedback * add clarification regarding background responses --------- Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
bcbf1b33e8
commit
a610a4769c
@@ -40,6 +40,7 @@
|
||||
<Folder Name="/Samples/GettingStarted/A2A/">
|
||||
<File Path="samples/GettingStarted/A2A/README.md" />
|
||||
<Project Path="samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj" />
|
||||
<Project Path="samples/GettingStarted/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/AgentProviders/">
|
||||
<File Path="samples/GettingStarted/AgentProviders/README.md" />
|
||||
@@ -389,4 +390,4 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
</Solution>
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="A2A" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="System.Net.ServerSentEvents" />
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.A2A\Microsoft.Agents.AI.A2A.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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);
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 @@ internal sealed class A2AAgent : AIAgent
|
||||
{
|
||||
_ = 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 @@ internal sealed class A2AAgent : AIAgent
|
||||
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 @@ internal sealed class A2AAgent : AIAgent
|
||||
{
|
||||
_ = 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<SseItem<A2AEvent>> 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);
|
||||
}
|
||||
|
||||
UpdateThreadConversationId(typedThread, message.ContextId);
|
||||
|
||||
yield return new AgentRunResponseUpdate
|
||||
else if (sseEvent.Data is AgentTask task)
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ResponseId = message.MessageId,
|
||||
RawRepresentation = message,
|
||||
Role = ChatRole.Assistant,
|
||||
MessageId = message.MessageId,
|
||||
Contents = [.. message.Parts.Select(part => part.ToAIContent()).OfType<AIContent>()],
|
||||
AdditionalProperties = message.Metadata.ToAdditionalProperties(),
|
||||
};
|
||||
contextId = task.ContextId;
|
||||
taskId = task.Id;
|
||||
|
||||
yield return this.ConvertToAgentResponseUpdate(task);
|
||||
}
|
||||
else if (sseEvent.Data is TaskUpdateEvent taskUpdateEvent)
|
||||
{
|
||||
contextId = taskUpdateEvent.ContextId;
|
||||
taskId = taskUpdateEvent.TaskId;
|
||||
|
||||
yield return this.ConvertToAgentResponseUpdate(taskUpdateEvent);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotSupportedException($"Only message, task, task update events are supported from A2A agents. Received: {sseEvent.Data.GetType().FullName ?? "null"}");
|
||||
}
|
||||
}
|
||||
|
||||
UpdateThread(typedThread, contextId, taskId);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -177,7 +217,7 @@ internal sealed class A2AAgent : AIAgent
|
||||
/// <inheritdoc/>
|
||||
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 @@ internal sealed class A2AAgent : AIAgent
|
||||
|
||||
// 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<ChatMessage> 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<ChatMessage> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
/// <summary>
|
||||
/// Thread for A2A based agents.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ID for the current conversation with the A2A agent.
|
||||
/// </summary>
|
||||
public string? ContextId
|
||||
public string? ContextId { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ID for the task the agent is currently working on.
|
||||
/// </summary>
|
||||
public string? TaskId { get; internal set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
get { return this.ServiceThreadId; }
|
||||
internal set { this.ServiceThreadId = value; }
|
||||
var state = new A2AAgentThreadState
|
||||
{
|
||||
ContextId = this.ContextId,
|
||||
TaskId = this.TaskId
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToElement(state, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(A2AAgentThreadState)));
|
||||
}
|
||||
|
||||
internal sealed class A2AAgentThreadState
|
||||
{
|
||||
public string? ContextId { get; set; }
|
||||
|
||||
public string? TaskId { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<byte> 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<byte> 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Provides utility methods and configurations for JSON serialization operations for A2A agent types.
|
||||
/// </summary>
|
||||
public static partial class A2AJsonUtilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the default <see cref="JsonSerializerOptions"/> instance used for JSON serialization operations of A2A agent types.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// For Native AOT or applications disabling <see cref="JsonSerializer.IsReflectionEnabledByDefault"/>, this instance
|
||||
/// includes source generated contracts for A2A agent types.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It additionally turns on the following settings:
|
||||
/// <list type="number">
|
||||
/// <item><description>Enables <see cref="JsonSerializerDefaults.Web"/> defaults.</description></item>
|
||||
/// <item><description>Enables <see cref="JsonIgnoreCondition.WhenWritingNull"/> as the default ignore condition for properties.</description></item>
|
||||
/// <item><description>Enables <see cref="JsonNumberHandling.AllowReadingFromString"/> as the default number handling for number types.</description></item>
|
||||
/// <item><description>
|
||||
/// Enables <see cref="JavaScriptEncoder.UnsafeRelaxedJsonEscaping"/> 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.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static JsonSerializerOptions DefaultOptions { get; } = CreateDefaultOptions();
|
||||
|
||||
/// <summary>
|
||||
/// Creates and configures the default JSON serialization options for agent abstraction types.
|
||||
/// </summary>
|
||||
/// <returns>The configured options.</returns>
|
||||
[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;
|
||||
}
|
||||
@@ -11,20 +11,37 @@ namespace A2A;
|
||||
/// </summary>
|
||||
internal static class A2AAgentTaskExtensions
|
||||
{
|
||||
internal static IList<ChatMessage> ToChatMessages(this AgentTask agentTask)
|
||||
internal static IList<ChatMessage>? ToChatMessages(this AgentTask agentTask)
|
||||
{
|
||||
_ = Throw.IfNull(agentTask);
|
||||
|
||||
List<ChatMessage> messages = [];
|
||||
List<ChatMessage>? 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<AIContent>? ToAIContents(this AgentTask agentTask)
|
||||
{
|
||||
_ = Throw.IfNull(agentTask);
|
||||
|
||||
List<AIContent>? aiContents = null;
|
||||
|
||||
if (agentTask.Artifacts is not null)
|
||||
{
|
||||
foreach (var artifact in agentTask.Artifacts)
|
||||
{
|
||||
(aiContents ??= []).AddRange(artifact.ToAIContents());
|
||||
}
|
||||
}
|
||||
|
||||
return aiContents;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,21 +12,15 @@ internal static class A2AArtifactExtensions
|
||||
{
|
||||
internal static ChatMessage ToChatMessage(this Artifact artifact)
|
||||
{
|
||||
List<AIContent>? 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<AIContent> ToAIContents(this Artifact artifact)
|
||||
{
|
||||
return artifact.Parts.ConvertAll(part => part.ToAIContent());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<NoWarn>$(NoWarn);MEAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -367,6 +367,7 @@ public sealed class A2AAgentTests : IDisposable
|
||||
// Act & Assert
|
||||
await foreach (var _ in this._agent.RunStreamingAsync(inputMessages))
|
||||
{
|
||||
// Just iterate through to trigger the logic
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,15 +397,422 @@ public sealed class A2AAgentTests : IDisposable
|
||||
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<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
|
||||
var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken("task-123") };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => 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<string, JsonElement>
|
||||
{
|
||||
{ "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<AgentTask>(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<A2AContinuationToken>(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<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
|
||||
var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken("task-123") };
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(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<AgentRunResponseUpdate>();
|
||||
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<AgentMessage>(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<AgentRunResponseUpdate>();
|
||||
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<AgentTask>(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<AgentRunResponseUpdate>();
|
||||
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<TaskStatusUpdateEvent>(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<AgentRunResponseUpdate>();
|
||||
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<TaskArtifactUpdateEvent>(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 @@ public sealed class A2AAgentTests : IDisposable
|
||||
var content = await request.Content!.ReadAsStringAsync();
|
||||
#pragma warning restore CA2016
|
||||
|
||||
var jsonRpcRequest = JsonSerializer.Deserialize<JsonRpcRequest>(content)!;
|
||||
this.CapturedJsonRpcRequest = JsonSerializer.Deserialize<JsonRpcRequest>(content);
|
||||
|
||||
this.CapturedMessageSendParams = jsonRpcRequest.Params?.Deserialize<MessageSendParams>();
|
||||
try
|
||||
{
|
||||
this.CapturedMessageSendParams = this.CapturedJsonRpcRequest?.Params?.Deserialize<MessageSendParams>();
|
||||
}
|
||||
catch { /* Ignore deserialization errors for non-MessageSendParams requests */ }
|
||||
|
||||
try
|
||||
{
|
||||
this.CapturedTaskIdParams = this.CapturedJsonRpcRequest?.Params?.Deserialize<TaskIdParams>();
|
||||
}
|
||||
catch { /* Ignore deserialization errors for non-TaskIdParams requests */ }
|
||||
|
||||
// Return the pre-configured non-streaming response
|
||||
if (this.ResponseToReturn is not null)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Microsoft.Agents.AI.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="A2AAgentThread"/> class.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="A2AContinuationToken"/> class.
|
||||
/// </summary>
|
||||
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<A2AContinuationToken>(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<byte>.Empty);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => 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<JsonException>(() => 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock implementation of ResponseContinuationToken for testing.
|
||||
/// </summary>
|
||||
private sealed class MockResponseContinuationToken : ResponseContinuationToken
|
||||
{
|
||||
private readonly ReadOnlyMemory<byte> _data;
|
||||
|
||||
public MockResponseContinuationToken(ReadOnlyMemory<byte> data)
|
||||
{
|
||||
this._data = data;
|
||||
}
|
||||
|
||||
public override ReadOnlyMemory<byte> ToBytes()
|
||||
{
|
||||
return this._data;
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="A2AAgentTaskExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class A2AAgentTaskExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToChatMessages_WithNullAgentTask_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AgentTask agentTask = null!;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => agentTask.ToChatMessages());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAIContents_WithNullAgentTask_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
AgentTask agentTask = null!;
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => agentTask.ToAIContents());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToChatMessages_WithEmptyArtifactsAndNoUserInputRequests_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var agentTask = new AgentTask
|
||||
{
|
||||
Id = "task1",
|
||||
Artifacts = [],
|
||||
Status = new AgentTaskStatus { State = TaskState.Completed },
|
||||
};
|
||||
|
||||
// Act
|
||||
IList<ChatMessage>? 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<ChatMessage>? 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<AIContent>? 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<AIContent>? 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<ChatMessage>? 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<AIContent>? 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());
|
||||
}
|
||||
}
|
||||
+107
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="A2AArtifactExtensions"/> class.
|
||||
/// </summary>
|
||||
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<string, JsonElement>
|
||||
{
|
||||
{ "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<TextContent>(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<Part>
|
||||
{
|
||||
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<TextContent>(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<Part>(),
|
||||
Metadata = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = artifact.ToAIContents();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user