From bef10d0fb39cd08bca8d01ee1c6e790c3e1e719b Mon Sep 17 00:00:00 2001
From: westey <164392973+westey-m@users.noreply.github.com>
Date: Sat, 22 Mar 2025 15:34:53 +0000
Subject: [PATCH 1/3] Add unit tests for AgentThread and ChatHistoryAgentThread
---
.../Agents/UnitTests/Core/AgentThreadTests.cs | 168 ++++++++++++++++++
.../Core/ChatHistoryAgentThreadTests.cs | 126 +++++++++++++
2 files changed, 294 insertions(+)
create mode 100644 dotnet/src/Agents/UnitTests/Core/AgentThreadTests.cs
create mode 100644 dotnet/src/Agents/UnitTests/Core/ChatHistoryAgentThreadTests.cs
diff --git a/dotnet/src/Agents/UnitTests/Core/AgentThreadTests.cs b/dotnet/src/Agents/UnitTests/Core/AgentThreadTests.cs
new file mode 100644
index 000000000000..5a9d483d9f32
--- /dev/null
+++ b/dotnet/src/Agents/UnitTests/Core/AgentThreadTests.cs
@@ -0,0 +1,168 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.SemanticKernel;
+using Microsoft.SemanticKernel.Agents;
+using Xunit;
+
+namespace SemanticKernel.Agents.UnitTests.Core;
+
+///
+/// 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 ChatMessageContent();
+
+ // 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 ChatMessageContent();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => thread.OnNewMessageAsync(message));
+ Assert.Equal(1, thread.CreateInternalAsyncCount);
+ Assert.Equal(1, thread.DeleteInternalAsyncCount);
+ Assert.Equal(0, thread.OnNewMessageInternalAsyncCount);
+ }
+
+ private class TestAgentThread : AgentThread
+ {
+ public int CreateInternalAsyncCount { get; private set; }
+ public int DeleteInternalAsyncCount { get; private set; }
+ public int OnNewMessageInternalAsyncCount { get; private set; }
+
+ protected override Task CreateInternalAsync(CancellationToken cancellationToken)
+ {
+ this.CreateInternalAsyncCount++;
+ return Task.FromResult("test-thread-id");
+ }
+
+ protected override Task DeleteInternalAsync(CancellationToken cancellationToken)
+ {
+ this.DeleteInternalAsyncCount++;
+ return Task.CompletedTask;
+ }
+
+ protected override Task OnNewMessageInternalAsync(ChatMessageContent newMessage, CancellationToken cancellationToken = default)
+ {
+ this.OnNewMessageInternalAsyncCount++;
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/dotnet/src/Agents/UnitTests/Core/ChatHistoryAgentThreadTests.cs b/dotnet/src/Agents/UnitTests/Core/ChatHistoryAgentThreadTests.cs
new file mode 100644
index 000000000000..c5fa720adc50
--- /dev/null
+++ b/dotnet/src/Agents/UnitTests/Core/ChatHistoryAgentThreadTests.cs
@@ -0,0 +1,126 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.SemanticKernel;
+using Microsoft.SemanticKernel.Agents;
+using Microsoft.SemanticKernel.ChatCompletion;
+using Xunit;
+
+namespace SemanticKernel.Agents.UnitTests.Core;
+
+///
+/// Contains tests for the class.
+///
+public class ChatHistoryAgentThreadTests
+{
+ ///
+ /// Tests that creating a thread generates a unique Id and doesn't change IsDeleted.
+ ///
+ [Fact]
+ public async Task CreateShouldGenerateIdAsync()
+ {
+ // Arrange
+ var thread = new ChatHistoryAgentThread();
+
+ // Act
+ await thread.CreateAsync();
+
+ // Assert
+ Assert.NotNull(thread.Id);
+ Assert.False(thread.IsDeleted);
+ }
+
+ ///
+ /// Tests that deleting a thread marks it as deleted.
+ ///
+ [Fact]
+ public async Task DeleteShouldMarkThreadAsDeletedAsync()
+ {
+ // Arrange
+ var thread = new ChatHistoryAgentThread();
+ await thread.CreateAsync();
+
+ // Act
+ await thread.DeleteAsync();
+
+ // Assert
+ Assert.True(thread.IsDeleted);
+ }
+
+ ///
+ /// Tests that adding a new message to the thread adds it to the message history.
+ ///
+ [Fact]
+ public async Task OnNewMessageShouldAddMessageToHistoryAsync()
+ {
+ // Arrange
+ var thread = new ChatHistoryAgentThread();
+ var message = new ChatMessageContent(AuthorRole.User, "Hello");
+
+ // Act
+ await thread.OnNewMessageAsync(message);
+
+ // Assert
+ var messages = await thread.GetMessagesAsync().ToListAsync();
+ Assert.Single(messages);
+ Assert.Equal("Hello", messages[0].Content);
+ }
+
+ ///
+ /// Tests that GetMessagesAsync returns all messages in the thread.
+ ///
+ [Fact]
+ public async Task GetMessagesShouldReturnAllMessagesAsync()
+ {
+ // Arrange
+ var thread = new ChatHistoryAgentThread();
+ var message1 = new ChatMessageContent(AuthorRole.User, "Hello");
+ var message2 = new ChatMessageContent(AuthorRole.Assistant, "Hi there");
+
+ await thread.OnNewMessageAsync(message1);
+ await thread.OnNewMessageAsync(message2);
+
+ // Act
+ var messages = await thread.GetMessagesAsync().ToListAsync();
+
+ // Assert
+ Assert.Equal(2, messages.Count);
+ Assert.Equal("Hello", messages[0].Content);
+ Assert.Equal("Hi there", messages[1].Content);
+ }
+
+ ///
+ /// Tests that GetMessagesAsync throws an InvalidOperationException if the thread is deleted.
+ ///
+ [Fact]
+ public async Task GetMessagesShouldThrowIfThreadIsDeletedAsync()
+ {
+ // Arrange
+ var thread = new ChatHistoryAgentThread();
+ await thread.CreateAsync();
+ await thread.DeleteAsync();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(async () => await thread.GetMessagesAsync().ToListAsync());
+ }
+
+ ///
+ /// Tests that GetMessagesAsync creates the thread if it has not been created yet.
+ ///
+ [Fact]
+ public async Task GetMessagesShouldCreateThreadIfNotCreatedAsync()
+ {
+ // Arrange
+ var thread = new ChatHistoryAgentThread();
+
+ // Act
+ var messages = await thread.GetMessagesAsync().ToListAsync();
+
+ // Assert
+ Assert.NotNull(thread.Id);
+ Assert.False(thread.IsDeleted);
+ Assert.Empty(messages);
+ }
+}
From 70993a6b6cde66025f02479ff6af21dd53ce01c0 Mon Sep 17 00:00:00 2001
From: westey <164392973+westey-m@users.noreply.github.com>
Date: Sat, 22 Mar 2025 15:38:08 +0000
Subject: [PATCH 2/3] Seal test agent thread.
---
dotnet/src/Agents/UnitTests/Core/AgentThreadTests.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dotnet/src/Agents/UnitTests/Core/AgentThreadTests.cs b/dotnet/src/Agents/UnitTests/Core/AgentThreadTests.cs
index 5a9d483d9f32..0f22756037bb 100644
--- a/dotnet/src/Agents/UnitTests/Core/AgentThreadTests.cs
+++ b/dotnet/src/Agents/UnitTests/Core/AgentThreadTests.cs
@@ -141,7 +141,7 @@ public async Task OnNewMessageShouldThrowIfThreadDeletedAsync()
Assert.Equal(0, thread.OnNewMessageInternalAsyncCount);
}
- private class TestAgentThread : AgentThread
+ private sealed class TestAgentThread : AgentThread
{
public int CreateInternalAsyncCount { get; private set; }
public int DeleteInternalAsyncCount { get; private set; }
From 8aea07401c48d15efc88e29efded0c231eb34b40 Mon Sep 17 00:00:00 2001
From: westey <164392973+westey-m@users.noreply.github.com>
Date: Sat, 22 Mar 2025 16:11:15 +0000
Subject: [PATCH 3/3] Add unit tests for the OpenAIAssistantAgentThread
---
.../OpenAI/OpenAIAssistantAgentThreadTests.cs | 123 ++++++++++++++++++
1 file changed, 123 insertions(+)
create mode 100644 dotnet/src/Agents/UnitTests/OpenAI/OpenAIAssistantAgentThreadTests.cs
diff --git a/dotnet/src/Agents/UnitTests/OpenAI/OpenAIAssistantAgentThreadTests.cs b/dotnet/src/Agents/UnitTests/OpenAI/OpenAIAssistantAgentThreadTests.cs
new file mode 100644
index 000000000000..b6443fc05845
--- /dev/null
+++ b/dotnet/src/Agents/UnitTests/OpenAI/OpenAIAssistantAgentThreadTests.cs
@@ -0,0 +1,123 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.ClientModel;
+using System.Net;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Microsoft.SemanticKernel.Agents.OpenAI;
+using Moq;
+using OpenAI.Assistants;
+using Xunit;
+
+namespace SemanticKernel.Agents.UnitTests.OpenAI;
+
+///
+/// Tests for the class.
+///
+public class OpenAIAssistantAgentThreadTests : IDisposable
+{
+ private readonly HttpMessageHandlerStub _messageHandlerStub;
+ private readonly HttpClient _httpClient;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public OpenAIAssistantAgentThreadTests()
+ {
+ this._messageHandlerStub = new HttpMessageHandlerStub();
+ this._httpClient = new HttpClient(this._messageHandlerStub, disposeHandler: false);
+ }
+
+ ///
+ /// Tests that the constructor verifies parameters and throws when necessary.
+ ///
+ [Fact]
+ public void ConstructorShouldVerifyParams()
+ {
+ // Arrange
+ var mockClient = new Mock();
+
+ // Act & Assert
+ Assert.Throws(() => new OpenAIAssistantAgentThread(null!));
+ Assert.Throws(() => new OpenAIAssistantAgentThread(null!, "threadId"));
+ Assert.Throws(() => new OpenAIAssistantAgentThread(mockClient.Object, id: null!));
+
+ var thread = new OpenAIAssistantAgentThread(mockClient.Object);
+ Assert.NotNull(thread);
+ }
+
+ ///
+ /// Tests that the constructor for resuming a thread uses the provided parameters.
+ ///
+ [Fact]
+ public void ConstructorForResumingThreadShouldUseParams()
+ {
+ // Arrange
+ var mockClient = new Mock();
+
+ // Act
+ var threadWithId = new OpenAIAssistantAgentThread(mockClient.Object, "threadId");
+
+ // Assert
+ Assert.NotNull(threadWithId);
+ Assert.Equal("threadId", threadWithId.Id);
+ }
+
+ ///
+ /// Tests that the CreateAsync method invokes the client and sets the thread ID.
+ ///
+ [Fact]
+ public async Task CreateShouldInvokeClientAsync()
+ {
+ // Arrange
+ this._messageHandlerStub.SetupResponses(HttpStatusCode.OK, OpenAIAssistantResponseContent.CreateThread);
+
+ var provider = this.CreateTestProvider();
+ var assistantClient = provider.AssistantClient;
+
+ var thread = new OpenAIAssistantAgentThread(assistantClient);
+
+ // Act
+ await thread.CreateAsync();
+
+ // Assert
+ Assert.Equal("thread_abc123", thread.Id);
+ Assert.Empty(this._messageHandlerStub.ResponseQueue);
+ }
+
+ ///
+ /// Tests that the DeleteAsync method invokes the client.
+ ///
+ [Fact]
+ public async Task DeleteShouldInvokeClientAsync()
+ {
+ // Arrange
+ this._messageHandlerStub.SetupResponses(HttpStatusCode.OK, OpenAIAssistantResponseContent.CreateThread);
+ this._messageHandlerStub.SetupResponses(HttpStatusCode.OK, OpenAIAssistantResponseContent.DeleteThread);
+
+ var provider = this.CreateTestProvider();
+ var assistantClient = provider.AssistantClient;
+
+ var thread = new OpenAIAssistantAgentThread(assistantClient);
+ await thread.CreateAsync();
+
+ // Act
+ await thread.DeleteAsync();
+
+ // Assert
+ Assert.Empty(this._messageHandlerStub.ResponseQueue);
+ }
+
+ ///
+ public void Dispose()
+ {
+ this._messageHandlerStub.Dispose();
+ this._httpClient.Dispose();
+
+ GC.SuppressFinalize(this);
+ }
+
+ private OpenAIClientProvider CreateTestProvider()
+ => OpenAIClientProvider.ForOpenAI(apiKey: new ApiKeyCredential("fakekey"), endpoint: null, this._httpClient);
+}