.NET: A2A agent (#520)

* add a2a agent

* Update dotnet/src/Microsoft.Extensions.AI.Agents.A2A/A2AAgent.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* address pr review feedback

* move unit tests for extension methods to the extensions folder

* address pr review comments

* address pr review comments

* address pr review feedback

* move a2a agent sample to console app

* remove unnecessary Ids set for new projects in the solution file

* remove unnecessary configuration

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
SergeyMenshykh
2025-09-01 08:30:30 +00:00
committed by GitHub
co-authored by Copilot
parent 97d72c967f
commit 1d2f833122
30 changed files with 1656 additions and 1 deletions
@@ -11,6 +11,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="System.Linq.Async" />
</ItemGroup>
<ItemGroup>
-1
View File
@@ -18,7 +18,6 @@
<PackageReference Include="xRetry" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="System.Linq.Async" />
</ItemGroup>
<ItemGroup>
@@ -12,6 +12,7 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="System.Linq.Async" />
</ItemGroup>
</Project>
@@ -12,6 +12,7 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="System.Linq.Async" />
</ItemGroup>
</Project>
@@ -0,0 +1,459 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.ServerSentEvents;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using A2A;
namespace Microsoft.Extensions.AI.Agents.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="A2AAgent"/> class.
/// </summary>
public sealed class A2AAgentTests : IDisposable
{
private readonly HttpClient _httpClient;
private readonly A2AClientHttpMessageHandlerStub _handler;
private readonly A2AClient _a2aClient;
private readonly A2AAgent _agent;
public A2AAgentTests()
{
this._handler = new A2AClientHttpMessageHandlerStub();
this._httpClient = new HttpClient(this._handler, false);
this._a2aClient = new A2AClient(new Uri("http://test-endpoint"), this._httpClient);
this._agent = new A2AAgent(this._a2aClient);
}
[Fact]
public void Constructor_WithAllParameters_InitializesPropertiesCorrectly()
{
// Arrange
const string TestId = "test-id";
const string TestName = "test-name";
const string TestDescription = "test-description";
const string TestDisplayName = "test-display-name";
// Act
var agent = new A2AAgent(this._a2aClient, TestId, TestName, TestDescription, TestDisplayName);
// Assert
Assert.Equal(TestId, agent.Id);
Assert.Equal(TestName, agent.Name);
Assert.Equal(TestDescription, agent.Description);
Assert.Equal(TestDisplayName, agent.DisplayName);
}
[Fact]
public void Constructor_WithNullA2AClient_ThrowsArgumentNullException()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new A2AAgent(null!));
}
[Fact]
public void Constructor_WithDefaultParameters_UsesBaseProperties()
{
// Act
var agent = new A2AAgent(this._a2aClient);
// Assert
Assert.NotNull(agent.Id);
Assert.NotEmpty(agent.Id);
Assert.Null(agent.Name);
Assert.Null(agent.Description);
Assert.Equal(agent.Id, agent.DisplayName);
}
[Fact]
public async Task RunAsync_NonUserRoleMessages_ThrowsArgumentExceptionAsync()
{
// Arrange
var inputMessages = new List<ChatMessage>
{
new(ChatRole.Assistant, "I am an assistant message"),
new(ChatRole.User, "Valid user message")
};
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => this._agent.RunAsync(inputMessages));
}
[Fact]
public async Task RunAsync_WithValidUserMessage_RunsSuccessfullyAsync()
{
// Arrange
this._handler.ResponseToReturn = new Message
{
MessageId = "response-123",
Role = MessageRole.Agent,
Parts = new List<Part>
{
new TextPart { Text = "Hello! How can I help you today?" }
}
};
var inputMessages = new List<ChatMessage>
{
new(ChatRole.User, "Hello, world!")
};
// Act
var result = await this._agent.RunAsync(inputMessages);
// Assert input message sent to A2AClient
var inputMessage = this._handler.CapturedMessageSendParams?.Message;
Assert.NotNull(inputMessage);
Assert.Single(inputMessage.Parts);
Assert.Equal(MessageRole.User, inputMessage.Role);
Assert.Equal("Hello, world!", ((TextPart)inputMessage.Parts[0]).Text);
// Assert response from A2AClient is converted correctly
Assert.NotNull(result);
Assert.Equal(this._agent.Id, result.AgentId);
Assert.Equal("response-123", result.ResponseId);
Assert.NotNull(result.RawRepresentation);
Assert.IsType<Message>(result.RawRepresentation);
Assert.Equal("response-123", ((Message)result.RawRepresentation).MessageId);
Assert.Single(result.Messages);
Assert.Equal(ChatRole.Assistant, result.Messages[0].Role);
Assert.Equal("Hello! How can I help you today?", result.Messages[0].Text);
}
[Fact]
public async Task RunAsync_WithNewThread_UpdatesThreadConversationIdAsync()
{
// Arrange
this._handler.ResponseToReturn = new Message
{
MessageId = "response-123",
Role = MessageRole.Agent,
Parts = new List<Part>
{
new TextPart { Text = "Response" }
},
ContextId = "new-context-id"
};
var inputMessages = new List<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var thread = this._agent.GetNewThread();
// Act
await this._agent.RunAsync(inputMessages, thread);
// Assert
Assert.Equal("new-context-id", thread.ConversationId);
}
[Fact]
public async Task RunAsync_WithExistingThread_SetConversationIdToMessageAsync()
{
// Arrange
var inputMessages = new List<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var thread = this._agent.GetNewThread();
thread.ConversationId = "existing-context-id";
// Act
await this._agent.RunAsync(inputMessages, thread);
// Assert
var message = this._handler.CapturedMessageSendParams?.Message;
Assert.NotNull(message);
Assert.Equal("existing-context-id", message.ContextId);
}
[Fact]
public async Task RunAsync_WithThreadHavingDifferentContextId_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
var inputMessages = new List<ChatMessage>
{
new(ChatRole.User, "Test message")
};
this._handler.ResponseToReturn = new Message
{
MessageId = "response-123",
Role = MessageRole.Agent,
Parts = new List<Part>
{
new TextPart { Text = "Response" }
},
ContextId = "different-context"
};
var thread = this._agent.GetNewThread();
thread.ConversationId = "existing-context-id";
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => this._agent.RunAsync(inputMessages, thread));
}
[Fact]
public async Task RunStreamingAsync_WithValidUserMessage_YieldsAgentRunResponseUpdatesAsync()
{
// Arrange
var inputMessages = new List<ChatMessage>
{
new(ChatRole.User, "Hello, streaming!")
};
this._handler.StreamingResponseToReturn = new Message()
{
MessageId = "stream-1",
Role = MessageRole.Agent,
Parts = new List<Part> { new TextPart { Text = "Hello" } },
ContextId = "stream-context"
};
// Act
var updates = new List<AgentRunResponseUpdate>();
await foreach (var update in this._agent.RunStreamingAsync(inputMessages))
{
updates.Add(update);
}
// Assert
Assert.Single(updates);
// Assert input message sent to A2AClient
var inputMessage = this._handler.CapturedMessageSendParams?.Message;
Assert.NotNull(inputMessage);
Assert.Single(inputMessage.Parts);
Assert.Equal(MessageRole.User, inputMessage.Role);
Assert.Equal("Hello, streaming!", ((TextPart)inputMessage.Parts[0]).Text);
// Assert response from A2AClient is converted correctly
Assert.Equal(ChatRole.Assistant, updates[0].Role);
Assert.Equal("Hello", updates[0].Text);
Assert.Equal("stream-1", updates[0].MessageId);
Assert.Equal(this._agent.Id, updates[0].AgentId);
Assert.Equal("stream-1", updates[0].ResponseId);
Assert.NotNull(updates[0].RawRepresentation);
Assert.IsType<Message>(updates[0].RawRepresentation);
Assert.Equal("stream-1", ((Message)updates[0].RawRepresentation!).MessageId);
}
[Fact]
public async Task RunStreamingAsync_WithThread_UpdatesThreadConversationIdAsync()
{
// Arrange
var inputMessages = new List<ChatMessage>
{
new(ChatRole.User, "Test streaming")
};
this._handler.StreamingResponseToReturn = new Message()
{
MessageId = "stream-1",
Role = MessageRole.Agent,
Parts = new List<Part> { new TextPart { Text = "Response" } },
ContextId = "new-stream-context"
};
var thread = this._agent.GetNewThread();
// Act
await foreach (var update in this._agent.RunStreamingAsync(inputMessages, thread))
{
// Just iterate through to trigger the logic
}
// Assert
Assert.Equal("new-stream-context", thread.ConversationId);
}
[Fact]
public async Task RunStreamingAsync_WithExistingThread_SetConversationIdToMessageAsync()
{
// Arrange
var inputMessages = new List<ChatMessage>
{
new(ChatRole.User, "Test streaming")
};
this._handler.StreamingResponseToReturn = new Message();
var thread = this._agent.GetNewThread();
thread.ConversationId = "existing-context-id";
// Act
await foreach (var update in this._agent.RunStreamingAsync(inputMessages, thread))
{
// Just iterate through to trigger the logic
}
// Assert
var message = this._handler.CapturedMessageSendParams?.Message;
Assert.NotNull(message);
Assert.Equal("existing-context-id", message.ContextId);
}
[Fact]
public async Task RunStreamingAsync_WithThreadHavingDifferentContextId_ThrowsInvalidOperationExceptionAsync()
{
// Arrange
var thread = this._agent.GetNewThread();
thread.ConversationId = "existing-context-id";
var inputMessages = new List<ChatMessage>
{
new(ChatRole.User, "Test streaming")
};
this._handler.StreamingResponseToReturn = new Message()
{
MessageId = "stream-1",
Role = MessageRole.Agent,
Parts = new List<Part> { new TextPart { Text = "Response" } },
ContextId = "different-context"
};
// Act
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var update in this._agent.RunStreamingAsync(inputMessages, thread))
{
}
});
}
[Fact]
public async Task RunStreamingAsync_NonUserRoleMessages_ThrowsArgumentExceptionAsync()
{
// Arrange
var inputMessages = new List<ChatMessage>
{
new(ChatRole.Assistant, "I am an assistant message")
};
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(async () =>
{
await foreach (var update in this._agent.RunStreamingAsync(inputMessages))
{
}
});
}
[Fact]
public async Task RunAsync_WithHostedFileContent_ConvertsToFilePartAsync()
{
// Arrange
var inputMessages = new List<ChatMessage>
{
new(ChatRole.User, new List<AIContent>
{
new TextContent("Check this file:"),
new HostedFileContent("https://example.com/file.pdf")
})
};
// Act
await this._agent.RunAsync(inputMessages);
// Assert
var message = this._handler.CapturedMessageSendParams?.Message;
Assert.NotNull(message);
Assert.Equal(2, message.Parts.Count);
Assert.IsType<TextPart>(message.Parts[0]);
Assert.Equal("Check this file:", ((TextPart)message.Parts[0]).Text);
Assert.IsType<FilePart>(message.Parts[1]);
Assert.Equal("https://example.com/file.pdf", ((FileWithUri)((FilePart)message.Parts[1]).File).Uri);
}
public void Dispose()
{
this._handler.Dispose();
this._httpClient.Dispose();
}
internal sealed class A2AClientHttpMessageHandlerStub : HttpMessageHandler
{
public MessageSendParams? CapturedMessageSendParams { get; set; }
public A2AEvent? ResponseToReturn { get; set; }
public A2AEvent? StreamingResponseToReturn { get; set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
// Capture the request content
#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods; overload doesn't exist on .NET …
var content = await request.Content!.ReadAsStringAsync();
#pragma warning restore CA2016
var jsonRpcRequest = JsonSerializer.Deserialize<JsonRpcRequest>(content)!;
this.CapturedMessageSendParams = jsonRpcRequest.Params?.Deserialize<MessageSendParams>();
// Return the pre-configured non-streaming response
if (this.ResponseToReturn is not null)
{
var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse<A2AEvent>("response-id", this.ResponseToReturn);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
};
}
// Return the pre-configured streaming response
else if (this.StreamingResponseToReturn is not null)
{
var stream = new MemoryStream();
await SseFormatter.WriteAsync(
new SseItem<JsonRpcResponse>[]
{
new(JsonRpcResponse.CreateJsonRpcResponse<A2AEvent>("response-id", this.StreamingResponseToReturn!))
}.ToAsyncEnumerable(),
stream,
(item, writer) =>
{
using Utf8JsonWriter json = new(writer, new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping });
JsonSerializer.Serialize(json, item.Data);
},
cancellationToken
);
stream.Position = 0;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StreamContent(stream)
{
Headers = { { "Content-Type", "text/event-stream" } }
}
};
}
else
{
var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse<A2AEvent>("response-id", new Message());
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
};
}
}
}
}
@@ -0,0 +1,126 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using A2A;
namespace Microsoft.Extensions.AI.Agents.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="A2ACardResolverExtensions"/> class.
/// </summary>
public sealed class A2ACardResolverExtensionsTests : IDisposable
{
private readonly HttpClient _httpClient;
private readonly HttpMessageHandlerStub _handler;
private readonly A2ACardResolver _resolver;
public A2ACardResolverExtensionsTests()
{
this._handler = new HttpMessageHandlerStub();
this._httpClient = new HttpClient(this._handler, false);
this._resolver = new A2ACardResolver(new Uri("http://test-host"), httpClient: this._httpClient);
}
[Fact]
public async Task GetAIAgentAsync_WithValidAgentCard_ReturnsAIAgentAsync()
{
// Arrange
this._handler.ResponsesToReturn.Enqueue(new AgentCard
{
Name = "Test Agent",
Description = "A test agent for unit testing",
Url = "http://test-endpoint/agent"
});
// Act
var agent = await this._resolver.GetAIAgentAsync();
// Assert
Assert.NotNull(agent);
Assert.IsType<A2AAgent>(agent);
Assert.Equal("Test Agent", agent.Name);
Assert.Equal("A test agent for unit testing", agent.Description);
// Verify that there was only one request made to retrieve the agent card
Assert.Single(this._handler.CapturedUris);
Assert.StartsWith("http://test-host/", this._handler.CapturedUris[0].ToString());
}
[Fact]
public async Task RunIAgentAsync_WithUrlFromAgentCard_SendsRequestToTheUrlAsync()
{
// Arrange
this._handler.ResponsesToReturn.Enqueue(new AgentCard
{
Url = "http://test-endpoint/agent"
});
this._handler.ResponsesToReturn.Enqueue(new Message
{
Role = MessageRole.Agent,
Parts = new List<Part> { new TextPart { Text = "Response" } },
});
var agent = await this._resolver.GetAIAgentAsync(this._httpClient);
// Act
await agent.RunAsync("Test input");
// Assert
Assert.Equal(2, this._handler.CapturedUris.Count); // One for getting the card, one for sending the message to the agent
Assert.Equal(new Uri("http://test-endpoint/agent"), this._handler.CapturedUris[1]);
}
public void Dispose()
{
this._handler.Dispose();
this._httpClient.Dispose();
}
internal sealed class HttpMessageHandlerStub : HttpMessageHandler
{
public Queue ResponsesToReturn { get; } = new();
public List<Uri> CapturedUris { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
this.CapturedUris.Add(request.RequestUri!);
var response = this.ResponsesToReturn.Dequeue();
if (response is AgentCard agentCard)
{
var json = JsonSerializer.Serialize(agentCard);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}
else if (response is Message message)
{
var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse<A2AEvent>("response-id", message);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
};
}
// Return empty agent card if none specified
var emptyCard = new AgentCard();
var emptyJson = JsonSerializer.Serialize(emptyCard);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(emptyJson, Encoding.UTF8, "application/json")
};
}
}
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using A2A;
namespace Microsoft.Extensions.AI.Agents.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="A2AClientExtensions"/> class.
/// </summary>
public sealed class A2AClientExtensionsTests
{
[Fact]
public void GetAIAgent_WithAllParameters_ReturnsA2AAgentWithSpecifiedProperties()
{
// Arrange
var a2aClient = new A2AClient(new Uri("http://test-endpoint"));
const string TestId = "test-agent-id";
const string TestName = "Test Agent";
const string TestDescription = "This is a test agent description";
const string TestDisplayName = "Test Display Name";
// Act
var agent = a2aClient.GetAIAgent(TestId, TestName, TestDescription, TestDisplayName);
// Assert
Assert.NotNull(agent);
Assert.IsType<A2AAgent>(agent);
Assert.Equal(TestId, agent.Id);
Assert.Equal(TestName, agent.Name);
Assert.Equal(TestDescription, agent.Description);
Assert.Equal(TestDisplayName, agent.DisplayName);
}
}
@@ -0,0 +1,64 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using A2A;
namespace Microsoft.Extensions.AI.Agents.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="A2AMessageExtensions"/> class.
/// </summary>
public sealed class A2AMessageExtensionsTests
{
[Fact]
public void ToChatMessage_WithMixedParts_ReturnsChatMessageWithMixedContents()
{
// Arrange
var uri = "https://example.com/image.jpg";
var metadata = new Dictionary<string, JsonElement>
{
["isUrgent"] = JsonDocument.Parse("true").RootElement
};
var message = new Message
{
MessageId = "mixed-parts-id",
Role = MessageRole.Agent,
Parts = new List<Part>
{
new TextPart { Text = "Here's an image:" },
new FilePart { File = new FileWithUri { Uri = uri } },
new TextPart { Text = "What do you think?" }
},
Metadata = metadata
};
// Act
var result = message.ToChatMessage();
// Assert
Assert.NotNull(result);
Assert.Equal(ChatRole.Assistant, result.Role);
Assert.Equal(message, result.RawRepresentation);
Assert.NotNull(result.Contents);
Assert.Equal(3, result.Contents.Count);
var firstContent = Assert.IsType<TextContent>(result.Contents[0]);
Assert.Equal("Here's an image:", firstContent.Text);
var fileContent = Assert.IsType<HostedFileContent>(result.Contents[1]);
Assert.Equal(uri, fileContent.FileId);
var lastContent = Assert.IsType<TextContent>(result.Contents[2]);
Assert.Equal("What do you think?", lastContent.Text);
Assert.NotNull(result.AdditionalProperties);
Assert.Single(result.AdditionalProperties);
Assert.True(result.AdditionalProperties.ContainsKey("isUrgent"));
Assert.True(((JsonElement)result.AdditionalProperties["isUrgent"]!).GetBoolean());
}
}
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
namespace Microsoft.Extensions.AI.Agents.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="A2AMetadataExtensions"/> class.
/// </summary>
public sealed class A2AMetadataExtensionsTests
{
[Fact]
public void ToAdditionalProperties_WithNullMetadata_ReturnsNull()
{
// Arrange
Dictionary<string, JsonElement>? metadata = null;
// Act
var result = metadata.ToAdditionalProperties();
// Assert
Assert.Null(result);
}
[Fact]
public void ToAdditionalProperties_WithEmptyMetadata_ReturnsNull()
{
// Arrange
var metadata = new Dictionary<string, JsonElement>();
// Act
var result = metadata.ToAdditionalProperties();
// Assert
Assert.Null(result);
}
[Fact]
public void ToAdditionalProperties_WithMultipleProperties_ReturnsAdditionalPropertiesDictionaryWithAllProperties()
{
// Arrange
var metadata = new Dictionary<string, JsonElement>
{
{ "stringKey", JsonSerializer.SerializeToElement("stringValue") },
{ "numberKey", JsonSerializer.SerializeToElement(42) },
{ "booleanKey", JsonSerializer.SerializeToElement(true) }
};
// Act
var result = metadata.ToAdditionalProperties();
// Assert
Assert.NotNull(result);
Assert.Equal(3, result.Count);
Assert.True(result.ContainsKey("stringKey"));
Assert.Equal("stringValue", ((JsonElement)result["stringKey"]!).GetString());
Assert.True(result.ContainsKey("numberKey"));
Assert.Equal(42, ((JsonElement)result["numberKey"]!).GetInt32());
Assert.True(result.ContainsKey("booleanKey"));
Assert.True(((JsonElement)result["booleanKey"]!).GetBoolean());
}
}
@@ -0,0 +1,96 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using A2A;
namespace Microsoft.Extensions.AI.Agents.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="A2APartExtensions"/> class.
/// </summary>
public sealed class A2APartExtensionsTests
{
[Fact]
public void ToAIContent_WithTextPart_ReturnsTextContent()
{
// Arrange
var textPart = new TextPart { Text = "Hello, world!" };
// Act
var result = textPart.ToAIContent();
// Assert
Assert.NotNull(result);
Assert.Equal(textPart, result.RawRepresentation);
var textContent = Assert.IsType<TextContent>(result);
Assert.Equal("Hello, world!", textContent.Text);
}
[Fact]
public void ToAIContent_WithTextPartWithMetadata_ReturnsTextContentWithAdditionalProperties()
{
// Arrange
var metadata = new Dictionary<string, JsonElement>
{
["key1"] = JsonDocument.Parse("\"value1\"").RootElement,
["key2"] = JsonDocument.Parse("42").RootElement,
["key3"] = JsonDocument.Parse("true").RootElement
};
var textPart = new TextPart
{
Text = "Hello with metadata!",
Metadata = metadata
};
// Act
var result = textPart.ToAIContent();
// Assert
Assert.NotNull(result);
var textContent = Assert.IsType<TextContent>(result);
Assert.Equal("Hello with metadata!", textContent.Text);
Assert.NotNull(textContent.AdditionalProperties);
Assert.Equal(3, textContent.AdditionalProperties.Count);
Assert.True(textContent.AdditionalProperties.ContainsKey("key1"));
Assert.True(textContent.AdditionalProperties.ContainsKey("key2"));
Assert.True(textContent.AdditionalProperties.ContainsKey("key3"));
}
[Fact]
public void ToAIContent_WithFilePartWithFileWithUri_ReturnsHostedFileContent()
{
// Arrange
var uri = "https://example.com/file.txt";
var filePart = new FilePart { File = new FileWithUri { Uri = uri } };
// Act
var result = filePart.ToAIContent();
// Assert
Assert.NotNull(result);
Assert.Equal(filePart, result.RawRepresentation);
var hostedFileContent = Assert.IsType<HostedFileContent>(result);
Assert.Equal(uri, hostedFileContent.FileId);
Assert.Null(hostedFileContent.AdditionalProperties);
}
[Fact]
public void ToAIContent_WithCustomPartType_ThrowsNotSupportedException()
{
// Arrange
var customPart = new MockPart();
// Act & Assert
var exception = Assert.Throws<NotSupportedException>(() => customPart.ToAIContent());
Assert.Equal("Part type 'MockPart' is not supported.", exception.Message);
}
// Mock class for testing unsupported scenarios
private sealed class MockPart : Part
{
}
}
@@ -0,0 +1,112 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using A2A;
namespace Microsoft.Extensions.AI.Agents.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AIContentExtensions"/> class.
/// </summary>
public sealed class AIContentExtensionsTests
{
[Fact]
public void ToA2APart_WithTextContent_ReturnsTextPart()
{
// Arrange
var textContent = new TextContent("Hello, world!");
// Act
var result = textContent.ToA2APart();
// Assert
Assert.NotNull(result);
var textPart = Assert.IsType<TextPart>(result);
Assert.Equal("Hello, world!", textPart.Text);
}
[Fact]
public void ToA2APart_WithHostedFileContent_ReturnsFilePart()
{
// Arrange
var uri = "https://example.com/file.txt";
var hostedFileContent = new HostedFileContent(uri);
// Act
var result = hostedFileContent.ToA2APart();
// Assert
Assert.NotNull(result);
var filePart = Assert.IsType<FilePart>(result);
Assert.NotNull(filePart.File);
var fileWithUri = Assert.IsType<FileWithUri>(filePart.File);
Assert.Equal(uri, fileWithUri.Uri);
}
[Fact]
public void ToA2APart_WithUnsupportedContentType_ThrowsNotSupportedException()
{
// Arrange
var unsupportedContent = new MockAIContent();
// Act & Assert
var exception = Assert.Throws<NotSupportedException>(() => unsupportedContent.ToA2APart());
Assert.Equal("Unsupported content type: MockAIContent.", exception.Message);
}
[Fact]
public void ToA2AParts_WithEmptyCollection_ReturnsNull()
{
// Arrange
var emptyContents = new List<AIContent>();
// Act
var result = emptyContents.ToA2AParts();
// Assert
Assert.Null(result);
}
[Fact]
public void ToA2AParts_WithMultipleContents_ReturnsListWithAllParts()
{
// Arrange
var contents = new List<AIContent>
{
new TextContent("First text"),
new HostedFileContent("https://example.com/file1.txt"),
new TextContent("Second text"),
new HostedFileContent("https://example.com/file2.txt")
};
// Act
var result = contents.ToA2AParts();
// Assert
Assert.NotNull(result);
Assert.Equal(4, result.Count);
var firstTextPart = Assert.IsType<TextPart>(result[0]);
Assert.Equal("First text", firstTextPart.Text);
var firstFilePart = Assert.IsType<FilePart>(result[1]);
var firstFileWithUri = Assert.IsType<FileWithUri>(firstFilePart.File);
Assert.Equal("https://example.com/file1.txt", firstFileWithUri.Uri);
var secondTextPart = Assert.IsType<TextPart>(result[2]);
Assert.Equal("Second text", secondTextPart.Text);
var secondFilePart = Assert.IsType<FilePart>(result[3]);
var secondFileWithUri = Assert.IsType<FileWithUri>(secondFilePart.File);
Assert.Equal("https://example.com/file2.txt", secondFileWithUri.Uri);
}
// Mock class for testing unsupported scenarios
private sealed class MockAIContent : AIContent
{
}
}
@@ -0,0 +1,90 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using A2A;
namespace Microsoft.Extensions.AI.Agents.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="ChatMessageExtensions"/> class.
/// </summary>
public sealed class ChatMessageExtensionsTests
{
[Fact]
public void ToA2AMessage_WithMessageContainingMultipleContents_AddsAllContentsAsParts()
{
// Arrange
var contents = new List<AIContent>
{
new HostedFileContent("https://example.com/report.pdf"),
new TextContent("please summarize the file content"),
new TextContent("and send it to me over email")
};
var chatMessage = new ChatMessage(ChatRole.User, contents);
var messages = new List<ChatMessage> { chatMessage };
// Act
var a2aMessage = messages.ToA2AMessage();
// Assert
Assert.NotNull(a2aMessage);
Assert.NotNull(a2aMessage.MessageId);
Assert.NotEmpty(a2aMessage.MessageId);
Assert.Equal(MessageRole.User, a2aMessage.Role);
Assert.NotNull(a2aMessage.Parts);
Assert.Equal(3, a2aMessage.Parts.Count);
var filePart = Assert.IsType<FilePart>(a2aMessage.Parts[0]);
Assert.NotNull(filePart.File);
var fileWithUri = Assert.IsType<FileWithUri>(filePart.File);
Assert.Equal("https://example.com/report.pdf", fileWithUri.Uri);
var secondTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[1]);
Assert.Equal("please summarize the file content", secondTextPart.Text);
var thirdTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[2]);
Assert.Equal("and send it to me over email", thirdTextPart.Text);
}
[Fact]
public void ToA2AMessage_WithMixedMessages_AddsAllContentsAsParts()
{
// Arrange
var firstMessage = new ChatMessage(ChatRole.User, [
new HostedFileContent("https://example.com/report.pdf")
]);
var secondMessage = new ChatMessage(ChatRole.User, [
new TextContent("please summarize the file content")
]);
var thirdMessage = new ChatMessage(ChatRole.User, [
new TextContent("and send it to me over email")
]);
var messages = new List<ChatMessage> { firstMessage, secondMessage, thirdMessage };
// Act
var a2aMessage = messages.ToA2AMessage();
// Assert
Assert.NotNull(a2aMessage);
Assert.NotNull(a2aMessage.MessageId);
Assert.NotEmpty(a2aMessage.MessageId);
Assert.Equal(MessageRole.User, a2aMessage.Role);
Assert.NotNull(a2aMessage.Parts);
Assert.Equal(3, a2aMessage.Parts.Count);
var filePart = Assert.IsType<FilePart>(a2aMessage.Parts[0]);
Assert.NotNull(filePart.File);
var fileWithUri = Assert.IsType<FileWithUri>(filePart.File);
Assert.Equal("https://example.com/report.pdf", fileWithUri.Uri);
var secondTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[1]);
Assert.Equal("please summarize the file content", secondTextPart.Text);
var thirdTextPart = Assert.IsType<TextPart>(a2aMessage.Parts[2]);
Assert.Equal("and send it to me over email", thirdTextPart.Text);
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(ProjectsTargetFrameworks)</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Net.ServerSentEvents" VersionOverride="10.0.0-preview.5.25277.114" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" VersionOverride="10.0.0-preview.5.25277.114" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Extensions.AI.Agents.A2A\Microsoft.Extensions.AI.Agents.A2A.csproj" />
</ItemGroup>
</Project>
@@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="System.Text.Json" />
<PackageReference Include="System.Linq.Async" />
</ItemGroup>
</Project>
@@ -12,6 +12,7 @@
<ItemGroup>
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
<PackageReference Include="System.Linq.Async" />
</ItemGroup>
</Project>