Files
agent-framework/dotnet/tests/Microsoft.Extensions.AI.Agents.Abstractions.UnitTests/AgentTests.cs
T
westey 715769e649 Run Response ADR & Updates (#104)
* Add ADR for different run response options

* Add another option to the list.

* Update agno non-streaming with further clarification

* Add another option

* Adding optional includeUpdates option

* Adding Pros/Cons for each option

* Make pros/cons a list

* Add some thoughts on structured outputs and custom AIContent types

* Update design doc to clarify primary and secondary better and split out custom response types with it's own options

* Add structured outputs competitive comparison and suggestion

* Address PR comments.

* Remove AgentRunFinishReason until we can find a good use case for it.

* Add finish reason to list of excluded properties.

* Add custom agent run response types.
Usage to follow.

* Update Agent run response types

* Add additional code coverage

* Remove onIntermediateMessage since it is unecessary with the new response approach.

* Add AgentId to response.

* Rename ParseAsStructuredOutput to Deserialize

* Update decision doc.

* Fix formatting.

* Update CopilotStudio to return new response types

* Address PR comment

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>

---------

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
2025-07-11 11:39:18 +00:00

296 lines
11 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using Moq.Protected;
namespace Microsoft.Extensions.AI.Agents.Abstractions.UnitTests;
/// <summary>
/// Unit tests for the <see cref="Agent"/> class.
/// </summary>
public class AgentTests
{
private readonly Mock<Agent> _agentMock;
private readonly Mock<AgentThread> _agentThreadMock;
private readonly AgentRunResponse _invokeResponse = new();
private readonly List<AgentRunResponseUpdate> _invokeStreamingResponses = [];
/// <summary>
/// Initializes a new instance of the <see cref="AgentTests"/> class.
/// </summary>
public AgentTests()
{
this._agentThreadMock = new Mock<AgentThread>(MockBehavior.Strict);
this._invokeResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Hi"));
this._invokeStreamingResponses.Add(new AgentRunResponseUpdate(ChatRole.Assistant, "Hi"));
this._agentMock = new Mock<Agent>() { CallBase = true };
this._agentMock
.Setup(x => x.RunAsync(
It.IsAny<IReadOnlyCollection<ChatMessage>>(),
this._agentThreadMock.Object,
It.IsAny<AgentRunOptions?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(this._invokeResponse);
this._agentMock
.Setup(x => x.RunStreamingAsync(
It.IsAny<IReadOnlyCollection<ChatMessage>>(),
this._agentThreadMock.Object,
It.IsAny<AgentRunOptions?>(),
It.IsAny<CancellationToken>()))
.Returns(this._invokeStreamingResponses.ToAsyncEnumerable());
}
/// <summary>
/// Tests that invoking without a message calls the mocked invoke method with an empty array.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InvokeWithoutMessageCallsMockedInvokeWithEmptyArrayAsync()
{
// Arrange
var options = new AgentRunOptions();
var cancellationToken = new 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.Verify(
x => x.RunAsync(
It.Is<IReadOnlyCollection<ChatMessage>>(messages => messages.Count == 0),
this._agentThreadMock.Object,
options,
cancellationToken),
Times.Once);
}
/// <summary>
/// Tests that invoking with a string message calls the mocked invoke method with the message in the ICollection of messages.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InvokeWithStringMessageCallsMockedInvokeWithMessageInCollectionAsync()
{
// Arrange
var message = "Hello, Agent!";
var options = new AgentRunOptions();
var cancellationToken = new 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.Verify(
x => x.RunAsync(
It.Is<IReadOnlyCollection<ChatMessage>>(messages => messages.Count == 1 && messages.First().Text == message),
this._agentThreadMock.Object,
options,
cancellationToken),
Times.Once);
}
/// <summary>
/// Tests that invoking with a single message calls the mocked invoke method with the message in the ICollection of messages.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InvokeWithSingleMessageCallsMockedInvokeWithMessageInCollectionAsync()
{
// Arrange
var message = new ChatMessage(ChatRole.User, "Hello, Agent!");
var options = new AgentRunOptions();
var cancellationToken = new 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.Verify(
x => x.RunAsync(
It.Is<IReadOnlyCollection<ChatMessage>>(messages => messages.Count == 1 && messages.First() == message),
this._agentThreadMock.Object,
options,
cancellationToken),
Times.Once);
}
/// <summary>
/// Tests that invoking streaming without a message calls the mocked invoke method with an empty array.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InvokeStreamingWithoutMessageCallsMockedInvokeWithEmptyArrayAsync()
{
// Arrange
var options = new AgentRunOptions();
var cancellationToken = new 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.Verify(
x => x.RunStreamingAsync(
It.Is<IReadOnlyCollection<ChatMessage>>(messages => messages.Count == 0),
this._agentThreadMock.Object,
options,
cancellationToken),
Times.Once);
}
/// <summary>
/// Tests that invoking streaming with a string message calls the mocked invoke method with the message in the ICollection of messages.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InvokeStreamingWithStringMessageCallsMockedInvokeWithMessageInCollectionAsync()
{
// Arrange
var message = "Hello, Agent!";
var options = new AgentRunOptions();
var cancellationToken = new 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.Verify(
x => x.RunStreamingAsync(
It.Is<IReadOnlyCollection<ChatMessage>>(messages => messages.Count == 1 && messages.First().Text == message),
this._agentThreadMock.Object,
options,
cancellationToken),
Times.Once);
}
/// <summary>
/// Tests that invoking streaming with a single message calls the mocked invoke method with the message in the ICollection of messages.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InvokeStreamingWithSingleMessageCallsMockedInvokeWithMessageInCollectionAsync()
{
// Arrange
var message = new ChatMessage(ChatRole.User, "Hello, Agent!");
var options = new AgentRunOptions();
var cancellationToken = new 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.Verify(
x => x.RunStreamingAsync(
It.Is<IReadOnlyCollection<ChatMessage>>(messages => messages.Count == 1 && messages.First() == message),
this._agentThreadMock.Object,
options,
cancellationToken),
Times.Once);
}
[Fact]
public void ValidateAgentIDIsIdempotent()
{
var agent = new MockAgent();
string id = agent.Id;
Assert.NotNull(id);
Assert.Equal(id, agent.Id);
}
[Fact]
public void ValidateOrCreateThreadTypeVerifiesAndCreatesThread()
{
// Custom thread type for type checking
var threadMock = new Mock<TestAgentThread>() { CallBase = true };
var agent = new MockAgent();
// Should create
var result = agent.ValidateOrCreateThreadType<TestAgentThread>(null, () => threadMock.Object);
Assert.Same(threadMock.Object, result);
// Should throw if wrong type
var wrongThread = new Mock<AgentThread>().Object;
Assert.Throws<NotSupportedException>(() => agent.ValidateOrCreateThreadType<TestAgentThread>(wrongThread, () => threadMock.Object));
}
[Fact]
public async Task NotifyThreadOfNewMessagesNotifiesThreadAsync()
{
var cancellationToken = new CancellationToken();
var messages = new[] { new ChatMessage(ChatRole.User, "msg1"), new ChatMessage(ChatRole.User, "msg2") };
var threadMock = new Mock<TestAgentThread>() { CallBase = true };
var agent = new MockAgent();
await agent.NotifyThreadOfNewMessagesAsync(threadMock.Object, messages, cancellationToken);
threadMock.Protected().Verify("OnNewMessagesAsync", Times.Once(), messages, cancellationToken);
}
/// <summary>
/// Typed mock thread.
/// </summary>
public abstract class TestAgentThread : AgentThread;
/// <summary>
/// Mock class to test the <see cref="Agent.ValidateOrCreateThreadType{TThreadType}"/> method.
/// </summary>
private sealed class MockAgent : Agent
{
public new TThreadType ValidateOrCreateThreadType<TThreadType>(
AgentThread? thread,
Func<TThreadType> constructThread)
where TThreadType : AgentThread
{
return base.ValidateOrCreateThreadType<TThreadType>(
thread,
constructThread);
}
public new Task NotifyThreadOfNewMessagesAsync(AgentThread thread, IReadOnlyCollection<ChatMessage> messages, CancellationToken cancellationToken)
{
return base.NotifyThreadOfNewMessagesAsync(thread, messages, cancellationToken);
}
public override AgentThread GetNewThread()
{
throw new NotImplementedException();
}
public override Task<AgentRunResponse> RunAsync(IReadOnlyCollection<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
throw new System.NotImplementedException();
}
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IReadOnlyCollection<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
throw new System.NotImplementedException();
}
}
}