From 7b14b00e7b33391defb8985f6964846a2d44d110 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:40:46 +0000 Subject: [PATCH 1/2] Add helpers to more easily access in-memory ChatHistory and make ChatHistoryProvider management more configurable. --- .../Agent_Step16_ChatReduction/Program.cs | 26 +- .../AgentSessionExtensions.cs | 67 +++++ .../ChatClient/ChatClientAgent.cs | 36 +-- .../ChatClient/ChatClientAgentLogMessages.cs | 13 + .../ChatClient/ChatClientAgentOptions.cs | 34 +++ .../AgentSessionExtensionsTests.cs | 231 ++++++++++++++++++ .../ChatClient/ChatClientAgentOptionsTests.cs | 14 +- ...tClientAgent_ChatHistoryManagementTests.cs | 118 +++++++++ 8 files changed, 510 insertions(+), 29 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionExtensions.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionExtensionsTests.cs diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Program.cs index c95f46a6c0..fe93ed785c 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Program.cs @@ -10,7 +10,6 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using OpenAI.Chat; -using ChatMessage = Microsoft.Extensions.AI.ChatMessage; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; @@ -39,9 +38,10 @@ // We can use the ChatHistoryProvider, that is also used by the agent, to read the // chat history from the session state, and see how the reducer is affecting the stored messages. // Here we expect to see 2 messages, the original user message and the agent response message. -var provider = agent.GetService(); -List? chatHistory = provider?.GetMessages(session); -Console.WriteLine($"\nChat history has {chatHistory?.Count} messages.\n"); +if (session.TryGetInMemoryChatHistory(out var chatHistory)) +{ + Console.WriteLine($"\nChat history has {chatHistory.Count} messages.\n"); +} // Invoke the agent a few more times. Console.WriteLine(await agent.RunAsync("Tell me a joke about a robot.", session)); @@ -51,16 +51,22 @@ // to trigger the reducer is just before messages are contributed to a new agent run. // So at this time, we have not yet triggered the reducer for the most recently added messages, // and they are still in the chat history. -chatHistory = provider?.GetMessages(session); -Console.WriteLine($"\nChat history has {chatHistory?.Count} messages.\n"); +if (session.TryGetInMemoryChatHistory(out chatHistory)) +{ + Console.WriteLine($"\nChat history has {chatHistory.Count} messages.\n"); +} Console.WriteLine(await agent.RunAsync("Tell me a joke about a lemur.", session)); -chatHistory = provider?.GetMessages(session); -Console.WriteLine($"\nChat history has {chatHistory?.Count} messages.\n"); +if (session.TryGetInMemoryChatHistory(out chatHistory)) +{ + Console.WriteLine($"\nChat history has {chatHistory.Count} messages.\n"); +} // At this point, the chat history has exceeded the limit and the original message will not exist anymore, // so asking a follow up question about it may not work as expected. Console.WriteLine(await agent.RunAsync("What was the first joke I asked you to tell again?", session)); -chatHistory = provider?.GetMessages(session); -Console.WriteLine($"\nChat history has {chatHistory?.Count} messages.\n"); +if (session.TryGetInMemoryChatHistory(out chatHistory)) +{ + Console.WriteLine($"\nChat history has {chatHistory.Count} messages.\n"); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionExtensions.cs new file mode 100644 index 0000000000..b61c29650a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionExtensions.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Provides extension methods for . +/// +public static class AgentSessionExtensions +{ + /// + /// Attempts to retrieve the in-memory chat history messages associated with the specified agent session, if the agent is storing memories in the session using the + /// + /// + /// This method is only applicable when using and if the service does not require in-service chat history storage. + /// + /// The agent session from which to retrieve in-memory chat history. + /// When this method returns, contains the list of chat history messages if available; otherwise, null. + /// An optional key used to identify the chat history state in the session's state bag. If null, the default key for + /// in-memory chat history is used. + /// Optional JSON serializer options to use when accessing the session state. If null, default options are used. + /// if the in-memory chat history messages were found and retrieved; otherwise. + public static bool TryGetInMemoryChatHistory(this AgentSession session, [MaybeNullWhen(false)] out List messages, string? stateKey = null, JsonSerializerOptions? jsonSerializerOptions = null) + { + _ = Throw.IfNull(session); + + if (session.StateBag.TryGetValue(stateKey ?? nameof(InMemoryChatHistoryProvider), out InMemoryChatHistoryProvider.State? state, jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions) && state?.Messages is not null) + { + messages = state.Messages; + return true; + } + + messages = null; + return false; + } + + /// + /// Sets the in-memory chat message history for the specified agent session, replacing any existing messages. + /// + /// + /// This method is only applicable when using and if the service does not require in-service chat history storage. + /// If messages are set, but a different is used, or if chat history is stored in the underlying AI service, the messages will be ignored. + /// + /// The agent session whose in-memory chat history will be updated. + /// The list of chat messages to store in memory for the session. Replaces any existing messages for the specified + /// state key. + /// The key used to identify the in-memory chat history within the session's state bag. If null, a default key is + /// used. + /// The serializer options used when accessing or storing the state. If null, default options are applied. + public static void SetInMemoryChatHistory(this AgentSession session, List messages, string? stateKey = null, JsonSerializerOptions? jsonSerializerOptions = null) + { + _ = Throw.IfNull(session); + + if (session.StateBag.TryGetValue(stateKey ?? nameof(InMemoryChatHistoryProvider), out InMemoryChatHistoryProvider.State? state, jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions) && state is not null) + { + state.Messages = messages; + return; + } + + session.StateBag.SetValue(stateKey ?? nameof(InMemoryChatHistoryProvider), new InMemoryChatHistoryProvider.State() { Messages = messages }, jsonSerializerOptions ?? AgentAbstractionsJsonUtilities.DefaultOptions); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index b1dbacd438..d52ea52e43 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -109,7 +109,7 @@ public ChatClientAgent(IChatClient chatClient, ChatClientAgentOptions? options, // Use the ChatHistoryProvider from options if provided. // If one was not provided, and we later find out that the underlying service does not manage chat history server-side, // we will use the default InMemoryChatHistoryProvider at that time. - this.ChatHistoryProvider = options?.ChatHistoryProvider; + this.ChatHistoryProvider = options?.ChatHistoryProvider ?? new InMemoryChatHistoryProvider(); this.AIContextProviders = this._agentOptions?.AIContextProviders as IReadOnlyList ?? this._agentOptions?.AIContextProviders?.ToList(); // Validate that no two providers share the same StateKey, since they would overwrite each other's state in the session. @@ -743,25 +743,31 @@ private void UpdateSessionConversationId(ChatClientAgentSession session, string? if (!string.IsNullOrWhiteSpace(responseConversationId)) { - if (this.ChatHistoryProvider is not null) + if (this._agentOptions?.ChatHistoryProvider is not null) { // The agent has a ChatHistoryProvider configured, but the service returned a conversation id, // meaning the service manages chat history server-side. Both cannot be used simultaneously. - throw new InvalidOperationException( - $"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. The service returned a conversation id indicating server-side chat history management, but the agent has a {nameof(this.ChatHistoryProvider)} configured."); + if (this._agentOptions?.WarnOnChatHistoryProviderConflict is true) + { + this._logger.LogAgentChatClientHistoryProviderConflict(nameof(ChatClientAgentSession.ConversationId), nameof(this.ChatHistoryProvider), this.Id, this.GetLoggingAgentName()); + } + + if (this._agentOptions?.ThrowOnChatHistoryProviderConflict is true) + { + throw new InvalidOperationException( + $"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. The service returned a conversation id indicating server-side chat history management, but the agent has a {nameof(this.ChatHistoryProvider)} configured."); + } + + if (this._agentOptions?.ClearOnChatHistoryProviderConflict is true) + { + this.ChatHistoryProvider = null; + } } // If we got a conversation id back from the chat client, it means that the service supports server side session storage // so we should update the session with the new id. session.ConversationId = responseConversationId; } - else - { - // If the service doesn't use service side chat history storage (i.e. we got no id back from invocation), and - // the agent has no ChatHistoryProvider yet, we should use the default InMemoryChatHistoryProvider so that - // we have somewhere to store the chat history. - this.ChatHistoryProvider ??= new InMemoryChatHistoryProvider(); - } } private Task NotifyChatHistoryProviderOfFailureAsync( @@ -807,13 +813,7 @@ private Task NotifyChatHistoryProviderOfNewMessagesAsync( private ChatHistoryProvider? ResolveChatHistoryProvider(ChatOptions? chatOptions, ChatClientAgentSession session) { - ChatHistoryProvider? provider = this.ChatHistoryProvider; - - if (session.ConversationId is not null && provider is not null) - { - throw new InvalidOperationException( - $"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. The current {nameof(ChatClientAgentSession)} has a {nameof(ChatClientAgentSession.ConversationId)} indicating server-side chat history management, but the agent has a {nameof(this.ChatHistoryProvider)} configured."); - } + ChatHistoryProvider? provider = session.ConversationId is null ? this.ChatHistoryProvider : null; // If someone provided an override ChatHistoryProvider via AdditionalProperties, we should use that instead. if (chatOptions?.AdditionalProperties?.TryGetValue(out ChatHistoryProvider? overrideProvider) is true) diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentLogMessages.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentLogMessages.cs index a1804a0383..98ff4583dc 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentLogMessages.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentLogMessages.cs @@ -56,4 +56,17 @@ public static partial void LogAgentChatClientInvokedStreamingAgent( string agentId, string agentName, Type clientType); + + /// + /// Logs warning about conflict. + /// + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Agent {AgentId}/{AgentName}: Only {ConversationIdName} or {ChatHistoryProviderName} may be used, but not both. The service returned a conversation id indicating server-side chat history management, but the agent has a {ChatHistoryProviderName} configured.")] + public static partial void LogAgentChatClientHistoryProviderConflict( + this ILogger logger, + string conversationIdName, + string chatHistoryProviderName, + string agentId, + string agentName); } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs index ddca9197ab..38cad40bbe 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs @@ -59,6 +59,36 @@ public sealed class ChatClientAgentOptions /// public bool UseProvidedChatClientAsIs { get; set; } + /// + /// Gets or sets a value indicating whether to set the to + /// if the underlying AI service indicates that it manages chat history (for example, by returning a conversation id in the response), but a is configured for the agent. + /// + /// + /// Note that even if this setting is set to , the will still not be used if the underlying AI service indicates that it manages chat history. + /// + /// + /// Default is . + /// + public bool ClearOnChatHistoryProviderConflict { get; set; } = true; + + /// + /// Gets or sets a value indicating whether to log a warning if the underlying AI service indicates that it manages chat history + /// (for example, by returning a conversation id in the response), but a is configured for the agent. + /// + /// + /// Default is . + /// + public bool WarnOnChatHistoryProviderConflict { get; set; } = true; + + /// + /// Gets or sets a value indicating whether an exception is thrown if the underlying AI service indicates that it manages chat history + /// (for example, by returning a conversation id in the response), but a is configured for the agent. + /// + /// + /// Default is . + /// + public bool ThrowOnChatHistoryProviderConflict { get; set; } = true; + /// /// Creates a new instance of with the same values as this instance. /// @@ -71,5 +101,9 @@ public ChatClientAgentOptions Clone() ChatOptions = this.ChatOptions?.Clone(), ChatHistoryProvider = this.ChatHistoryProvider, AIContextProviders = this.AIContextProviders is null ? null : new List(this.AIContextProviders), + UseProvidedChatClientAsIs = this.UseProvidedChatClientAsIs, + ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict, + WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict, + ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict, }; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionExtensionsTests.cs new file mode 100644 index 0000000000..7d06fa854d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionExtensionsTests.cs @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests; + +/// +/// Tests for . +/// +public class AgentSessionExtensionsTests +{ + #region TryGetInMemoryChatHistory Tests + + [Fact] + public void TryGetInMemoryChatHistory_WithNullSession_ThrowsArgumentNullException() + { + // Arrange + AgentSession session = null!; + + // Act & Assert + Assert.Throws(() => session.TryGetInMemoryChatHistory(out _)); + } + + [Fact] + public void TryGetInMemoryChatHistory_WhenStateExists_ReturnsTrueAndMessages() + { + // Arrange + var session = new Mock().Object; + var expectedMessages = new List + { + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi there!") + }; + + session.StateBag.SetValue( + nameof(InMemoryChatHistoryProvider), + new InMemoryChatHistoryProvider.State { Messages = expectedMessages }); + + // Act + var result = session.TryGetInMemoryChatHistory(out var messages); + + // Assert + Assert.True(result); + Assert.NotNull(messages); + Assert.Same(expectedMessages, messages); + } + + [Fact] + public void TryGetInMemoryChatHistory_WhenStateDoesNotExist_ReturnsFalse() + { + // Arrange + var session = new Mock().Object; + + // Act + var result = session.TryGetInMemoryChatHistory(out var messages); + + // Assert + Assert.False(result); + Assert.Null(messages); + } + + [Fact] + public void TryGetInMemoryChatHistory_WithCustomStateKey_UsesCustomKey() + { + // Arrange + var session = new Mock().Object; + const string CustomKey = "custom-history-key"; + var expectedMessages = new List + { + new(ChatRole.User, "Test message") + }; + + session.StateBag.SetValue( + CustomKey, + new InMemoryChatHistoryProvider.State { Messages = expectedMessages }); + + // Act + var result = session.TryGetInMemoryChatHistory(out var messages, stateKey: CustomKey); + + // Assert + Assert.True(result); + Assert.NotNull(messages); + Assert.Same(expectedMessages, messages); + } + + [Fact] + public void TryGetInMemoryChatHistory_WithCustomStateKey_DoesNotFindDefaultKey() + { + // Arrange + var session = new Mock().Object; + var expectedMessages = new List + { + new(ChatRole.User, "Test message") + }; + + session.StateBag.SetValue( + nameof(InMemoryChatHistoryProvider), + new InMemoryChatHistoryProvider.State { Messages = expectedMessages }); + + // Act + var result = session.TryGetInMemoryChatHistory(out var messages, stateKey: "other-key"); + + // Assert + Assert.False(result); + Assert.Null(messages); + } + + [Fact] + public void TryGetInMemoryChatHistory_WhenStateExistsWithNullMessages_ReturnsFalse() + { + // Arrange + var session = new Mock().Object; + session.StateBag.SetValue( + nameof(InMemoryChatHistoryProvider), + new InMemoryChatHistoryProvider.State { Messages = null! }); + + // Act + var result = session.TryGetInMemoryChatHistory(out var messages); + + // Assert + Assert.False(result); + Assert.Null(messages); + } + + #endregion + + #region SetInMemoryChatHistory Tests + + [Fact] + public void SetInMemoryChatHistory_WithNullSession_ThrowsArgumentNullException() + { + // Arrange + AgentSession session = null!; + var messages = new List(); + + // Act & Assert + Assert.Throws(() => session.SetInMemoryChatHistory(messages)); + } + + [Fact] + public void SetInMemoryChatHistory_WhenNoExistingState_CreatesNewState() + { + // Arrange + var session = new Mock().Object; + var messages = new List + { + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi!") + }; + + // Act + session.SetInMemoryChatHistory(messages); + + // Assert + var result = session.TryGetInMemoryChatHistory(out var retrievedMessages); + Assert.True(result); + Assert.Same(messages, retrievedMessages); + } + + [Fact] + public void SetInMemoryChatHistory_WhenExistingState_ReplacesMessages() + { + // Arrange + var session = new Mock().Object; + var originalMessages = new List + { + new(ChatRole.User, "Original") + }; + var newMessages = new List + { + new(ChatRole.User, "New message"), + new(ChatRole.Assistant, "New response") + }; + + session.SetInMemoryChatHistory(originalMessages); + + // Act + session.SetInMemoryChatHistory(newMessages); + + // Assert + var result = session.TryGetInMemoryChatHistory(out var retrievedMessages); + Assert.True(result); + Assert.Same(newMessages, retrievedMessages); + } + + [Fact] + public void SetInMemoryChatHistory_WithCustomStateKey_UsesCustomKey() + { + // Arrange + var session = new Mock().Object; + const string CustomKey = "custom-history-key"; + var messages = new List + { + new(ChatRole.User, "Test") + }; + + // Act + session.SetInMemoryChatHistory(messages, stateKey: CustomKey); + + // Assert + var result = session.TryGetInMemoryChatHistory(out var retrievedMessages, stateKey: CustomKey); + Assert.True(result); + Assert.Same(messages, retrievedMessages); + + // Verify default key is not set + var defaultResult = session.TryGetInMemoryChatHistory(out _); + Assert.False(defaultResult); + } + + [Fact] + public void SetInMemoryChatHistory_WithEmptyList_SetsEmptyList() + { + // Arrange + var session = new Mock().Object; + var messages = new List(); + + // Act + session.SetInMemoryChatHistory(messages); + + // Assert + var result = session.TryGetInMemoryChatHistory(out var retrievedMessages); + Assert.True(result); + Assert.NotNull(retrievedMessages); + Assert.Empty(retrievedMessages); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentOptionsTests.cs index b8e3c57af6..1798afb433 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentOptionsTests.cs @@ -23,6 +23,10 @@ public void DefaultConstructor_InitializesWithNullValues() Assert.Null(options.ChatOptions); Assert.Null(options.ChatHistoryProvider); Assert.Null(options.AIContextProviders); + Assert.False(options.UseProvidedChatClientAsIs); + Assert.True(options.ClearOnChatHistoryProviderConflict); + Assert.True(options.WarnOnChatHistoryProviderConflict); + Assert.True(options.ThrowOnChatHistoryProviderConflict); } [Fact] @@ -125,7 +129,11 @@ public void Clone_CreatesDeepCopyWithSameValues() ChatOptions = new() { Tools = tools }, Id = "test-id", ChatHistoryProvider = mockChatHistoryProvider, - AIContextProviders = [mockAIContextProvider] + AIContextProviders = [mockAIContextProvider], + UseProvidedChatClientAsIs = true, + ClearOnChatHistoryProviderConflict = false, + WarnOnChatHistoryProviderConflict = false, + ThrowOnChatHistoryProviderConflict = false, }; // Act @@ -138,6 +146,10 @@ public void Clone_CreatesDeepCopyWithSameValues() Assert.Equal(original.Description, clone.Description); Assert.Same(original.ChatHistoryProvider, clone.ChatHistoryProvider); Assert.Equal(original.AIContextProviders, clone.AIContextProviders); + Assert.Equal(original.UseProvidedChatClientAsIs, clone.UseProvidedChatClientAsIs); + Assert.Equal(original.ClearOnChatHistoryProviderConflict, clone.ClearOnChatHistoryProviderConflict); + Assert.Equal(original.WarnOnChatHistoryProviderConflict, clone.WarnOnChatHistoryProviderConflict); + Assert.Equal(original.ThrowOnChatHistoryProviderConflict, clone.ThrowOnChatHistoryProviderConflict); // ChatOptions should be cloned, not the same reference Assert.NotSame(original.ChatOptions, clone.ChatOptions); 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 423c867abd..4d8326269a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_ChatHistoryManagementTests.cs @@ -291,6 +291,124 @@ public async Task RunAsync_Throws_WhenChatHistoryProviderProvidedAndConversation Assert.Equal("Only ConversationId or ChatHistoryProvider may be used, but not both. The service returned a conversation id indicating server-side chat history management, but the agent has a ChatHistoryProvider configured.", exception.Message); } + /// + /// Verify that RunAsync clears the ChatHistoryProvider when ThrowOnChatHistoryProviderConflict is false + /// and ClearOnChatHistoryProviderConflict is true. + /// + [Fact] + public async Task RunAsync_ClearsChatHistoryProvider_WhenThrowDisabledAndClearEnabledAsync() + { + // 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" }, + ChatHistoryProvider = new InMemoryChatHistoryProvider(), + ThrowOnChatHistoryProviderConflict = false, + ClearOnChatHistoryProviderConflict = true, + }); + + // Act + ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await agent.RunAsync([new(ChatRole.User, "test")], session); + + // Assert + Assert.Null(agent.ChatHistoryProvider); + Assert.Equal("ConvId", session!.ConversationId); + } + + /// + /// Verify that RunAsync does not throw and does not clear the ChatHistoryProvider when both + /// ThrowOnChatHistoryProviderConflict and ClearOnChatHistoryProviderConflict are false. + /// + [Fact] + public async Task RunAsync_KeepsChatHistoryProvider_WhenThrowAndClearDisabledAsync() + { + // Arrange + Mock mockService = new(); + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" }); + var chatHistoryProvider = new InMemoryChatHistoryProvider(); + ChatClientAgent agent = new(mockService.Object, options: new() + { + ChatOptions = new() { Instructions = "test instructions" }, + ChatHistoryProvider = chatHistoryProvider, + ThrowOnChatHistoryProviderConflict = false, + ClearOnChatHistoryProviderConflict = false, + WarnOnChatHistoryProviderConflict = false, + }); + + // Act + ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await agent.RunAsync([new(ChatRole.User, "test")], session); + + // Assert + Assert.Same(chatHistoryProvider, agent.ChatHistoryProvider); + Assert.Equal("ConvId", session!.ConversationId); + } + + /// + /// Verify that RunAsync still throws when ThrowOnChatHistoryProviderConflict is true + /// even if ClearOnChatHistoryProviderConflict is also true (throw takes precedence). + /// + [Fact] + public async Task RunAsync_Throws_WhenThrowEnabledRegardlessOfClearSettingAsync() + { + // 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" }, + ChatHistoryProvider = new InMemoryChatHistoryProvider(), + ThrowOnChatHistoryProviderConflict = true, + ClearOnChatHistoryProviderConflict = true, + }); + + // Act & Assert + ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await Assert.ThrowsAsync(() => agent.RunAsync([new(ChatRole.User, "test")], session)); + } + + /// + /// Verify that RunAsync does not throw when no ChatHistoryProvider is configured on options, + /// even if the service returns a conversation id (default InMemoryChatHistoryProvider is used but not from options). + /// + [Fact] + public async Task RunAsync_DoesNotThrow_WhenNoChatHistoryProviderInOptionsAndConversationIdReturnedAsync() + { + // 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" }, + }); + + // Act + ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession; + await agent.RunAsync([new(ChatRole.User, "test")], session); + + // Assert - no exception, session gets the conversation id + Assert.Equal("ConvId", session!.ConversationId); + } + #endregion #region ChatHistoryProvider Override Tests From e3328b10a6b649e05de61b7acfa1bb717a917429 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:23:05 +0000 Subject: [PATCH 2/2] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Microsoft.Agents.AI.Abstractions/AgentSessionExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionExtensions.cs index b61c29650a..dbc3b878bf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionExtensions.cs @@ -24,7 +24,7 @@ public static class AgentSessionExtensions /// An optional key used to identify the chat history state in the session's state bag. If null, the default key for /// in-memory chat history is used. /// Optional JSON serializer options to use when accessing the session state. If null, default options are used. - /// if the in-memory chat history messages were found and retrieved; otherwise. + /// if the in-memory chat history messages were found and retrieved; otherwise. public static bool TryGetInMemoryChatHistory(this AgentSession session, [MaybeNullWhen(false)] out List messages, string? stateKey = null, JsonSerializerOptions? jsonSerializerOptions = null) { _ = Throw.IfNull(session);