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..bf11a98c84
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AdditionalPropertiesExtensions.cs
@@ -0,0 +1,99 @@
+// 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
+{
+ ///
+ /// Adds an additional property using the type name of the property as the key.
+ ///
+ /// The type of the property to add.
+ /// The dictionary of additional properties.
+ /// The value to add.
+ public static void Add(this AdditionalPropertiesDictionary additionalProperties, T value)
+ {
+ _ = Throw.IfNull(additionalProperties);
+
+ 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);
+ }
+
+ ///
+ /// 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);
+ }
+
+ ///
+ /// 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/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..d39a5c8893 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,21 +805,22 @@ 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!)
+ var invokedContext = new ChatMessageStore.InvokedContext(requestMessages, chatMessageStoreMessages)
{
AIContextProviderMessages = aiContextProviderMessages,
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!)
+ 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..86ce4f187e
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AdditionalPropertiesExtensionsTests.cs
@@ -0,0 +1,490 @@
+// 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_ThrowsArgumentException_WhenSameTypeAddedTwice()
+ {
+ // Arrange
+ AdditionalPropertiesDictionary additionalProperties = new();
+ TestClass firstValue = new() { Name = "First" };
+ TestClass secondValue = new() { Name = "Second" };
+ additionalProperties.Add(firstValue);
+
+ // Act & Assert
+ Assert.Throws(() => additionalProperties.Add(secondValue));
+ }
+
+ [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 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]
+ 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_AfterTryAddFails_ReturnsOriginalValue()
+ {
+ // Arrange
+ AdditionalPropertiesDictionary additionalProperties = new();
+ TestClass firstValue = new() { Name = "First" };
+ TestClass secondValue = new() { Name = "Second" };
+ additionalProperties.Add(firstValue);
+ additionalProperties.TryAdd(secondValue);
+
+ // Act
+ bool result = additionalProperties.TryGetValue(out TestClass? actualValue);
+
+ // Assert
+ Assert.Single(additionalProperties);
+ Assert.True(result);
+ 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
+
+ #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..96edee3dac
--- /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.