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