From 40637fc7a367f8738bed171c2eb0914903b0cb9c Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:40:40 +0000 Subject: [PATCH 1/3] Allow overriding the ChatMessageStore to be used per agent run. --- .../AdditionalPropertiesExtensions.cs | 49 +++ .../ChatMessageStore.cs | 8 +- .../ChatClient/ChatClientAgent.cs | 49 ++- .../AdditionalPropertiesExtensionsTests.cs | 236 +++++++++++ .../ChatClientAgentContinuationTokenTests.cs | 2 +- .../ChatClient/ChatClientAgentTests.cs | 296 -------------- ...tClientAgent_ChatHistoryManagementTests.cs | 371 ++++++++++++++++++ .../ChatClientAgent_DeserializeThreadTests.cs | 2 +- .../ChatClientAgent_GetNewThreadTests.cs | 2 +- 9 files changed, 696 insertions(+), 319 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/AdditionalPropertiesExtensions.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AdditionalPropertiesExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AdditionalPropertiesExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AdditionalPropertiesExtensions.cs new file mode 100644 index 0000000000..faddd522bf --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AdditionalPropertiesExtensions.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Contains extension methods to allow storing and retrieving properties using the type name of the property as the key. +/// +public static class AdditionalPropertiesExtensions +{ + /// + /// Sets an additional property using the type name of the property as the key. + /// + /// The type of the property to set. + /// The dictionary of additional properties. + /// The value to set. + public static void Add(this AdditionalPropertiesDictionary additionalProperties, T value) + { + _ = Throw.IfNull(additionalProperties); + + additionalProperties[typeof(T).FullName!] = value!; + } + + /// + /// Attempts to retrieve a value from the additional properties dictionary using the type name of the property as the key. + /// + /// + /// This method uses the full name of the type parameter as the key when searching the dictionary. + /// + /// The type of the property to be retrieved. + /// The dictionary containing additional properties. + /// + /// When this method returns, contains the value retrieved from the dictionary, if found and successfully converted to the requested type; + /// otherwise, the default value of . + /// + /// + /// if a non- value was found + /// in the dictionary and converted to the requested type; otherwise, . + /// + public static bool TryGetValue(this AdditionalPropertiesDictionary additionalProperties, [NotNullWhen(true)] out T? value) + { + _ = Throw.IfNull(additionalProperties); + + return additionalProperties.TryGetValue(typeof(T).FullName!, out value); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStore.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStore.cs index 54cee063d7..6c4eb1e3f9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatMessageStore.cs @@ -171,10 +171,10 @@ public sealed class InvokedContext /// The caller provided messages that were used by the agent for this invocation. /// The messages retrieved from the for this invocation. /// is . - public InvokedContext(IEnumerable requestMessages, IEnumerable chatMessageStoreMessages) + public InvokedContext(IEnumerable requestMessages, IEnumerable? chatMessageStoreMessages) { this.RequestMessages = Throw.IfNull(requestMessages); - this.ChatMessageStoreMessages = Throw.IfNull(chatMessageStoreMessages); + this.ChatMessageStoreMessages = chatMessageStoreMessages; } /// @@ -191,9 +191,9 @@ public InvokedContext(IEnumerable requestMessages, IEnumerable /// /// A collection of instances that were retrieved from the , - /// and were used by the agent as part of the invocation. + /// and were used by the agent as part of the invocation. May be null on the first run. /// - public IEnumerable ChatMessageStoreMessages { get; set { field = Throw.IfNull(value); } } + public IEnumerable? ChatMessageStoreMessages { get; set; } /// /// Gets or sets the messages provided by the for this invocation, if any. diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index 4a42241b3c..f744a5a254 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -231,7 +231,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA } catch (Exception ex) { - await NotifyMessageStoreOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false); + await NotifyMessageStoreOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, chatOptions, cancellationToken).ConfigureAwait(false); await NotifyAIContextProviderOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, cancellationToken).ConfigureAwait(false); throw; } @@ -246,7 +246,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA } catch (Exception ex) { - await NotifyMessageStoreOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false); + await NotifyMessageStoreOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, chatOptions, cancellationToken).ConfigureAwait(false); await NotifyAIContextProviderOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, cancellationToken).ConfigureAwait(false); throw; } @@ -273,7 +273,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA } catch (Exception ex) { - await NotifyMessageStoreOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false); + await NotifyMessageStoreOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, chatOptions, cancellationToken).ConfigureAwait(false); await NotifyAIContextProviderOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, cancellationToken).ConfigureAwait(false); throw; } @@ -286,7 +286,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA await this.UpdateThreadWithTypeAndConversationIdAsync(safeThread, chatResponse.ConversationId, cancellationToken).ConfigureAwait(false); // To avoid inconsistent state we only notify the thread of the input messages if no error occurs after the initial request. - await NotifyMessageStoreOfNewMessagesAsync(safeThread, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false); + await NotifyMessageStoreOfNewMessagesAsync(safeThread, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, chatResponse.Messages, chatOptions, cancellationToken).ConfigureAwait(false); // Notify the AIContextProvider of all new messages. await NotifyAIContextProviderOfSuccessAsync(safeThread, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false); @@ -442,7 +442,7 @@ private async Task RunCoreAsync RunCoreAsync inputMessagesForChatClient = []; IList? aiContextProviderMessages = null; - IList? chatMessageStoreMessages = []; + IList? chatMessageStoreMessages = null; // Populate the thread messages only if we are not continuing an existing response as it's not allowed if (chatOptions?.ContinuationToken is null) { - // Add any existing messages from the thread to the messages to be sent to the chat client. - if (typedThread.MessageStore is not null) + ChatMessageStore? chatMessageStore = ResolveChatMessageStore(typedThread, chatOptions); + + // Add any existing messages from the chatMessageStore to the messages to be sent to the chat client. + if (chatMessageStore is not null) { var invokingContext = new ChatMessageStore.InvokingContext(inputMessages); - var storeMessages = await typedThread.MessageStore.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false); + var storeMessages = await chatMessageStore.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false); inputMessagesForChatClient.AddRange(storeMessages); chatMessageStoreMessages = storeMessages as IList ?? storeMessages.ToList(); } @@ -803,13 +805,14 @@ private static Task NotifyMessageStoreOfFailureAsync( IEnumerable requestMessages, IEnumerable? chatMessageStoreMessages, IEnumerable? aiContextProviderMessages, + ChatOptions? chatOptions, CancellationToken cancellationToken) { - var messageStore = thread.MessageStore; + ChatMessageStore? chatMessageStore = ResolveChatMessageStore(thread, chatOptions); // Only notify the message store if we have one. // If we don't have one, it means that the chat history is service managed and the underlying service is responsible for storing messages. - if (messageStore is not null) + if (chatMessageStore is not null) { var invokedContext = new ChatMessageStore.InvokedContext(requestMessages, chatMessageStoreMessages!) { @@ -817,7 +820,7 @@ private static Task NotifyMessageStoreOfFailureAsync( InvokeException = ex }; - return messageStore.InvokedAsync(invokedContext, cancellationToken).AsTask(); + return chatMessageStore.InvokedAsync(invokedContext, cancellationToken).AsTask(); } return Task.CompletedTask; @@ -829,25 +832,39 @@ private static Task NotifyMessageStoreOfNewMessagesAsync( IEnumerable? chatMessageStoreMessages, IEnumerable? aiContextProviderMessages, IEnumerable responseMessages, + ChatOptions? chatOptions, CancellationToken cancellationToken) { - var messageStore = thread.MessageStore; + ChatMessageStore? chatMessageStore = ResolveChatMessageStore(thread, chatOptions); // Only notify the message store if we have one. // If we don't have one, it means that the chat history is service managed and the underlying service is responsible for storing messages. - if (messageStore is not null) + if (chatMessageStore is not null) { var invokedContext = new ChatMessageStore.InvokedContext(requestMessages, chatMessageStoreMessages!) { AIContextProviderMessages = aiContextProviderMessages, ResponseMessages = responseMessages }; - return messageStore.InvokedAsync(invokedContext, cancellationToken).AsTask(); + return chatMessageStore.InvokedAsync(invokedContext, cancellationToken).AsTask(); } return Task.CompletedTask; } + private static ChatMessageStore? ResolveChatMessageStore(ChatClientAgentThread thread, ChatOptions? chatOptions) + { + ChatMessageStore? chatMessageStore = thread.MessageStore; + + // If someone provided an override ChatMessageStore via AdditionalProperties, we should use that instead of the one on the thread. + if (chatOptions?.AdditionalProperties?.TryGetValue(out ChatMessageStore? overrideChatMessageStore) is true) + { + chatMessageStore = overrideChatMessageStore; + } + + return chatMessageStore; + } + private static ChatClientAgentContinuationToken? WrapContinuationToken(ResponseContinuationToken? continuationToken, IEnumerable? inputMessages = null, List? responseUpdates = null) { if (continuationToken is null) diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AdditionalPropertiesExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AdditionalPropertiesExtensionsTests.cs new file mode 100644 index 0000000000..c33dd42d28 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AdditionalPropertiesExtensionsTests.cs @@ -0,0 +1,236 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests; + +/// +/// Contains tests for the class. +/// +public sealed class AdditionalPropertiesExtensionsTests +{ + #region Add Method Tests + + [Fact] + public void Add_WithValidValue_StoresValueUsingTypeName() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + TestClass value = new() { Name = "Test" }; + + // Act + additionalProperties.Add(value); + + // Assert + Assert.True(additionalProperties.ContainsKey(typeof(TestClass).FullName!)); + Assert.Same(value, additionalProperties[typeof(TestClass).FullName!]); + } + + [Fact] + public void Add_WithNullDictionary_ThrowsArgumentNullException() + { + // Arrange + AdditionalPropertiesDictionary? additionalProperties = null; + TestClass value = new() { Name = "Test" }; + + // Act & Assert + Assert.Throws(() => additionalProperties!.Add(value)); + } + + [Fact] + public void Add_WithStringValue_StoresValueCorrectly() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + const string Value = "test string"; + + // Act + additionalProperties.Add(Value); + + // Assert + Assert.True(additionalProperties.ContainsKey(typeof(string).FullName!)); + Assert.Equal(Value, additionalProperties[typeof(string).FullName!]); + } + + [Fact] + public void Add_WithIntValue_StoresValueCorrectly() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + const int Value = 42; + + // Act + additionalProperties.Add(Value); + + // Assert + Assert.True(additionalProperties.ContainsKey(typeof(int).FullName!)); + Assert.Equal(Value, additionalProperties[typeof(int).FullName!]); + } + + [Fact] + public void Add_OverwritesExistingValue_WhenSameTypeAddedTwice() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + TestClass firstValue = new() { Name = "First" }; + TestClass secondValue = new() { Name = "Second" }; + + // Act + additionalProperties.Add(firstValue); + additionalProperties.Add(secondValue); + + // Assert + Assert.Single(additionalProperties); + Assert.Same(secondValue, additionalProperties[typeof(TestClass).FullName!]); + } + + [Fact] + public void Add_WithMultipleDifferentTypes_StoresAllValues() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + TestClass testClassValue = new() { Name = "Test" }; + AnotherTestClass anotherValue = new() { Id = 123 }; + const string StringValue = "test"; + + // Act + additionalProperties.Add(testClassValue); + additionalProperties.Add(anotherValue); + additionalProperties.Add(StringValue); + + // Assert + Assert.Equal(3, additionalProperties.Count); + Assert.Same(testClassValue, additionalProperties[typeof(TestClass).FullName!]); + Assert.Same(anotherValue, additionalProperties[typeof(AnotherTestClass).FullName!]); + Assert.Equal(StringValue, additionalProperties[typeof(string).FullName!]); + } + + #endregion + + #region TryGetValue Method Tests + + [Fact] + public void TryGetValue_WithExistingValue_ReturnsTrueAndValue() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + TestClass expectedValue = new() { Name = "Test" }; + additionalProperties.Add(expectedValue); + + // Act + bool result = additionalProperties.TryGetValue(out TestClass? actualValue); + + // Assert + Assert.True(result); + Assert.NotNull(actualValue); + Assert.Same(expectedValue, actualValue); + } + + [Fact] + public void TryGetValue_WithNonExistingValue_ReturnsFalseAndNull() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + + // Act + bool result = additionalProperties.TryGetValue(out TestClass? actualValue); + + // Assert + Assert.False(result); + Assert.Null(actualValue); + } + + [Fact] + public void TryGetValue_WithNullDictionary_ThrowsArgumentNullException() + { + // Arrange + AdditionalPropertiesDictionary? additionalProperties = null; + + // Act & Assert + Assert.Throws(() => additionalProperties!.TryGetValue(out _)); + } + + [Fact] + public void TryGetValue_WithStringValue_ReturnsCorrectValue() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + const string ExpectedValue = "test string"; + additionalProperties.Add(ExpectedValue); + + // Act + bool result = additionalProperties.TryGetValue(out string? actualValue); + + // Assert + Assert.True(result); + Assert.Equal(ExpectedValue, actualValue); + } + + [Fact] + public void TryGetValue_WithIntValue_ReturnsCorrectValue() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + const int ExpectedValue = 42; + additionalProperties.Add(ExpectedValue); + + // Act + bool result = additionalProperties.TryGetValue(out int actualValue); + + // Assert + Assert.True(result); + Assert.Equal(ExpectedValue, actualValue); + } + + [Fact] + public void TryGetValue_WithWrongType_ReturnsFalse() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + TestClass testValue = new() { Name = "Test" }; + additionalProperties.Add(testValue); + + // Act + bool result = additionalProperties.TryGetValue(out AnotherTestClass? actualValue); + + // Assert + Assert.False(result); + Assert.Null(actualValue); + } + + [Fact] + public void TryGetValue_AfterOverwrite_ReturnsLatestValue() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + TestClass firstValue = new() { Name = "First" }; + TestClass secondValue = new() { Name = "Second" }; + additionalProperties.Add(firstValue); + additionalProperties.Add(secondValue); + + // Act + bool result = additionalProperties.TryGetValue(out TestClass? actualValue); + + // Assert + Assert.Single(additionalProperties); + Assert.True(result); + Assert.Same(secondValue, actualValue); + } + + #endregion + + #region Test Helper Classes + + private sealed class TestClass + { + public string Name { get; set; } = string.Empty; + } + + private sealed class AnotherTestClass + { + public int Id { get; set; } + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentContinuationTokenTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentContinuationTokenTests.cs index a2add9634b..080fd18a95 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentContinuationTokenTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentContinuationTokenTests.cs @@ -5,7 +5,7 @@ using System.Text.Json; using Microsoft.Extensions.AI; -namespace Microsoft.Agents.AI.UnitTests.ChatClient; +namespace Microsoft.Agents.AI.UnitTests; public class ChatClientAgentContinuationTokenTests { diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs index 546dc258cd..fbafe5fcf2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs @@ -310,302 +310,6 @@ public async Task RunAsyncWorksWithEmptyMessagesWhenNoMessagesProvidedAsync() Assert.Empty(capturedMessages); } - /// - /// Verify that RunAsync does not throw when providing a thread with a ThreadId and a Conversationid - /// via ChatOptions and the two are the same. - /// - [Fact] - public async Task RunAsyncDoesNotThrowWhenSpecifyingTwoSameThreadIdsAsync() - { - // Arrange - var chatOptions = new ChatOptions { ConversationId = "ConvId" }; - Mock mockService = new(); - mockService.Setup( - s => s.GetResponseAsync( - It.IsAny>(), - It.Is(opts => opts.ConversationId == "ConvId"), - It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" }); - - ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } }); - - ChatClientAgentThread thread = new() { ConversationId = "ConvId" }; - - // Act & Assert - var response = await agent.RunAsync([new(ChatRole.User, "test")], thread, options: new ChatClientAgentRunOptions(chatOptions)); - Assert.NotNull(response); - } - - /// - /// Verify that RunAsync throws when providing a thread with a ThreadId and a Conversationid - /// via ChatOptions and the two are different. - /// - [Fact] - public async Task RunAsyncThrowsWhenSpecifyingTwoDifferentThreadIdsAsync() - { - // Arrange - var chatOptions = new ChatOptions { ConversationId = "ConvId" }; - Mock mockService = new(); - - ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } }); - - ChatClientAgentThread thread = new() { ConversationId = "ThreadId" }; - - // Act & Assert - await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], thread, options: new ChatClientAgentRunOptions(chatOptions))); - } - - /// - /// Verify that RunAsync clones the ChatOptions when providing a thread with a ThreadId and a ChatOptions. - /// - [Fact] - public async Task RunAsyncClonesChatOptionsToAddThreadIdAsync() - { - // Arrange - var chatOptions = new ChatOptions { MaxOutputTokens = 100 }; - Mock mockService = new(); - mockService.Setup( - s => s.GetResponseAsync( - It.IsAny>(), - It.Is(opts => opts.MaxOutputTokens == 100 && opts.ConversationId == "ConvId"), - It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" }); - - ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } }); - - ChatClientAgentThread thread = new() { ConversationId = "ConvId" }; - - // Act - await agent.RunAsync([new(ChatRole.User, "test")], thread, options: new ChatClientAgentRunOptions(chatOptions)); - - // Assert - Assert.Null(chatOptions.ConversationId); - } - - /// - /// Verify that RunAsync throws if a thread is provided that uses a conversation id already, but the service does not return one on invoke. - /// - [Fact] - public async Task RunAsyncThrowsForMissingConversationIdWithConversationIdThreadAsync() - { - // Arrange - Mock mockService = new(); - mockService.Setup( - s => s.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); - - ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } }); - - ChatClientAgentThread thread = new() { ConversationId = "ConvId" }; - - // Act & Assert - await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], thread)); - } - - /// - /// Verify that RunAsync sets the ConversationId on the thread when the service returns one. - /// - [Fact] - public async Task RunAsyncSetsConversationIdOnThreadWhenReturnedByChatClientAsync() - { - // Arrange - Mock mockService = new(); - mockService.Setup( - s => s.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" }); - ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } }); - ChatClientAgentThread thread = new(); - - // Act - await agent.RunAsync([new(ChatRole.User, "test")], thread); - - // Assert - Assert.Equal("ConvId", thread.ConversationId); - } - - /// - /// Verify that RunAsync uses the ChatMessageStore factory when the chat client returns no conversation id. - /// - [Fact] - public async Task RunAsyncUsesChatMessageStoreWhenNoConversationIdReturnedByChatClientAsync() - { - // Arrange - Mock mockService = new(); - mockService.Setup( - s => s.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); - Mock>> mockFactory = new(); - mockFactory.Setup(f => f(It.IsAny(), It.IsAny())).ReturnsAsync(new InMemoryChatMessageStore()); - ChatClientAgent agent = new(mockService.Object, options: new() - { - ChatOptions = new() { Instructions = "test instructions" }, - ChatMessageStoreFactory = mockFactory.Object - }); - - // Act - ChatClientAgentThread? thread = await agent.GetNewThreadAsync() as ChatClientAgentThread; - await agent.RunAsync([new(ChatRole.User, "test")], thread); - - // Assert - var messageStore = Assert.IsType(thread!.MessageStore); - Assert.Equal(2, messageStore.Count); - Assert.Equal("test", messageStore[0].Text); - Assert.Equal("response", messageStore[1].Text); - mockFactory.Verify(f => f(It.IsAny(), It.IsAny()), Times.Once); - } - - /// - /// Verify that RunAsync uses the default InMemoryChatMessageStore when the chat client returns no conversation id. - /// - [Fact] - public async Task RunAsyncUsesDefaultInMemoryChatMessageStoreWhenNoConversationIdReturnedByChatClientAsync() - { - // Arrange - Mock mockService = new(); - mockService.Setup( - s => s.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); - ChatClientAgent agent = new(mockService.Object, options: new() - { - ChatOptions = new() { Instructions = "test instructions" }, - }); - - // Act - ChatClientAgentThread? thread = await agent.GetNewThreadAsync() as ChatClientAgentThread; - await agent.RunAsync([new(ChatRole.User, "test")], thread); - - // Assert - var messageStore = Assert.IsType(thread!.MessageStore); - Assert.Equal(2, messageStore.Count); - Assert.Equal("test", messageStore[0].Text); - Assert.Equal("response", messageStore[1].Text); - } - - /// - /// Verify that RunAsync uses the ChatMessageStore factory when the chat client returns no conversation id. - /// - [Fact] - public async Task RunAsyncUsesChatMessageStoreFactoryWhenProvidedAndNoConversationIdReturnedByChatClientAsync() - { - // Arrange - Mock mockService = new(); - mockService.Setup( - s => s.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); - - Mock mockChatMessageStore = new(); - mockChatMessageStore.Setup(s => s.InvokingAsync( - It.IsAny(), - It.IsAny())).ReturnsAsync([new ChatMessage(ChatRole.User, "Existing Chat History")]); - mockChatMessageStore.Setup(s => s.InvokedAsync( - It.IsAny(), - It.IsAny())).Returns(new ValueTask()); - - Mock>> mockFactory = new(); - mockFactory.Setup(f => f(It.IsAny(), It.IsAny())).ReturnsAsync(mockChatMessageStore.Object); - - ChatClientAgent agent = new(mockService.Object, options: new() - { - ChatOptions = new() { Instructions = "test instructions" }, - ChatMessageStoreFactory = mockFactory.Object - }); - - // Act - ChatClientAgentThread? thread = await agent.GetNewThreadAsync() as ChatClientAgentThread; - await agent.RunAsync([new(ChatRole.User, "test")], thread); - - // Assert - Assert.IsType(thread!.MessageStore, exactMatch: false); - mockService.Verify( - x => x.GetResponseAsync( - It.Is>(msgs => msgs.Count() == 2 && msgs.Any(m => m.Text == "Existing Chat History") && msgs.Any(m => m.Text == "test")), - It.IsAny(), - It.IsAny()), - Times.Once); - mockChatMessageStore.Verify(s => s.InvokingAsync( - It.Is(x => x.RequestMessages.Count() == 1), - It.IsAny()), - Times.Once); - mockChatMessageStore.Verify(s => s.InvokedAsync( - It.Is(x => x.RequestMessages.Count() == 1 && x.ChatMessageStoreMessages.Count() == 1 && x.ResponseMessages!.Count() == 1), - It.IsAny()), - Times.Once); - mockFactory.Verify(f => f(It.IsAny(), It.IsAny()), Times.Once); - } - - /// - /// Verify that RunAsync notifies the ChatMessageStore on failure. - /// - [Fact] - public async Task RunAsyncNotifiesChatMessageStoreOnFailureAsync() - { - // Arrange - Mock mockService = new(); - mockService.Setup( - s => s.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())).Throws(new InvalidOperationException("Test Error")); - - Mock mockChatMessageStore = new(); - - Mock>> mockFactory = new(); - mockFactory.Setup(f => f(It.IsAny(), It.IsAny())).ReturnsAsync(mockChatMessageStore.Object); - - ChatClientAgent agent = new(mockService.Object, options: new() - { - ChatOptions = new() { Instructions = "test instructions" }, - ChatMessageStoreFactory = mockFactory.Object - }); - - // Act - ChatClientAgentThread? thread = await agent.GetNewThreadAsync() as ChatClientAgentThread; - await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], thread)); - - // Assert - Assert.IsType(thread!.MessageStore, exactMatch: false); - mockChatMessageStore.Verify(s => s.InvokedAsync( - It.Is(x => x.RequestMessages.Count() == 1 && x.ResponseMessages == null && x.InvokeException!.Message == "Test Error"), - It.IsAny()), - Times.Once); - mockFactory.Verify(f => f(It.IsAny(), It.IsAny()), Times.Once); - } - - /// - /// Verify that RunAsync throws when a ChatMessageStore Factory is provided and the chat client returns a conversation id. - /// - [Fact] - public async Task RunAsyncThrowsWhenChatMessageStoreFactoryProvidedAndConversationIdReturnedByChatClientAsync() - { - // Arrange - Mock mockService = new(); - mockService.Setup( - s => s.GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" }); - Mock>> mockFactory = new(); - mockFactory.Setup(f => f(It.IsAny(), It.IsAny())).ReturnsAsync(new InMemoryChatMessageStore()); - ChatClientAgent agent = new(mockService.Object, options: new() - { - ChatOptions = new() { Instructions = "test instructions" }, - ChatMessageStoreFactory = mockFactory.Object - }); - - // Act & Assert - ChatClientAgentThread? thread = await agent.GetNewThreadAsync() as ChatClientAgentThread; - var exception = await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], thread)); - Assert.Equal("Only the ConversationId or MessageStore may be set, but not both and switching from one to another is not supported.", exception.Message); - } - /// /// Verify that RunAsync invokes any provided AIContextProvider and uses the result. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs new file mode 100644 index 0000000000..0f066061b7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs @@ -0,0 +1,371 @@ +// 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 Xunit.Sdk; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Contains unit tests that verify the chat history management functionality of the class, +/// e.g. that it correctly reads and updates chat history in any available or that +/// it uses conversation id correctly for service managed chat history. +/// +public class ChatClientAgent_ChatHistoryManagementTests +{ + #region ConversationId Tests + + /// + /// Verify that RunAsync does not throw when providing a Conversationid via both AgentThread and + /// via ChatOptions and the two are the same. + /// + [Fact] + public async Task RunAsync_DoesNotThrow_WhenSpecifyingTwoSameConversationIdsAsync() + { + // Arrange + var chatOptions = new ChatOptions { ConversationId = "ConvId" }; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.Is(opts => opts.ConversationId == "ConvId"), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" }); + + ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } }); + + ChatClientAgentThread thread = new() { ConversationId = "ConvId" }; + + // Act & Assert + var response = await agent.RunAsync([new(ChatRole.User, "test")], thread, options: new ChatClientAgentRunOptions(chatOptions)); + Assert.NotNull(response); + } + + /// + /// Verify that RunAsync throws when providing a ConversationId via both AgentThread and + /// via ChatOptions and the two are different. + /// + [Fact] + public async Task RunAsync_Throws_WhenSpecifyingTwoDifferentConversationIdsAsync() + { + // Arrange + var chatOptions = new ChatOptions { ConversationId = "ConvId" }; + Mock mockService = new(); + + ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } }); + + ChatClientAgentThread thread = new() { ConversationId = "ThreadId" }; + + // Act & Assert + await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], thread, options: new ChatClientAgentRunOptions(chatOptions))); + } + + /// + /// Verify that RunAsync clones the ChatOptions when providing a thread with a ConversationId and a ChatOptions. + /// + [Fact] + public async Task RunAsync_ClonesChatOptions_ToAddConversationIdAsync() + { + // Arrange + var chatOptions = new ChatOptions { MaxOutputTokens = 100 }; + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.Is(opts => opts.MaxOutputTokens == 100 && opts.ConversationId == "ConvId"), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" }); + + ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } }); + + ChatClientAgentThread thread = new() { ConversationId = "ConvId" }; + + // Act + await agent.RunAsync([new(ChatRole.User, "test")], thread, options: new ChatClientAgentRunOptions(chatOptions)); + + // Assert + Assert.Null(chatOptions.ConversationId); + } + + /// + /// Verify that RunAsync throws if a thread is provided that uses a conversation id already, but the service does not return one on invoke. + /// + [Fact] + public async Task RunAsync_Throws_ForMissingConversationIdWithConversationIdThreadAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } }); + + ChatClientAgentThread thread = new() { ConversationId = "ConvId" }; + + // Act & Assert + await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], thread)); + } + + /// + /// Verify that RunAsync sets the ConversationId on the thread when the service returns one. + /// + [Fact] + public async Task RunAsync_SetsConversationIdOnThread_WhenReturnedByChatClientAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" }); + ChatClientAgent agent = new(mockService.Object, options: new() { ChatOptions = new() { Instructions = "test instructions" } }); + ChatClientAgentThread thread = new(); + + // Act + await agent.RunAsync([new(ChatRole.User, "test")], thread); + + // Assert + Assert.Equal("ConvId", thread.ConversationId); + } + + #endregion + + #region ChatMessageStore Tests + + /// + /// Verify that RunAsync uses the default InMemoryChatMessageStore when the chat client returns no conversation id. + /// + [Fact] + public async Task RunAsync_UsesDefaultInMemoryChatMessageStore_WhenNoConversationIdReturnedByChatClientAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test instructions" }, + }); + + // Act + ChatClientAgentThread? thread = await agent.GetNewThreadAsync() as ChatClientAgentThread; + await agent.RunAsync([new(ChatRole.User, "test")], thread); + + // Assert + var messageStore = Assert.IsType(thread!.MessageStore); + Assert.Equal(2, messageStore.Count); + Assert.Equal("test", messageStore[0].Text); + Assert.Equal("response", messageStore[1].Text); + } + + /// + /// Verify that RunAsync uses the ChatMessageStore factory when the chat client returns no conversation id. + /// + [Fact] + public async Task RunAsync_UsesChatMessageStoreFactory_WhenProvidedAndNoConversationIdReturnedByChatClientAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + Mock mockChatMessageStore = new(); + mockChatMessageStore.Setup(s => s.InvokingAsync( + It.IsAny(), + It.IsAny())).ReturnsAsync([new ChatMessage(ChatRole.User, "Existing Chat History")]); + mockChatMessageStore.Setup(s => s.InvokedAsync( + It.IsAny(), + It.IsAny())).Returns(new ValueTask()); + + Mock>> mockFactory = new(); + mockFactory.Setup(f => f(It.IsAny(), It.IsAny())).ReturnsAsync(mockChatMessageStore.Object); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test instructions" }, + ChatMessageStoreFactory = mockFactory.Object + }); + + // Act + ChatClientAgentThread? thread = await agent.GetNewThreadAsync() as ChatClientAgentThread; + await agent.RunAsync([new(ChatRole.User, "test")], thread); + + // Assert + Assert.IsType(thread!.MessageStore, exactMatch: false); + mockService.Verify( + x => x.GetResponseAsync( + It.Is>(msgs => msgs.Count() == 2 && msgs.Any(m => m.Text == "Existing Chat History") && msgs.Any(m => m.Text == "test")), + It.IsAny(), + It.IsAny()), + Times.Once); + mockChatMessageStore.Verify(s => s.InvokingAsync( + It.Is(x => x.RequestMessages.Count() == 1), + It.IsAny()), + Times.Once); + mockChatMessageStore.Verify(s => s.InvokedAsync( + It.Is(x => x.RequestMessages.Count() == 1 && x.ChatMessageStoreMessages != null && x.ChatMessageStoreMessages.Count() == 1 && x.ResponseMessages!.Count() == 1), + It.IsAny()), + Times.Once); + mockFactory.Verify(f => f(It.IsAny(), It.IsAny()), Times.Once); + } + + /// + /// Verify that RunAsync notifies the ChatMessageStore on failure. + /// + [Fact] + public async Task RunAsync_NotifiesChatMessageStore_OnFailureAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).Throws(new InvalidOperationException("Test Error")); + + Mock mockChatMessageStore = new(); + + Mock>> mockFactory = new(); + mockFactory.Setup(f => f(It.IsAny(), It.IsAny())).ReturnsAsync(mockChatMessageStore.Object); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test instructions" }, + ChatMessageStoreFactory = mockFactory.Object + }); + + // Act + ChatClientAgentThread? thread = await agent.GetNewThreadAsync() as ChatClientAgentThread; + await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], thread)); + + // Assert + Assert.IsType(thread!.MessageStore, exactMatch: false); + mockChatMessageStore.Verify(s => s.InvokedAsync( + It.Is(x => x.RequestMessages.Count() == 1 && x.ResponseMessages == null && x.InvokeException!.Message == "Test Error"), + It.IsAny()), + Times.Once); + mockFactory.Verify(f => f(It.IsAny(), It.IsAny()), Times.Once); + } + + /// + /// Verify that RunAsync throws when a ChatMessageStore Factory is provided and the chat client returns a conversation id. + /// + [Fact] + public async Task RunAsync_Throws_WhenChatMessageStoreFactoryProvidedAndConversationIdReturnedByChatClientAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" }); + Mock>> mockFactory = new(); + mockFactory.Setup(f => f(It.IsAny(), It.IsAny())).ReturnsAsync(new InMemoryChatMessageStore()); + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test instructions" }, + ChatMessageStoreFactory = mockFactory.Object + }); + + // Act & Assert + ChatClientAgentThread? thread = await agent.GetNewThreadAsync() as ChatClientAgentThread; + var exception = await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], thread)); + Assert.Equal("Only the ConversationId or MessageStore may be set, but not both and switching from one to another is not supported.", exception.Message); + } + + #endregion + + #region ChatMessageStore Override Tests + + /// + /// Tests that RunAsync uses an override ChatMessageStore provided via AdditionalProperties instead of the store from a factory + /// if one is supplied. + /// + [Fact] + public async Task RunAsync_UsesOverrideChatMessageStore_WhenProvidedViaAdditionalPropertiesAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + // Arrange a chat message store to override the factory provided one. + Mock mockOverrideChatMessageStore = new(); + mockOverrideChatMessageStore.Setup(s => s.InvokingAsync( + It.IsAny(), + It.IsAny())).ReturnsAsync([new ChatMessage(ChatRole.User, "Existing Chat History")]); + mockOverrideChatMessageStore.Setup(s => s.InvokedAsync( + It.IsAny(), + It.IsAny())).Returns(new ValueTask()); + + // Arrange a chat message store to provide to the agent via a factory at construction time. + // This one shouldn't be used since it is being overridden. + Mock mockFactoryChatMessageStore = new(); + mockFactoryChatMessageStore.Setup(s => s.InvokingAsync( + It.IsAny(), + It.IsAny())).ThrowsAsync(FailException.ForFailure("Base ChatMessageStore shouldn't be used.")); + mockFactoryChatMessageStore.Setup(s => s.InvokedAsync( + It.IsAny(), + It.IsAny())).Throws(FailException.ForFailure("Base ChatMessageStore shouldn't be used.")); + + Mock>> mockFactory = new(); + mockFactory.Setup(f => f(It.IsAny(), It.IsAny())).ReturnsAsync(mockFactoryChatMessageStore.Object); + + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test instructions" }, + ChatMessageStoreFactory = mockFactory.Object + }); + + // Act + ChatClientAgentThread? thread = await agent.GetNewThreadAsync() as ChatClientAgentThread; + var additionalProperties = new AdditionalPropertiesDictionary(); + additionalProperties.Add(mockOverrideChatMessageStore.Object); + await agent.RunAsync([new(ChatRole.User, "test")], thread, options: new AgentRunOptions { AdditionalProperties = additionalProperties }); + + // Assert + Assert.Same(mockFactoryChatMessageStore.Object, thread!.MessageStore); + mockService.Verify( + x => x.GetResponseAsync( + It.Is>(msgs => msgs.Count() == 2 && msgs.Any(m => m.Text == "Existing Chat History") && msgs.Any(m => m.Text == "test")), + It.IsAny(), + It.IsAny()), + Times.Once); + mockOverrideChatMessageStore.Verify(s => s.InvokingAsync( + It.Is(x => x.RequestMessages.Count() == 1), + It.IsAny()), + Times.Once); + mockOverrideChatMessageStore.Verify(s => s.InvokedAsync( + It.Is(x => x.RequestMessages.Count() == 1 && x.ChatMessageStoreMessages != null && x.ChatMessageStoreMessages.Count() == 1 && x.ResponseMessages!.Count() == 1), + It.IsAny()), + Times.Once); + + mockFactoryChatMessageStore.Verify(s => s.InvokingAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + mockFactoryChatMessageStore.Verify(s => s.InvokedAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_DeserializeThreadTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_DeserializeThreadTests.cs index 98e5b0ed1a..97ce6b92f4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_DeserializeThreadTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_DeserializeThreadTests.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.AI; using Moq; -namespace Microsoft.Agents.AI.UnitTests.ChatClient; +namespace Microsoft.Agents.AI.UnitTests; /// /// Contains unit tests for the ChatClientAgent.DeserializeThread methods. diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_GetNewThreadTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_GetNewThreadTests.cs index e6cc7e90e9..0cd49ce1eb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_GetNewThreadTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_GetNewThreadTests.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.AI; using Moq; -namespace Microsoft.Agents.AI.UnitTests.ChatClient; +namespace Microsoft.Agents.AI.UnitTests; /// /// Contains unit tests for the ChatClientAgent.GetNewThreadAsync methods. From fe81d5185d55cd50d753cae56d068f9cbfb4988f Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 21 Jan 2026 15:44:39 +0000 Subject: [PATCH 2/3] Fix typos --- dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs | 4 ++-- .../ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index f744a5a254..d39a5c8893 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -814,7 +814,7 @@ private static Task NotifyMessageStoreOfFailureAsync( // If we don't have one, it means that the chat history is service managed and the underlying service is responsible for storing messages. if (chatMessageStore is not null) { - var invokedContext = new ChatMessageStore.InvokedContext(requestMessages, chatMessageStoreMessages!) + var invokedContext = new ChatMessageStore.InvokedContext(requestMessages, chatMessageStoreMessages) { AIContextProviderMessages = aiContextProviderMessages, InvokeException = ex @@ -841,7 +841,7 @@ private static Task NotifyMessageStoreOfNewMessagesAsync( // If we don't have one, it means that the chat history is service managed and the underlying service is responsible for storing messages. if (chatMessageStore is not null) { - var invokedContext = new ChatMessageStore.InvokedContext(requestMessages, chatMessageStoreMessages!) + var invokedContext = new ChatMessageStore.InvokedContext(requestMessages, chatMessageStoreMessages) { AIContextProviderMessages = aiContextProviderMessages, ResponseMessages = responseMessages diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs index 0f066061b7..96edee3dac 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs @@ -21,7 +21,7 @@ public class ChatClientAgent_ChatHistoryManagementTests #region ConversationId Tests /// - /// Verify that RunAsync does not throw when providing a Conversationid via both AgentThread and + /// Verify that RunAsync does not throw when providing a ConversationId via both AgentThread and /// via ChatOptions and the two are the same. /// [Fact] From 5bbde82ab40f8c8e04ad1d94986c82d58619d4ae Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 22 Jan 2026 20:38:49 +0000 Subject: [PATCH 3/3] Fix Add and add TryAdd, Contains and Remove --- .../AdditionalPropertiesExtensions.cs | 58 +++- .../AdditionalPropertiesExtensionsTests.cs | 274 +++++++++++++++++- 2 files changed, 318 insertions(+), 14 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AdditionalPropertiesExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AdditionalPropertiesExtensions.cs index faddd522bf..bf11a98c84 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AdditionalPropertiesExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AdditionalPropertiesExtensions.cs @@ -12,16 +12,36 @@ namespace Microsoft.Agents.AI; public static class AdditionalPropertiesExtensions { /// - /// Sets an additional property using the type name of the property as the key. + /// Adds an additional property using the type name of the property as the key. /// - /// The type of the property to set. + /// The type of the property to add. /// The dictionary of additional properties. - /// The value to set. + /// The value to add. public static void Add(this AdditionalPropertiesDictionary additionalProperties, T value) { _ = Throw.IfNull(additionalProperties); - additionalProperties[typeof(T).FullName!] = value!; + additionalProperties.Add(typeof(T).FullName!, value); + } + + /// + /// Attempts to add a property using the type name of the property as the key. + /// + /// + /// This method uses the full name of the type parameter as the key. If the key already exists, + /// the value is not updated and the method returns . + /// + /// The type of the property to add. + /// The dictionary of additional properties. + /// The value to add. + /// + /// if the value was added successfully; if the key already exists. + /// + public static bool TryAdd(this AdditionalPropertiesDictionary additionalProperties, T value) + { + _ = Throw.IfNull(additionalProperties); + + return additionalProperties.TryAdd(typeof(T).FullName!, value); } /// @@ -46,4 +66,34 @@ public static bool TryGetValue(this AdditionalPropertiesDictionary additional return additionalProperties.TryGetValue(typeof(T).FullName!, out value); } + + /// + /// Determines whether the additional properties dictionary contains a property with the name of the provided type as the key. + /// + /// The type of the property to check for. + /// The dictionary of additional properties. + /// + /// if the dictionary contains a property with the name of the provided type as the key; otherwise, . + /// + public static bool Contains(this AdditionalPropertiesDictionary additionalProperties) + { + _ = Throw.IfNull(additionalProperties); + + return additionalProperties.ContainsKey(typeof(T).FullName!); + } + + /// + /// Removes a property from the additional properties dictionary using the name of the provided type as the key. + /// + /// The type of the property to remove. + /// The dictionary of additional properties. + /// + /// if the property was successfully removed; otherwise, . + /// + public static bool Remove(this AdditionalPropertiesDictionary additionalProperties) + { + _ = Throw.IfNull(additionalProperties); + + return additionalProperties.Remove(typeof(T).FullName!); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AdditionalPropertiesExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AdditionalPropertiesExtensionsTests.cs index c33dd42d28..86ce4f187e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AdditionalPropertiesExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AdditionalPropertiesExtensionsTests.cs @@ -69,20 +69,16 @@ public void Add_WithIntValue_StoresValueCorrectly() } [Fact] - public void Add_OverwritesExistingValue_WhenSameTypeAddedTwice() + public void Add_ThrowsArgumentException_WhenSameTypeAddedTwice() { // Arrange AdditionalPropertiesDictionary additionalProperties = new(); TestClass firstValue = new() { Name = "First" }; TestClass secondValue = new() { Name = "Second" }; - - // Act additionalProperties.Add(firstValue); - additionalProperties.Add(secondValue); - // Assert - Assert.Single(additionalProperties); - Assert.Same(secondValue, additionalProperties[typeof(TestClass).FullName!]); + // Act & Assert + Assert.Throws(() => additionalProperties.Add(secondValue)); } [Fact] @@ -108,6 +104,111 @@ public void Add_WithMultipleDifferentTypes_StoresAllValues() #endregion + #region TryAdd Method Tests + + [Fact] + public void TryAdd_WithValidValue_ReturnsTrueAndStoresValue() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + TestClass value = new() { Name = "Test" }; + + // Act + bool result = additionalProperties.TryAdd(value); + + // Assert + Assert.True(result); + Assert.True(additionalProperties.ContainsKey(typeof(TestClass).FullName!)); + Assert.Same(value, additionalProperties[typeof(TestClass).FullName!]); + } + + [Fact] + public void TryAdd_WithNullDictionary_ThrowsArgumentNullException() + { + // Arrange + AdditionalPropertiesDictionary? additionalProperties = null; + TestClass value = new() { Name = "Test" }; + + // Act & Assert + Assert.Throws(() => additionalProperties!.TryAdd(value)); + } + + [Fact] + public void TryAdd_WithExistingType_ReturnsFalseAndKeepsOriginalValue() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + TestClass firstValue = new() { Name = "First" }; + TestClass secondValue = new() { Name = "Second" }; + additionalProperties.Add(firstValue); + + // Act + bool result = additionalProperties.TryAdd(secondValue); + + // Assert + Assert.False(result); + Assert.Single(additionalProperties); + Assert.Same(firstValue, additionalProperties[typeof(TestClass).FullName!]); + } + + [Fact] + public void TryAdd_WithStringValue_ReturnsTrueAndStoresValue() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + const string Value = "test string"; + + // Act + bool result = additionalProperties.TryAdd(Value); + + // Assert + Assert.True(result); + Assert.True(additionalProperties.ContainsKey(typeof(string).FullName!)); + Assert.Equal(Value, additionalProperties[typeof(string).FullName!]); + } + + [Fact] + public void TryAdd_WithIntValue_ReturnsTrueAndStoresValue() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + const int Value = 42; + + // Act + bool result = additionalProperties.TryAdd(Value); + + // Assert + Assert.True(result); + Assert.True(additionalProperties.ContainsKey(typeof(int).FullName!)); + Assert.Equal(Value, additionalProperties[typeof(int).FullName!]); + } + + [Fact] + public void TryAdd_WithMultipleDifferentTypes_StoresAllValues() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + TestClass testClassValue = new() { Name = "Test" }; + AnotherTestClass anotherValue = new() { Id = 123 }; + const string StringValue = "test"; + + // Act + bool result1 = additionalProperties.TryAdd(testClassValue); + bool result2 = additionalProperties.TryAdd(anotherValue); + bool result3 = additionalProperties.TryAdd(StringValue); + + // Assert + Assert.True(result1); + Assert.True(result2); + Assert.True(result3); + Assert.Equal(3, additionalProperties.Count); + Assert.Same(testClassValue, additionalProperties[typeof(TestClass).FullName!]); + Assert.Same(anotherValue, additionalProperties[typeof(AnotherTestClass).FullName!]); + Assert.Equal(StringValue, additionalProperties[typeof(string).FullName!]); + } + + #endregion + #region TryGetValue Method Tests [Fact] @@ -200,14 +301,14 @@ public void TryGetValue_WithWrongType_ReturnsFalse() } [Fact] - public void TryGetValue_AfterOverwrite_ReturnsLatestValue() + public void TryGetValue_AfterTryAddFails_ReturnsOriginalValue() { // Arrange AdditionalPropertiesDictionary additionalProperties = new(); TestClass firstValue = new() { Name = "First" }; TestClass secondValue = new() { Name = "Second" }; additionalProperties.Add(firstValue); - additionalProperties.Add(secondValue); + additionalProperties.TryAdd(secondValue); // Act bool result = additionalProperties.TryGetValue(out TestClass? actualValue); @@ -215,7 +316,160 @@ public void TryGetValue_AfterOverwrite_ReturnsLatestValue() // Assert Assert.Single(additionalProperties); Assert.True(result); - Assert.Same(secondValue, actualValue); + Assert.Same(firstValue, actualValue); + } + + #endregion + + #region Contains Method Tests + + [Fact] + public void Contains_WithExistingType_ReturnsTrue() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + TestClass value = new() { Name = "Test" }; + additionalProperties.Add(value); + + // Act + bool result = additionalProperties.Contains(); + + // Assert + Assert.True(result); + } + + [Fact] + public void Contains_WithNonExistingType_ReturnsFalse() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + + // Act + bool result = additionalProperties.Contains(); + + // Assert + Assert.False(result); + } + + [Fact] + public void Contains_WithNullDictionary_ThrowsArgumentNullException() + { + // Arrange + AdditionalPropertiesDictionary? additionalProperties = null; + + // Act & Assert + Assert.Throws(() => additionalProperties!.Contains()); + } + + [Fact] + public void Contains_WithDifferentType_ReturnsFalse() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + TestClass value = new() { Name = "Test" }; + additionalProperties.Add(value); + + // Act + bool result = additionalProperties.Contains(); + + // Assert + Assert.False(result); + } + + [Fact] + public void Contains_AfterRemove_ReturnsFalse() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + TestClass value = new() { Name = "Test" }; + additionalProperties.Add(value); + additionalProperties.Remove(); + + // Act + bool result = additionalProperties.Contains(); + + // Assert + Assert.False(result); + } + + #endregion + + #region Remove Method Tests + + [Fact] + public void Remove_WithExistingType_ReturnsTrueAndRemovesValue() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + TestClass value = new() { Name = "Test" }; + additionalProperties.Add(value); + + // Act + bool result = additionalProperties.Remove(); + + // Assert + Assert.True(result); + Assert.Empty(additionalProperties); + } + + [Fact] + public void Remove_WithNonExistingType_ReturnsFalse() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + + // Act + bool result = additionalProperties.Remove(); + + // Assert + Assert.False(result); + } + + [Fact] + public void Remove_WithNullDictionary_ThrowsArgumentNullException() + { + // Arrange + AdditionalPropertiesDictionary? additionalProperties = null; + + // Act & Assert + Assert.Throws(() => additionalProperties!.Remove()); + } + + [Fact] + public void Remove_OnlyRemovesSpecifiedType() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + TestClass testValue = new() { Name = "Test" }; + AnotherTestClass anotherValue = new() { Id = 123 }; + additionalProperties.Add(testValue); + additionalProperties.Add(anotherValue); + + // Act + bool result = additionalProperties.Remove(); + + // Assert + Assert.True(result); + Assert.Single(additionalProperties); + Assert.False(additionalProperties.Contains()); + Assert.True(additionalProperties.Contains()); + } + + [Fact] + public void Remove_CalledTwice_ReturnsFalseOnSecondCall() + { + // Arrange + AdditionalPropertiesDictionary additionalProperties = new(); + TestClass value = new() { Name = "Test" }; + additionalProperties.Add(value); + + // Act + bool firstResult = additionalProperties.Remove(); + bool secondResult = additionalProperties.Remove(); + + // Assert + Assert.True(firstResult); + Assert.False(secondResult); } #endregion