// 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;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.A2A.UnitTests;
///
/// Unit tests for the class.
///
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(() => 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_AllowsNonUserRoleMessagesAsync()
{
// Arrange
var inputMessages = new List
{
new(ChatRole.System, "I am a system message"),
new(ChatRole.Assistant, "I am an assistant message"),
new(ChatRole.User, "Valid user message")
};
// Act & Assert
await this._agent.RunAsync(inputMessages);
}
[Fact]
public async Task RunAsync_WithValidUserMessage_RunsSuccessfullyAsync()
{
// Arrange
this._handler.ResponseToReturn = new AgentMessage
{
MessageId = "response-123",
Role = MessageRole.Agent,
Parts =
[
new TextPart { Text = "Hello! How can I help you today?" }
]
};
var inputMessages = new List
{
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(result.RawRepresentation);
Assert.Equal("response-123", ((AgentMessage)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 AgentMessage
{
MessageId = "response-123",
Role = MessageRole.Agent,
Parts =
[
new TextPart { Text = "Response" }
],
ContextId = "new-context-id"
};
var inputMessages = new List
{
new(ChatRole.User, "Test message")
};
var thread = this._agent.GetNewThread();
// Act
await this._agent.RunAsync(inputMessages, thread);
// Assert
Assert.IsType(thread);
var a2aThread = (A2AAgentThread)thread;
Assert.Equal("new-context-id", a2aThread.ContextId);
}
[Fact]
public async Task RunAsync_WithExistingThread_SetConversationIdToMessageAsync()
{
// Arrange
var inputMessages = new List
{
new(ChatRole.User, "Test message")
};
var thread = this._agent.GetNewThread();
var a2aThread = (A2AAgentThread)thread;
a2aThread.ContextId = "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
{
new(ChatRole.User, "Test message")
};
this._handler.ResponseToReturn = new AgentMessage
{
MessageId = "response-123",
Role = MessageRole.Agent,
Parts =
[
new TextPart { Text = "Response" }
],
ContextId = "different-context"
};
var thread = this._agent.GetNewThread();
var a2aThread = (A2AAgentThread)thread;
a2aThread.ContextId = "existing-context-id";
// Act & Assert
await Assert.ThrowsAsync(() => this._agent.RunAsync(inputMessages, thread));
}
[Fact]
public async Task RunStreamingAsync_WithValidUserMessage_YieldsAgentRunResponseUpdatesAsync()
{
// Arrange
var inputMessages = new List
{
new(ChatRole.User, "Hello, streaming!")
};
this._handler.StreamingResponseToReturn = new AgentMessage()
{
MessageId = "stream-1",
Role = MessageRole.Agent,
Parts = [new TextPart { Text = "Hello" }],
ContextId = "stream-context"
};
// Act
var updates = new List();
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(updates[0].RawRepresentation);
Assert.Equal("stream-1", ((AgentMessage)updates[0].RawRepresentation!).MessageId);
}
[Fact]
public async Task RunStreamingAsync_WithThread_UpdatesThreadConversationIdAsync()
{
// Arrange
var inputMessages = new List
{
new(ChatRole.User, "Test streaming")
};
this._handler.StreamingResponseToReturn = new AgentMessage()
{
MessageId = "stream-1",
Role = MessageRole.Agent,
Parts = [new TextPart { Text = "Response" }],
ContextId = "new-stream-context"
};
var thread = this._agent.GetNewThread();
// Act
await foreach (var _ in this._agent.RunStreamingAsync(inputMessages, thread))
{
// Just iterate through to trigger the logic
}
// Assert
var a2aThread = (A2AAgentThread)thread;
Assert.Equal("new-stream-context", a2aThread.ContextId);
}
[Fact]
public async Task RunStreamingAsync_WithExistingThread_SetConversationIdToMessageAsync()
{
// Arrange
var inputMessages = new List
{
new(ChatRole.User, "Test streaming")
};
this._handler.StreamingResponseToReturn = new AgentMessage();
var thread = this._agent.GetNewThread();
var a2aThread = (A2AAgentThread)thread;
a2aThread.ContextId = "existing-context-id";
// Act
await foreach (var _ 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();
var a2aThread = (A2AAgentThread)thread;
a2aThread.ContextId = "existing-context-id";
var inputMessages = new List
{
new(ChatRole.User, "Test streaming")
};
this._handler.StreamingResponseToReturn = new AgentMessage()
{
MessageId = "stream-1",
Role = MessageRole.Agent,
Parts = [new TextPart { Text = "Response" }],
ContextId = "different-context"
};
// Act
await Assert.ThrowsAsync(async () =>
{
await foreach (var update in this._agent.RunStreamingAsync(inputMessages, thread))
{
}
});
}
[Fact]
public async Task RunStreamingAsync_AllowsNonUserRoleMessagesAsync()
{
// Arrange
this._handler.StreamingResponseToReturn = new AgentMessage()
{
MessageId = "stream-1",
Role = MessageRole.Agent,
Parts = [new TextPart { Text = "Response" }],
ContextId = "new-stream-context"
};
var inputMessages = new List
{
new(ChatRole.System, "I am a system message"),
new(ChatRole.Assistant, "I am an assistant message"),
new(ChatRole.User, "Valid user message")
};
// Act & Assert
await foreach (var _ in this._agent.RunStreamingAsync(inputMessages))
{
}
}
[Fact]
public async Task RunAsync_WithHostedFileContent_ConvertsToFilePartAsync()
{
// Arrange
var inputMessages = new List
{
new(ChatRole.User,
[
new TextContent("Check this file:"),
new UriContent("https://example.com/file.pdf", "application/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(message.Parts[0]);
Assert.Equal("Check this file:", ((TextPart)message.Parts[0]).Text);
Assert.IsType(message.Parts[1]);
Assert.Equal("https://example.com/file.pdf", ((FilePart)message.Parts[1]).File.Uri?.ToString());
}
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 SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
// Capture the request content
#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods; overload doesn't exist downlevel
var content = await request.Content!.ReadAsStringAsync();
#pragma warning restore CA2016
var jsonRpcRequest = JsonSerializer.Deserialize(content)!;
this.CapturedMessageSendParams = jsonRpcRequest.Params?.Deserialize();
// Return the pre-configured non-streaming response
if (this.ResponseToReturn is not null)
{
var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("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[]
{
new(JsonRpcResponse.CreateJsonRpcResponse("response-id", this.StreamingResponseToReturn!))
}.ToAsyncEnumerable(),
stream,
(item, writer) =>
{
using Utf8JsonWriter json = new(writer, new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping });
JsonSerializer.Serialize(json, item.Data);
},
cancellationToken
);
stream.Position = 0;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StreamContent(stream)
{
Headers = { { "Content-Type", "text/event-stream" } }
}
};
}
else
{
var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("response-id", new AgentMessage());
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json")
};
}
}
}
}