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()
- {
- }
-}