From af2295f1303671a95846a9d08eb1fd966deb78f1 Mon Sep 17 00:00:00 2001 From: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com> Date: Mon, 9 Jun 2025 03:35:41 -0700 Subject: [PATCH] .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> --- .github/workflows/dotnet-check-coverage.ps1 | 19 +- dotnet/Directory.Packages.props | 3 + .../Microsoft.Agents.Abstractions/Agent.cs | 253 +++++++++++++++- .../AgentRunOptions.cs | 50 ++++ .../AgentThread.cs | 123 ++++++++ .../Microsoft.Agents.Abstractions.csproj | 9 + dotnet/tests/Directory.Build.props | 1 + .../AgentRunOptionsTests.cs | 37 +++ .../AgentTests.cs | 277 ++++++++++++++++++ .../AgentThreadTests.cs | 171 +++++++++++ .../UnitTest1.cs | 14 - 11 files changed, 937 insertions(+), 20 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.Abstractions/AgentRunOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.Abstractions/AgentThread.cs create mode 100644 dotnet/tests/Microsoft.Agents.Abstractions.UnitTests/AgentRunOptionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.Abstractions.UnitTests/AgentTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.Abstractions.UnitTests/AgentThreadTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.Abstractions.UnitTests/UnitTest1.cs diff --git a/.github/workflows/dotnet-check-coverage.ps1 b/.github/workflows/dotnet-check-coverage.ps1 index 971da675ae..e2ded6765d 100644 --- a/.github/workflows/dotnet-check-coverage.ps1 +++ b/.github/workflows/dotnet-check-coverage.ps1 @@ -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 } } diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index d863e4cbc3..7a93f91552 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -5,10 +5,13 @@ true + + + diff --git a/dotnet/src/Microsoft.Agents.Abstractions/Agent.cs b/dotnet/src/Microsoft.Agents.Abstractions/Agent.cs index f028d80468..7f2a17b23c 100644 --- a/dotnet/src/Microsoft.Agents.Abstractions/Agent.cs +++ b/dotnet/src/Microsoft.Agents.Abstractions/Agent.cs @@ -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; /// -/// 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. /// -public class Agent +public abstract class Agent { + /// + /// Gets the identifier of the agent (optional). + /// + /// + /// 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. + /// + public virtual string Id => Guid.NewGuid().ToString(); + + /// + /// Gets the name of the agent (optional). + /// + public virtual string? Name { get; } + + /// + /// Gets the description of the agent (optional). + /// + public virtual string? Description { get; } + + /// + /// Gets the instructions for the agent (optional). + /// + public virtual string? Instructions { get; } + + /// + /// Create a new that is compatible with the agent. + /// + /// A new instance that is in the created state. + /// + /// 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. + /// + public abstract AgentThread CreateThreadAsync(); + + /// + /// Run the agent with no message assuming that all required instructions are already provided to the agent or on the 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. + /// Optional parameters for agent invocation. + /// The to monitor for cancellation requests. The default is . + /// A containing the list of items. + public virtual Task RunAsync( + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + return this.RunAsync((IReadOnlyCollection)[], thread, options, cancellationToken); + } + + /// + /// Run the agent with the provided message and arguments. + /// + /// The message to pass to the agent. + /// 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. + /// Optional parameters for agent invocation. + /// The to monitor for cancellation requests. The default is . + /// A containing the list of items. + /// + /// The provided message string will be treated as a user message. + /// + public virtual Task 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); + } + + /// + /// Run the agent with the provided message and arguments. + /// + /// The message to pass to the agent. + /// 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. + /// Optional parameters for agent invocation. + /// The to monitor for cancellation requests. The default is . + /// A containing the list of items. + public virtual Task RunAsync( + ChatMessage message, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + Throw.IfNull(message); + + return this.RunAsync([message], thread, options, cancellationToken); + } + + /// + /// Run the agent with the provided message and arguments. + /// + /// The messages to pass to the agent. + /// 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. + /// Optional parameters for agent invocation. + /// The to monitor for cancellation requests. The default is . + /// A containing the list of items. + public abstract Task RunAsync( + IReadOnlyCollection messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default); + + /// + /// Run the agent with no message assuming that all required instructions are already provided to the agent or on the 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. + /// Optional parameters for agent invocation. + /// The to monitor for cancellation requests. The default is . + /// An async list of response items that each contain a . + public virtual IAsyncEnumerable RunStreamingAsync( + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + return this.RunStreamingAsync((IReadOnlyCollection)[], thread, options, cancellationToken); + } + + /// + /// Run the agent with the provided message and arguments. + /// + /// The message to pass to the agent. + /// 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. + /// Optional parameters for agent invocation. + /// The to monitor for cancellation requests. The default is . + /// An async list of response items that each contain a . + /// + /// The provided message string will be treated as a user message. + /// + public virtual IAsyncEnumerable 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); + } + + /// + /// Run the agent with the provided message and arguments. + /// + /// The message to pass to the agent. + /// 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. + /// Optional parameters for agent invocation. + /// The to monitor for cancellation requests. The default is . + /// An async list of response items that each contain a . + public virtual IAsyncEnumerable RunStreamingAsync( + ChatMessage message, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + Throw.IfNull(message); + + return this.RunStreamingAsync([message], thread, options, cancellationToken); + } + + /// + /// Run the agent with the provided message and arguments. + /// + /// The messages to pass to the agent. + /// 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. + /// Optional parameters for agent invocation. + /// The to monitor for cancellation requests. The default is . + /// An async list of response items that each contain a . + public abstract IAsyncEnumerable RunStreamingAsync( + IReadOnlyCollection messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default); + + /// + /// Ensures that the thread exists, is of the expected type, and is active, plus adds the provided message to the thread. + /// + /// The expected type of the thead. + /// The messages to add to the thread once it is setup. + /// The thread to create if it's null, validate it's type if not null, and start if it is not active. + /// A callback to use to construct the thread if it's null. + /// The to monitor for cancellation requests. The default is . + /// An async task that completes once all update are complete. + protected virtual async Task EnsureThreadExistsWithMessagesAsync( + IReadOnlyCollection messages, + AgentThread? thread, + Func 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; + } + + /// + /// Notfiy the given thread that a new message is available. + /// + /// + /// + /// 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. + /// + /// + /// For other thread types, where history is managed by the service, the thread may + /// not need to take any action. + /// + /// + /// 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. + /// + /// + /// The thread to notify of the new message. + /// The message to pass to the thread. + /// The to monitor for cancellation requests. The default is . + /// An async task that completes once the notification is complete. + protected Task NotifyThreadOfNewMessage(AgentThread thread, ChatMessage message, CancellationToken cancellationToken) + { + return thread.OnNewMessageAsync(message, cancellationToken); + } } diff --git a/dotnet/src/Microsoft.Agents.Abstractions/AgentRunOptions.cs b/dotnet/src/Microsoft.Agents.Abstractions/AgentRunOptions.cs new file mode 100644 index 0000000000..68872d1749 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Abstractions/AgentRunOptions.cs @@ -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; + +/// +/// Optional parameters when running an agent. +/// +public class AgentRunOptions +{ + /// + /// Initializes a new instance of the class. + /// + public AgentRunOptions() + { + } + + /// + /// Initializes a new instance of the class by cloning the provided options. + /// + /// The options to clone. + public AgentRunOptions(AgentRunOptions options) + { + Throw.IfNull(options); + + this.AdditionalInstructions = options.AdditionalInstructions; + this.OnIntermediateMessage = options.OnIntermediateMessage; + } + + /// + /// 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. + /// + public string? AdditionalInstructions { get; set; } = null; + + /// + /// Gets or sets a function to be called when a complete new message is generated by the agent. + /// + /// + /// + /// This callback is particularly useful in cases where the caller wants to receive complete messages + /// when invoking the agent with streaming. + /// + /// + public Func? OnIntermediateMessage { get; set; } = null; +} diff --git a/dotnet/src/Microsoft.Agents.Abstractions/AgentThread.cs b/dotnet/src/Microsoft.Agents.Abstractions/AgentThread.cs new file mode 100644 index 0000000000..36a6516292 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.Abstractions/AgentThread.cs @@ -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; + +/// +/// Base abstraction for all agent threads. +/// A thread represents a specific conversation with an agent. +/// +/// +/// This class is used to manage the lifecycle of an agent thread. +/// The thread can be not-start, started or ended. +/// +public abstract class AgentThread +{ + /// + /// Gets the id of the current thread. + /// + public string? Id { get; protected set; } + + /// + /// Gets a value indicating whether the thread has been deleted. + /// + public bool IsDeleted { get; protected set; } = false; + + /// + /// Creates the thread and returns the thread id. + /// + /// The to monitor for cancellation requests. The default is . + /// A task that completes when the thread has been created. + /// The thread has been deleted. + 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); + } + + /// + /// Deletes the current thread. + /// + /// The to monitor for cancellation requests. The default is . + /// A task that completes when the thread has been deleted. + /// The thread was never created. + 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; + } + + /// + /// This method is called when a new message has been contributed to the chat by any participant. + /// + /// + /// Inheritors can use this method to update their context based on the new message. + /// + /// The new message. + /// The to monitor for cancellation requests. The default is . + /// A task that completes when the context has been updated. + /// The thread has been deleted. + 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); + } + + /// + /// Creates the thread and returns the thread id. + /// Checks have already been completed in the method to ensure that the thread can be created. + /// + /// The to monitor for cancellation requests. The default is . + /// The id of the thread that was created if one is available. + protected abstract Task CreateCoreAsync(CancellationToken cancellationToken); + + /// + /// Deletes the current thread. + /// Checks have already been completed in the method to ensure that the thread can be deleted. + /// + /// The to monitor for cancellation requests. The default is . + /// A task that completes when the thread has been deleted. + protected abstract Task DeleteCoreAsync(CancellationToken cancellationToken); + + /// + /// This method is called when a new message has been contributed to the chat by any participant. + /// Checks have already been completed in the method to ensure that the thread can be updated. + /// + /// The new message. + /// The to monitor for cancellation requests. The default is . + /// A task that completes when the context has been updated. + protected abstract Task OnNewMessageCoreAsync(ChatMessage newMessage, CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.Abstractions/Microsoft.Agents.Abstractions.csproj b/dotnet/src/Microsoft.Agents.Abstractions/Microsoft.Agents.Abstractions.csproj index 32d1a766b6..ea7e91804b 100644 --- a/dotnet/src/Microsoft.Agents.Abstractions/Microsoft.Agents.Abstractions.csproj +++ b/dotnet/src/Microsoft.Agents.Abstractions/Microsoft.Agents.Abstractions.csproj @@ -22,4 +22,13 @@ Contains the Microsoft Agent Framework interfaces and abstractions. + + + + + + + + + diff --git a/dotnet/tests/Directory.Build.props b/dotnet/tests/Directory.Build.props index 1847b29642..c54e3c39e5 100644 --- a/dotnet/tests/Directory.Build.props +++ b/dotnet/tests/Directory.Build.props @@ -16,6 +16,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.Abstractions.UnitTests/AgentRunOptionsTests.cs b/dotnet/tests/Microsoft.Agents.Abstractions.UnitTests/AgentRunOptionsTests.cs new file mode 100644 index 0000000000..df24cc0ef5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Abstractions.UnitTests/AgentRunOptionsTests.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; + +namespace Microsoft.Agents.Abstractions.UnitTests; + +/// +/// Unit tests for the class. +/// +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(() => new AgentRunOptions(null!)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.Abstractions.UnitTests/AgentTests.cs b/dotnet/tests/Microsoft.Agents.Abstractions.UnitTests/AgentTests.cs new file mode 100644 index 0000000000..5f51c5beb7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Abstractions.UnitTests/AgentTests.cs @@ -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; + +/// +/// Unit tests for the class. +/// +public class AgentTests +{ + private readonly Mock _agentMock; + private readonly Mock _agentThreadMock; + private readonly ChatResponse _invokeResponse = new(); + private readonly List _invokeStreamingResponses = new(); + + /// + /// Initializes a new instance of the class. + /// + public AgentTests() + { + this._agentThreadMock = new Mock(MockBehavior.Strict); + + this._invokeResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hi")); + this._invokeStreamingResponses.Add(new ChatResponseUpdate(ChatRole.Assistant, "Hi")); + + this._agentMock = new Mock() { CallBase = true }; + this._agentMock + .Setup(x => x.RunAsync( + It.IsAny>(), + this._agentThreadMock.Object, + It.IsAny(), + It.IsAny())) + .ReturnsAsync(this._invokeResponse); + this._agentMock + .Setup(x => x.RunStreamingAsync( + It.IsAny>(), + this._agentThreadMock.Object, + It.IsAny(), + It.IsAny())) + .Returns(this._invokeStreamingResponses.ToAsyncEnumerable()); + } + + /// + /// 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 = 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>(messages => messages.Count == 0), + this._agentThreadMock.Object, + options, + cancellationToken), + Times.Once); + } + + /// + /// 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 + 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>(messages => messages.Count == 1 && messages.First().Text == message), + this._agentThreadMock.Object, + options, + cancellationToken), + Times.Once); + } + + /// + /// 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 = 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>(messages => messages.Count == 1 && messages.First() == message), + this._agentThreadMock.Object, + options, + cancellationToken), + Times.Once); + } + + /// + /// 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 = 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>(messages => messages.Count == 0), + this._agentThreadMock.Object, + options, + cancellationToken), + Times.Once); + } + + /// + /// 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 + 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>(messages => messages.Count == 1 && messages.First().Text == message), + this._agentThreadMock.Object, + options, + cancellationToken), + Times.Once); + } + + /// + /// 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 = 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>(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() { CallBase = true }; + + var agent = new MockAgent(); + + // Should create and notify + var result = await agent.EnsureThreadExistsWithMessagesAsync(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().Object; + await Assert.ThrowsAsync(() => agent.EnsureThreadExistsWithMessagesAsync(messages, wrongThread, () => threadMock.Object, cancellationToken)); + } + + /// + /// Typed mock thread. + /// + public abstract class TestAgentThread : AgentThread + { + } + + /// + /// Mock class to test the method. + /// + private sealed class MockAgent : Agent + { + public new Task EnsureThreadExistsWithMessagesAsync( + IReadOnlyCollection messages, + AgentThread? thread, + Func constructThread, + CancellationToken cancellationToken) + where TThreadType : AgentThread + { + return base.EnsureThreadExistsWithMessagesAsync( + messages, + thread, + constructThread, + cancellationToken); + } + + public override AgentThread CreateThreadAsync() + { + throw new System.NotImplementedException(); + } + + public override Task RunAsync(IReadOnlyCollection messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + throw new System.NotImplementedException(); + } + + public override IAsyncEnumerable RunStreamingAsync(IReadOnlyCollection messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + throw new System.NotImplementedException(); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.Abstractions.UnitTests/AgentThreadTests.cs b/dotnet/tests/Microsoft.Agents.Abstractions.UnitTests/AgentThreadTests.cs new file mode 100644 index 0000000000..331a6d6604 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.Abstractions.UnitTests/AgentThreadTests.cs @@ -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; + +/// +/// Contains tests for the class. +/// +public class AgentThreadTests +{ + /// + /// Tests that the CreateAsync method sets the Id and invokes CreateInternalAsync once. + /// + [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); + } + + /// + /// Tests that the CreateAsync method throws an InvalidOperationException if the thread is deleted. + /// + [Fact] + public async Task CreateShouldThrowIfThreadDeletedAsync() + { + // Arrange + var thread = new TestAgentThread(); + await thread.CreateAsync(); + await thread.DeleteAsync(); + + // Act & Assert + await Assert.ThrowsAsync(() => thread.CreateAsync()); + Assert.Equal(1, thread.CreateInternalAsyncCount); + Assert.Equal(1, thread.DeleteInternalAsyncCount); + } + + /// + /// Tests that the DeleteAsync method sets IsDeleted and invokes DeleteInternalAsync. + /// + [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); + } + + /// + /// Tests that the DeleteAsync method does not invoke DeleteInternalAsync if the thread is already deleted. + /// + [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); + } + + /// + /// Tests that the DeleteAsync method throws an InvalidOperationException if the thread was never created. + /// + [Fact] + public async Task DeleteShouldThrowIfNeverCreatedAsync() + { + // Arrange + var thread = new TestAgentThread(); + + // Act & Assert + await Assert.ThrowsAsync(() => thread.DeleteAsync()); + Assert.Equal(0, thread.CreateInternalAsyncCount); + Assert.Equal(0, thread.DeleteInternalAsyncCount); + } + + /// + /// Tests that the OnNewMessageAsync method creates the thread if it is not already created. + /// + [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); + } + + /// + /// Tests that the OnNewMessageAsync method throws an InvalidOperationException if the thread is deleted. + /// + [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(() => 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 CreateCoreAsync(CancellationToken cancellationToken) + { + this.CreateInternalAsyncCount++; + return Task.FromResult("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; + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.Abstractions.UnitTests/UnitTest1.cs b/dotnet/tests/Microsoft.Agents.Abstractions.UnitTests/UnitTest1.cs deleted file mode 100644 index b5b95f77dc..0000000000 --- a/dotnet/tests/Microsoft.Agents.Abstractions.UnitTests/UnitTest1.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.Abstractions.Tests; - -/// -/// Placeholder. -/// -public class UnitTest1 -{ - [Fact] - public void Test1() - { - } -}