// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
///
/// Unit tests for the class.
///
public class AIAgentTests
{
private readonly Mock _agentMock;
private readonly Mock _agentThreadMock;
private readonly AgentResponse _invokeResponse;
private readonly List _invokeStreamingResponses = [];
///
/// Initializes a new instance of the class.
///
public AIAgentTests()
{
this._agentThreadMock = new Mock(MockBehavior.Strict);
this._invokeResponse = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Hi"));
this._invokeStreamingResponses.Add(new AgentResponseUpdate(ChatRole.Assistant, "Hi"));
this._agentMock = new Mock { CallBase = true };
this._agentMock
.Protected()
.Setup>("RunCoreAsync",
ItExpr.IsAny>(),
ItExpr.Is(t => t == this._agentThreadMock.Object),
ItExpr.IsAny(),
ItExpr.IsAny())
.ReturnsAsync(this._invokeResponse);
this._agentMock
.Protected()
.Setup>("RunCoreStreamingAsync",
ItExpr.IsAny>(),
ItExpr.Is(t => t == this._agentThreadMock.Object),
ItExpr.IsAny(),
ItExpr.IsAny())
.Returns(ToAsyncEnumerableAsync(this._invokeStreamingResponses));
}
///
/// Tests that invoking without a message calls the mocked invoke method with an empty array.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task InvokeWithoutMessageCallsMockedInvokeWithEmptyArrayAsync()
{
// Arrange
var options = new AgentRunOptions();
var cancellationToken = default(CancellationToken);
// Act
var response = await this._agentMock.Object.RunAsync(this._agentThreadMock.Object, options, cancellationToken);
Assert.Equal(this._invokeResponse, response);
// Verify that the mocked method was called with the expected parameters
this._agentMock
.Protected()
.Verify>("RunCoreAsync",
Times.Once(),
ItExpr.Is>(messages => !messages.Any()),
ItExpr.Is(t => t == this._agentThreadMock.Object),
ItExpr.Is(o => o == options),
ItExpr.Is(ct => ct == cancellationToken));
}
///
/// Tests that invoking with a string message calls the mocked invoke method with the message in the ICollection of messages.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task InvokeWithStringMessageCallsMockedInvokeWithMessageInCollectionAsync()
{
// Arrange
const string Message = "Hello, Agent!";
var options = new AgentRunOptions();
var cancellationToken = default(CancellationToken);
// Act
var response = await this._agentMock.Object.RunAsync(Message, this._agentThreadMock.Object, options, cancellationToken);
Assert.Equal(this._invokeResponse, response);
// Verify that the mocked method was called with the expected parameters
this._agentMock
.Protected()
.Verify>("RunCoreAsync",
Times.Once(),
ItExpr.Is>(messages => messages.Count() == 1 && messages.First().Text == Message),
ItExpr.Is(t => t == this._agentThreadMock.Object),
ItExpr.Is(o => o == options),
ItExpr.Is(ct => ct == cancellationToken));
}
///
/// Tests that invoking with a single message calls the mocked invoke method with the message in the ICollection of messages.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task InvokeWithSingleMessageCallsMockedInvokeWithMessageInCollectionAsync()
{
// Arrange
var message = new ChatMessage(ChatRole.User, "Hello, Agent!");
var options = new AgentRunOptions();
var cancellationToken = default(CancellationToken);
// Act
var response = await this._agentMock.Object.RunAsync(message, this._agentThreadMock.Object, options, cancellationToken);
Assert.Equal(this._invokeResponse, response);
// Verify that the mocked method was called with the expected parameters
this._agentMock
.Protected()
.Verify>("RunCoreAsync",
Times.Once(),
ItExpr.Is>(messages => messages.Count() == 1 && messages.First() == message),
ItExpr.Is(t => t == this._agentThreadMock.Object),
ItExpr.Is(o => o == options),
ItExpr.Is(ct => ct == cancellationToken));
}
///
/// Tests that invoking streaming without a message calls the mocked invoke method with an empty array.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task InvokeStreamingWithoutMessageCallsMockedInvokeWithEmptyArrayAsync()
{
// Arrange
var options = new AgentRunOptions();
var cancellationToken = default(CancellationToken);
// Act
await foreach (var response in this._agentMock.Object.RunStreamingAsync(this._agentThreadMock.Object, options, cancellationToken))
{
// Assert
Assert.Contains(response, this._invokeStreamingResponses);
}
// Verify that the mocked method was called with the expected parameters
this._agentMock
.Protected()
.Verify>("RunCoreStreamingAsync",
Times.Once(),
ItExpr.Is>(messages => !messages.Any()),
ItExpr.Is(t => t == this._agentThreadMock.Object),
ItExpr.Is(o => o == options),
ItExpr.Is(ct => ct == cancellationToken));
}
///
/// Tests that invoking streaming with a string message calls the mocked invoke method with the message in the ICollection of messages.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task InvokeStreamingWithStringMessageCallsMockedInvokeWithMessageInCollectionAsync()
{
// Arrange
const string Message = "Hello, Agent!";
var options = new AgentRunOptions();
var cancellationToken = default(CancellationToken);
// Act
await foreach (var response in this._agentMock.Object.RunStreamingAsync(Message, this._agentThreadMock.Object, options, cancellationToken))
{
// Assert
Assert.Contains(response, this._invokeStreamingResponses);
}
// Verify that the mocked method was called with the expected parameters
this._agentMock
.Protected()
.Verify>("RunCoreStreamingAsync",
Times.Once(),
ItExpr.Is>(messages => messages.Count() == 1 && messages.First().Text == Message),
ItExpr.Is(t => t == this._agentThreadMock.Object),
ItExpr.Is(o => o == options),
ItExpr.Is(ct => ct == cancellationToken));
}
///
/// Tests that invoking streaming with a single message calls the mocked invoke method with the message in the ICollection of messages.
///
/// A task that represents the asynchronous operation.
[Fact]
public async Task InvokeStreamingWithSingleMessageCallsMockedInvokeWithMessageInCollectionAsync()
{
// Arrange
var message = new ChatMessage(ChatRole.User, "Hello, Agent!");
var options = new AgentRunOptions();
var cancellationToken = default(CancellationToken);
// Act
await foreach (var response in this._agentMock.Object.RunStreamingAsync(message, this._agentThreadMock.Object, options, cancellationToken))
{
// Assert
Assert.Contains(response, this._invokeStreamingResponses);
}
// Verify that the mocked method was called with the expected parameters
this._agentMock
.Protected()
.Verify>("RunCoreStreamingAsync",
Times.Once(),
ItExpr.Is>(messages => messages.Count() == 1 && messages.First() == message),
ItExpr.Is(t => t == this._agentThreadMock.Object),
ItExpr.Is(o => o == options),
ItExpr.Is(ct => ct == cancellationToken));
}
[Fact]
public void ValidateAgentIDIsIdempotent()
{
// Arrange
var agent = new MockAgent();
// Act
string id = agent.Id;
// Assert
Assert.NotNull(id);
Assert.Equal(id, agent.Id);
}
[Fact]
public void ValidateAgentIDCanBeProvidedByDerivedAgentClass()
{
// Arrange
var agent = new MockAgent(id: "test-agent-id");
// Act
string id = agent.Id;
// Assert
Assert.NotNull(id);
Assert.Equal("test-agent-id", id);
}
#region GetService Method Tests
///
/// Verify that GetService returns the agent itself when requesting the exact agent type.
///
[Fact]
public void GetService_RequestingExactAgentType_ReturnsAgent()
{
// Arrange
var agent = new MockAgent();
// Act
var result = agent.GetService(typeof(MockAgent));
// Assert
Assert.NotNull(result);
Assert.Same(agent, result);
}
///
/// Verify that GetService returns the agent itself when requesting the base AIAgent type.
///
[Fact]
public void GetService_RequestingAIAgentType_ReturnsAgent()
{
// Arrange
var agent = new MockAgent();
// Act
var result = agent.GetService(typeof(AIAgent));
// Assert
Assert.NotNull(result);
Assert.Same(agent, result);
}
///
/// Verify that GetService returns null when requesting an unrelated type.
///
[Fact]
public void GetService_RequestingUnrelatedType_ReturnsNull()
{
// Arrange
var agent = new MockAgent();
// Act
var result = agent.GetService(typeof(string));
// Assert
Assert.Null(result);
}
///
/// Verify that GetService returns null when a service key is provided, even for matching types.
///
[Fact]
public void GetService_WithServiceKey_ReturnsNull()
{
// Arrange
var agent = new MockAgent();
// Act
var result = agent.GetService(typeof(MockAgent), "some-key");
// Assert
Assert.Null(result);
}
///
/// Verify that GetService throws ArgumentNullException when serviceType is null.
///
[Fact]
public void GetService_WithNullServiceType_ThrowsArgumentNullException()
{
// Arrange
var agent = new MockAgent();
// Act & Assert
Assert.Throws(() => agent.GetService(null!));
}
///
/// Verify that GetService generic method works correctly.
///
[Fact]
public void GetService_Generic_ReturnsCorrectType()
{
// Arrange
var agent = new MockAgent();
// Act
var result = agent.GetService();
// Assert
Assert.NotNull(result);
Assert.Same(agent, result);
}
///
/// Verify that GetService generic method returns null for unrelated types.
///
[Fact]
public void GetService_Generic_ReturnsNullForUnrelatedType()
{
// Arrange
var agent = new MockAgent();
// Act
var result = agent.GetService();
// Assert
Assert.Null(result);
}
#endregion
///
/// Typed mock thread.
///
public abstract class TestAgentThread : AgentThread;
private sealed class MockAgent : AIAgent
{
public MockAgent(string? id = null)
{
this.IdCore = id;
}
protected override string? IdCore { get; }
public override async ValueTask GetNewThreadAsync(CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public override async ValueTask DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
protected override Task RunCoreAsync(
IEnumerable messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
protected override IAsyncEnumerable RunCoreStreamingAsync(
IEnumerable messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
}
private static async IAsyncEnumerable ToAsyncEnumerableAsync(IEnumerable values)
{
await Task.Yield();
foreach (var update in values)
{
yield return update;
}
}
}