diff --git a/dotnet/src/Agents/UnitTests/Core/AgentThreadTests.cs b/dotnet/src/Agents/UnitTests/Core/AgentThreadTests.cs new file mode 100644 index 000000000000..0f22756037bb --- /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 sealed 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); + } +} 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); +}