.Net: Added Agent abstractions from SK repository (#56)

* Fixed project build in Visual Studio

* Added Agent abstractions

* Remove features we are not porting over, addressing PR comments and making updates as per agreed design.

* Add create thread method.

* Add unit tests and update invoke response type to async

* Address PR comments and fix formatting.

* Switch to shared null checker to fix build failures.

* Add additional tests to increase code coverage

* Seal mockagent

* Fix line coverage failure

* Improve coverage check formatting

---------

Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>
This commit is contained in:
Dmytro Struk
2025-06-09 03:35:41 -07:00
committed by GitHub
Unverified
parent a990866901
commit af2295f130
11 changed files with 937 additions and 20 deletions
+15 -4
View File
@@ -28,11 +28,15 @@ function Get-FormattedValue {
return "$formattedNumber% $icon"
}
$totallines = $jsonContent.summary.totallines
$totalbranches = $jsonContent.summary.totalbranches
$lineCoverage = $jsonContent.summary.linecoverage
$branchCoverage = $jsonContent.summary.branchcoverage
$totalTableData = [PSCustomObject]@{
'Metric' = 'Total Coverage'
'Total Lines' = $totallines
'Total Branches' = $totalbranches
'Line Coverage' = Get-FormattedValue -Coverage $lineCoverage
'Branch Coverage' = Get-FormattedValue -Coverage $branchCoverage
}
@@ -43,19 +47,26 @@ $assemblyTableData = @()
foreach ($assembly in $jsonContent.coverage.assemblies) {
$assemblyName = $assembly.name
$assemblyTotallines = $assembly.totallines
$assemblyTotalbranches = $assembly.totalbranches
$assemblyLineCoverage = $assembly.coverage
$assemblyBranchCoverage = $assembly.branchcoverage
$isNonExperimentalAssembly = $nonExperimentalAssemblies -contains $assemblyName
if ($isNonExperimentalAssembly -and ($assemblyLineCoverage -lt $CoverageThreshold -or $assemblyBranchCoverage -lt $CoverageThreshold)) {
$lineCoverageFailed = $assemblyLineCoverage -lt $CoverageThreshold -and $assemblyTotallines -gt 0
$branchCoverageFailed = $assemblyBranchCoverage -lt $CoverageThreshold -and $assemblyTotalbranches -gt 0
if ($isNonExperimentalAssembly -and ($lineCoverageFailed -or $branchCoverageFailed)) {
$coverageBelowThreshold = $true
}
$assemblyTableData += [PSCustomObject]@{
'Assembly Name' = $assemblyName
'Line' = Get-FormattedValue -Coverage $assemblyLineCoverage -UseIcon $isNonExperimentalAssembly
'Branch' = Get-FormattedValue -Coverage $assemblyBranchCoverage -UseIcon $isNonExperimentalAssembly
'Total Lines' = $assemblyTotallines
'Total Branches' = $assemblyTotalbranches
'Line Coverage' = Get-FormattedValue -Coverage $assemblyLineCoverage -UseIcon $isNonExperimentalAssembly
'Branch Coverage' = Get-FormattedValue -Coverage $assemblyBranchCoverage -UseIcon $isNonExperimentalAssembly
}
}
+3
View File
@@ -5,10 +5,13 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- System.* -->
<PackageVersion Include="System.Linq.Async" Version="6.0.1" />
<!-- Microsoft.Extensions.* -->
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="9.5.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.5" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.5" />
<!-- Test -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="Moq" Version="[4.18.4]" />
@@ -1,10 +1,259 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents;
/// <summary>
/// Placeholder class.
/// Base abstraction for all agents. An agent instance may participate in one or more conversations.
/// A conversation may include one or more agents.
/// </summary>
public class Agent
public abstract class Agent
{
/// <summary>
/// Gets the identifier of the agent (optional).
/// </summary>
/// <value>
/// The identifier of the agent. The default is a random GUID value, but for service agents, it will match the id of the agent in the service.
/// </value>
public virtual string Id => Guid.NewGuid().ToString();
/// <summary>
/// Gets the name of the agent (optional).
/// </summary>
public virtual string? Name { get; }
/// <summary>
/// Gets the description of the agent (optional).
/// </summary>
public virtual string? Description { get; }
/// <summary>
/// Gets the instructions for the agent (optional).
/// </summary>
public virtual string? Instructions { get; }
/// <summary>
/// Create a new <see cref="AgentThread"/> that is compatible with the agent.
/// </summary>
/// <returns>A new <see cref="AgentThread"/> instance that is in the created state.</returns>
/// <remarks>
/// If an agent supports multiple thread types, this method should return the default thread
/// type for the agent or whatever the agent was configured to use.
/// </remarks>
public abstract AgentThread CreateThreadAsync();
/// <summary>
/// Run the agent with no message assuming that all required instructions are already provided to the agent or on the thread.
/// </summary>
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public virtual Task<ChatResponse> RunAsync(
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
return this.RunAsync((IReadOnlyCollection<ChatMessage>)[], thread, options, cancellationToken);
}
/// <summary>
/// Run the agent with the provided message and arguments.
/// </summary>
/// <param name="message">The message to pass to the agent.</param>
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
/// <remarks>
/// The provided message string will be treated as a user message.
/// </remarks>
public virtual Task<ChatResponse> RunAsync(
string message,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
Throw.IfNullOrWhitespace(message);
return this.RunAsync(new ChatMessage(ChatRole.User, message), thread, options, cancellationToken);
}
/// <summary>
/// Run the agent with the provided message and arguments.
/// </summary>
/// <param name="message">The message to pass to the agent.</param>
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public virtual Task<ChatResponse> RunAsync(
ChatMessage message,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(message);
return this.RunAsync([message], thread, options, cancellationToken);
}
/// <summary>
/// Run the agent with the provided message and arguments.
/// </summary>
/// <param name="messages">The messages to pass to the agent.</param>
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public abstract Task<ChatResponse> RunAsync(
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Run the agent with no message assuming that all required instructions are already provided to the agent or on the thread.
/// </summary>
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An async list of response items that each contain a <see cref="ChatResponseUpdate"/>.</returns>
public virtual IAsyncEnumerable<ChatResponseUpdate> RunStreamingAsync(
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync((IReadOnlyCollection<ChatMessage>)[], thread, options, cancellationToken);
}
/// <summary>
/// Run the agent with the provided message and arguments.
/// </summary>
/// <param name="message">The message to pass to the agent.</param>
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An async list of response items that each contain a <see cref="ChatResponseUpdate"/>.</returns>
/// <remarks>
/// The provided message string will be treated as a user message.
/// </remarks>
public virtual IAsyncEnumerable<ChatResponseUpdate> RunStreamingAsync(
string message,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
Throw.IfNullOrWhitespace(message);
return this.RunStreamingAsync(new ChatMessage(ChatRole.User, message), thread, options, cancellationToken);
}
/// <summary>
/// Run the agent with the provided message and arguments.
/// </summary>
/// <param name="message">The message to pass to the agent.</param>
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An async list of response items that each contain a <see cref="ChatResponseUpdate"/>.</returns>
public virtual IAsyncEnumerable<ChatResponseUpdate> RunStreamingAsync(
ChatMessage message,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(message);
return this.RunStreamingAsync([message], thread, options, cancellationToken);
}
/// <summary>
/// Run the agent with the provided message and arguments.
/// </summary>
/// <param name="messages">The messages to pass to the agent.</param>
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent reponse.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An async list of response items that each contain a <see cref="ChatResponseUpdate"/>.</returns>
public abstract IAsyncEnumerable<ChatResponseUpdate> RunStreamingAsync(
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Ensures that the thread exists, is of the expected type, and is active, plus adds the provided message to the thread.
/// </summary>
/// <typeparam name="TThreadType">The expected type of the thead.</typeparam>
/// <param name="messages">The messages to add to the thread once it is setup.</param>
/// <param name="thread">The thread to create if it's null, validate it's type if not null, and start if it is not active.</param>
/// <param name="constructThread">A callback to use to construct the thread if it's null.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An async task that completes once all update are complete.</returns>
protected virtual async Task<TThreadType> EnsureThreadExistsWithMessagesAsync<TThreadType>(
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread,
Func<TThreadType> constructThread,
CancellationToken cancellationToken)
where TThreadType : AgentThread
{
Throw.IfNull(messages);
thread ??= constructThread is not null ? constructThread() : throw new ArgumentNullException(nameof(constructThread));
if (thread is not TThreadType concreteThreadType)
{
throw new NotSupportedException($"{this.GetType().Name} currently only supports agent threads of type {nameof(TThreadType)}.");
}
// We have to explicitly call create here to ensure that the thread is created
// before we run using the thread. While threads will be created when
// notified of new messages, some agents support invoking without a message,
// and in that case no messages will be sent in the next step.
await thread.CreateAsync(cancellationToken).ConfigureAwait(false);
// Notify the thread that new messages are available.
foreach (var message in messages)
{
await this.NotifyThreadOfNewMessage(thread, message, cancellationToken).ConfigureAwait(false);
}
return concreteThreadType;
}
/// <summary>
/// Notfiy the given thread that a new message is available.
/// </summary>
/// <remarks>
/// <para>
/// Note that while all agents should notify their threads of new messages,
/// not all threads will necessarily take action. For some treads, this may be
/// the only way that they would know that a new message is available to be added
/// to their history.
/// </para>
/// <para>
/// For other thread types, where history is managed by the service, the thread may
/// not need to take any action.
/// </para>
/// <para>
/// Where threads manage other memory components that need access to new messages,
/// notifying the thread will be important, even if the thread itself does not
/// require the message.
/// </para>
/// </remarks>
/// <param name="thread">The thread to notify of the new message.</param>
/// <param name="message">The message to pass to the thread.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An async task that completes once the notification is complete.</returns>
protected Task NotifyThreadOfNewMessage(AgentThread thread, ChatMessage message, CancellationToken cancellationToken)
{
return thread.OnNewMessageAsync(message, cancellationToken);
}
}
@@ -0,0 +1,50 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents;
/// <summary>
/// Optional parameters when running an agent.
/// </summary>
public class AgentRunOptions
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentRunOptions"/> class.
/// </summary>
public AgentRunOptions()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentRunOptions"/> class by cloning the provided options.
/// </summary>
/// <param name="options">The options to clone.</param>
public AgentRunOptions(AgentRunOptions options)
{
Throw.IfNull(options);
this.AdditionalInstructions = options.AdditionalInstructions;
this.OnIntermediateMessage = options.OnIntermediateMessage;
}
/// <summary>
/// Gets or sets any instructions, in addition to those that were provided to the agent
/// initially, that need to be added to the prompt for this invocation only.
/// </summary>
public string? AdditionalInstructions { get; set; } = null;
/// <summary>
/// Gets or sets a function to be called when a complete new message is generated by the agent.
/// </summary>
/// <remarks>
/// <para>
/// This callback is particularly useful in cases where the caller wants to receive complete messages
/// when invoking the agent with streaming.
/// </para>
/// </remarks>
public Func<ChatMessage, Task>? OnIntermediateMessage { get; set; } = null;
}
@@ -0,0 +1,123 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents;
/// <summary>
/// Base abstraction for all agent threads.
/// A thread represents a specific conversation with an agent.
/// </summary>
/// <remarks>
/// This class is used to manage the lifecycle of an agent thread.
/// The thread can be not-start, started or ended.
/// </remarks>
public abstract class AgentThread
{
/// <summary>
/// Gets the id of the current thread.
/// </summary>
public string? Id { get; protected set; }
/// <summary>
/// Gets a value indicating whether the thread has been deleted.
/// </summary>
public bool IsDeleted { get; protected set; } = false;
/// <summary>
/// Creates the thread and returns the thread id.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that completes when the thread has been created.</returns>
/// <exception cref="InvalidOperationException">The thread has been deleted.</exception>
internal async Task CreateAsync(CancellationToken cancellationToken = default)
{
if (this.IsDeleted)
{
throw new InvalidOperationException("This thread has been deleted and cannot be recreated.");
}
if (this.Id is not null)
{
return;
}
this.Id = await this.CreateCoreAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Deletes the current thread.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that completes when the thread has been deleted.</returns>
/// <exception cref="InvalidOperationException">The thread was never created.</exception>
public async Task DeleteAsync(CancellationToken cancellationToken = default)
{
if (this.IsDeleted)
{
return;
}
if (this.Id is null)
{
throw new InvalidOperationException("This thread cannot be deleted, since it has not been created.");
}
await this.DeleteCoreAsync(cancellationToken).ConfigureAwait(false);
this.IsDeleted = true;
}
/// <summary>
/// This method is called when a new message has been contributed to the chat by any participant.
/// </summary>
/// <remarks>
/// Inheritors can use this method to update their context based on the new message.
/// </remarks>
/// <param name="newMessage">The new message.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that completes when the context has been updated.</returns>
/// <exception cref="InvalidOperationException">The thread has been deleted.</exception>
internal async Task OnNewMessageAsync(ChatMessage newMessage, CancellationToken cancellationToken = default)
{
if (this.IsDeleted)
{
throw new InvalidOperationException("This thread has been deleted and cannot be used anymore.");
}
if (this.Id is null)
{
await this.CreateAsync(cancellationToken).ConfigureAwait(false);
}
await this.OnNewMessageCoreAsync(newMessage, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Creates the thread and returns the thread id.
/// Checks have already been completed in the <see cref="CreateAsync"/> method to ensure that the thread can be created.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The id of the thread that was created if one is available.</returns>
protected abstract Task<string?> CreateCoreAsync(CancellationToken cancellationToken);
/// <summary>
/// Deletes the current thread.
/// Checks have already been completed in the <see cref="DeleteAsync"/> method to ensure that the thread can be deleted.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that completes when the thread has been deleted.</returns>
protected abstract Task DeleteCoreAsync(CancellationToken cancellationToken);
/// <summary>
/// This method is called when a new message has been contributed to the chat by any participant.
/// Checks have already been completed in the <see cref="OnNewMessageAsync"/> method to ensure that the thread can be updated.
/// </summary>
/// <param name="newMessage">The new message.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that completes when the context has been updated.</returns>
protected abstract Task OnNewMessageCoreAsync(ChatMessage newMessage, CancellationToken cancellationToken = default);
}
@@ -22,4 +22,13 @@
<Description>Contains the Microsoft Agent Framework interfaces and abstractions.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.Abstractions.UnitTests" />
</ItemGroup>
</Project>
+1
View File
@@ -16,6 +16,7 @@
<PackageReference Include="Moq" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="System.Linq.Async" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,37 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
namespace Microsoft.Agents.Abstractions.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AgentRunOptions"/> class.
/// </summary>
public class AgentRunOptionsTests
{
[Fact]
public void CloningConstructorCopiesProperties()
{
// Arrange
var options = new AgentRunOptions
{
AdditionalInstructions = "Test instructions",
OnIntermediateMessage = msg => Task.CompletedTask
};
// Act
var clone = new AgentRunOptions(options);
// Assert
Assert.Equal(options.AdditionalInstructions, clone.AdditionalInstructions);
Assert.Equal(options.OnIntermediateMessage, clone.OnIntermediateMessage);
}
[Fact]
public void CloningConstructorThrowsIfNull()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => new AgentRunOptions(null!));
}
}
@@ -0,0 +1,277 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
using Moq.Protected;
namespace Microsoft.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 ChatResponse _invokeResponse = new();
private readonly List<ChatResponseUpdate> _invokeStreamingResponses = new();
/// <summary>
/// Initializes a new instance of the <see cref="AgentTests"/> class.
/// </summary>
public AgentTests()
{
this._agentThreadMock = new Mock<AgentThread>(MockBehavior.Strict);
this._invokeResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hi"));
this._invokeStreamingResponses.Add(new ChatResponseUpdate(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 async Task EnsureThreadExistsWithMessagesVerifiesAndCreatesThreadAndNotifiesThreadAsync()
{
var messages = new[] { new ChatMessage(ChatRole.User, "msg1"), new ChatMessage(ChatRole.User, "msg2") };
var cancellationToken = new CancellationToken();
// Custom thread type for type checking
var threadMock = new Mock<TestAgentThread>() { CallBase = true };
var agent = new MockAgent();
// Should create and notify
var result = await agent.EnsureThreadExistsWithMessagesAsync<TestAgentThread>(messages, null, () => threadMock.Object, cancellationToken);
Assert.Same(threadMock.Object, result);
threadMock.Protected().Verify("OnNewMessageCoreAsync", Times.Once(), messages[0], CancellationToken.None);
threadMock.Protected().Verify("OnNewMessageCoreAsync", Times.Once(), messages[1], CancellationToken.None);
// Should throw if wrong type
var wrongThread = new Mock<AgentThread>().Object;
await Assert.ThrowsAsync<NotSupportedException>(() => agent.EnsureThreadExistsWithMessagesAsync<TestAgentThread>(messages, wrongThread, () => threadMock.Object, cancellationToken));
}
/// <summary>
/// Typed mock thread.
/// </summary>
public abstract class TestAgentThread : AgentThread
{
}
/// <summary>
/// Mock class to test the <see cref="Agent.EnsureThreadExistsWithMessagesAsync{TThreadType}"/> method.
/// </summary>
private sealed class MockAgent : Agent
{
public new Task<TThreadType> EnsureThreadExistsWithMessagesAsync<TThreadType>(
IReadOnlyCollection<ChatMessage> messages,
AgentThread? thread,
Func<TThreadType> constructThread,
CancellationToken cancellationToken)
where TThreadType : AgentThread
{
return base.EnsureThreadExistsWithMessagesAsync<TThreadType>(
messages,
thread,
constructThread,
cancellationToken);
}
public override AgentThread CreateThreadAsync()
{
throw new System.NotImplementedException();
}
public override Task<ChatResponse> RunAsync(IReadOnlyCollection<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
throw new System.NotImplementedException();
}
public override IAsyncEnumerable<ChatResponseUpdate> RunStreamingAsync(IReadOnlyCollection<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
throw new System.NotImplementedException();
}
}
}
@@ -0,0 +1,171 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.Abstractions.UnitTests;
/// <summary>
/// Contains tests for the <see cref="AgentThread"/> class.
/// </summary>
public class AgentThreadTests
{
/// <summary>
/// Tests that the CreateAsync method sets the Id and invokes CreateInternalAsync once.
/// </summary>
[Fact]
public async Task CreateShouldSetIdAndInvokeCreateInternalOnceAsync()
{
// Arrange
var thread = new TestAgentThread();
// Act
await thread.CreateAsync();
await thread.CreateAsync();
// Assert
Assert.Equal("test-thread-id", thread.Id);
Assert.Equal(1, thread.CreateInternalAsyncCount);
}
/// <summary>
/// Tests that the CreateAsync method throws an InvalidOperationException if the thread is deleted.
/// </summary>
[Fact]
public async Task CreateShouldThrowIfThreadDeletedAsync()
{
// Arrange
var thread = new TestAgentThread();
await thread.CreateAsync();
await thread.DeleteAsync();
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => thread.CreateAsync());
Assert.Equal(1, thread.CreateInternalAsyncCount);
Assert.Equal(1, thread.DeleteInternalAsyncCount);
}
/// <summary>
/// Tests that the DeleteAsync method sets IsDeleted and invokes DeleteInternalAsync.
/// </summary>
[Fact]
public async Task DeleteShouldSetIsDeletedAndInvokeDeleteInternalAsync()
{
// Arrange
var thread = new TestAgentThread();
await thread.CreateAsync();
// Act
await thread.DeleteAsync();
// Assert
Assert.True(thread.IsDeleted);
Assert.Equal(1, thread.CreateInternalAsyncCount);
Assert.Equal(1, thread.DeleteInternalAsyncCount);
}
/// <summary>
/// Tests that the DeleteAsync method does not invoke DeleteInternalAsync if the thread is already deleted.
/// </summary>
[Fact]
public async Task DeleteShouldNotInvokeDeleteInternalIfAlreadyDeletedAsync()
{
// Arrange
var thread = new TestAgentThread();
await thread.CreateAsync();
await thread.DeleteAsync();
// Act
await thread.DeleteAsync();
// Assert
Assert.True(thread.IsDeleted);
Assert.Equal(1, thread.CreateInternalAsyncCount);
Assert.Equal(1, thread.DeleteInternalAsyncCount);
}
/// <summary>
/// Tests that the DeleteAsync method throws an InvalidOperationException if the thread was never created.
/// </summary>
[Fact]
public async Task DeleteShouldThrowIfNeverCreatedAsync()
{
// Arrange
var thread = new TestAgentThread();
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => thread.DeleteAsync());
Assert.Equal(0, thread.CreateInternalAsyncCount);
Assert.Equal(0, thread.DeleteInternalAsyncCount);
}
/// <summary>
/// Tests that the OnNewMessageAsync method creates the thread if it is not already created.
/// </summary>
[Fact]
public async Task OnNewMessageShouldCreateThreadIfNotCreatedAsync()
{
// Arrange
var thread = new TestAgentThread();
var message = new ChatMessage();
// Act
await thread.OnNewMessageAsync(message);
// Assert
Assert.Equal("test-thread-id", thread.Id);
Assert.Equal(1, thread.CreateInternalAsyncCount);
Assert.Equal(1, thread.OnNewMessageInternalAsyncCount);
}
/// <summary>
/// Tests that the OnNewMessageAsync method throws an InvalidOperationException if the thread is deleted.
/// </summary>
[Fact]
public async Task OnNewMessageShouldThrowIfThreadDeletedAsync()
{
// Arrange
var thread = new TestAgentThread();
await thread.CreateAsync();
await thread.DeleteAsync();
var message = new ChatMessage();
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => thread.OnNewMessageAsync(message));
Assert.Equal(1, thread.CreateInternalAsyncCount);
Assert.Equal(1, thread.DeleteInternalAsyncCount);
Assert.Equal(0, thread.OnNewMessageInternalAsyncCount);
}
private sealed class TestAgentThread : AgentThread
{
public int CreateInternalAsyncCount { get; private set; }
public int DeleteInternalAsyncCount { get; private set; }
public int OnNewMessageInternalAsyncCount { get; private set; }
public new Task CreateAsync(CancellationToken cancellationToken = default)
{
return base.CreateAsync(cancellationToken);
}
protected override Task<string?> CreateCoreAsync(CancellationToken cancellationToken)
{
this.CreateInternalAsyncCount++;
return Task.FromResult<string?>("test-thread-id");
}
protected override Task DeleteCoreAsync(CancellationToken cancellationToken)
{
this.DeleteInternalAsyncCount++;
return Task.CompletedTask;
}
protected override Task OnNewMessageCoreAsync(ChatMessage newMessage, CancellationToken cancellationToken = default)
{
this.OnNewMessageInternalAsyncCount++;
return Task.CompletedTask;
}
}
}
@@ -1,14 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.Abstractions.Tests;
/// <summary>
/// Placeholder.
/// </summary>
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}