.NET: Add helpers to more easily access in-memory ChatHistory and make ChatHistoryProvider management more configurable. (#4224)

* Add helpers to more easily access in-memory ChatHistory and make ChatHistoryProvider management more configurable.

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
westey
2026-02-26 14:16:21 +00:00
committed by GitHub
Unverified
parent c9cd067be6
commit 822a3eb5ea
8 changed files with 510 additions and 29 deletions
@@ -10,7 +10,6 @@ using Azure.Identity;
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 @@ Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.", session
// 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<InMemoryChatHistoryProvider>();
List<ChatMessage>? 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 @@ Console.WriteLine(await agent.RunAsync("Tell me a joke about a robot.", session)
// 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");
}
@@ -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;
/// <summary>
/// Provides extension methods for <see cref="AgentSession"/>.
/// </summary>
public static class AgentSessionExtensions
{
/// <summary>
/// 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 <see cref="InMemoryChatHistoryProvider"/>
/// </summary>
/// <remarks>
/// This method is only applicable when using <see cref="InMemoryChatHistoryProvider"/> and if the service does not require in-service chat history storage.
/// </remarks>
/// <param name="session">The agent session from which to retrieve in-memory chat history.</param>
/// <param name="messages">When this method returns, contains the list of chat history messages if available; otherwise, null.</param>
/// <param name="stateKey">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.</param>
/// <param name="jsonSerializerOptions">Optional JSON serializer options to use when accessing the session state. If null, default options are used.</param>
/// <returns><see langword="true"/> if the in-memory chat history messages were found and retrieved; <see langword="false"/> otherwise.</returns>
public static bool TryGetInMemoryChatHistory(this AgentSession session, [MaybeNullWhen(false)] out List<ChatMessage> 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;
}
/// <summary>
/// Sets the in-memory chat message history for the specified agent session, replacing any existing messages.
/// </summary>
/// <remarks>
/// This method is only applicable when using <see cref="InMemoryChatHistoryProvider"/> and if the service does not require in-service chat history storage.
/// If messages are set, but a different <see cref="ChatHistoryProvider"/> is used, or if chat history is stored in the underlying AI service, the messages will be ignored.
/// </remarks>
/// <param name="session">The agent session whose in-memory chat history will be updated.</param>
/// <param name="messages">The list of chat messages to store in memory for the session. Replaces any existing messages for the specified
/// state key.</param>
/// <param name="stateKey">The key used to identify the in-memory chat history within the session's state bag. If null, a default key is
/// used.</param>
/// <param name="jsonSerializerOptions">The serializer options used when accessing or storing the state. If null, default options are applied.</param>
public static void SetInMemoryChatHistory(this AgentSession session, List<ChatMessage> 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);
}
}
@@ -109,7 +109,7 @@ public sealed partial class ChatClientAgent : AIAgent
// 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<AIContextProvider> ?? 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 @@ public sealed partial class ChatClientAgent : AIAgent
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 @@ public sealed partial class ChatClientAgent : AIAgent
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)
@@ -56,4 +56,17 @@ internal static partial class ChatClientAgentLogMessages
string agentId,
string agentName,
Type clientType);
/// <summary>
/// Logs <see cref="ChatClientAgent"/> warning about <see cref="ChatHistoryProvider"/> conflict.
/// </summary>
[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);
}
@@ -59,6 +59,36 @@ public sealed class ChatClientAgentOptions
/// </remarks>
public bool UseProvidedChatClientAsIs { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to set the <see cref="ChatClientAgent.ChatHistoryProvider"/> to <see langword="null"/>
/// if the underlying AI service indicates that it manages chat history (for example, by returning a conversation id in the response), but a <see cref="ChatHistoryProvider"/> is configured for the agent.
/// </summary>
/// <remarks>
/// Note that even if this setting is set to <see langword="false"/>, the <see cref="ChatHistoryProvider"/> will still not be used if the underlying AI service indicates that it manages chat history.
/// </remarks>
/// <value>
/// Default is <see langword="true"/>.
/// </value>
public bool ClearOnChatHistoryProviderConflict { get; set; } = true;
/// <summary>
/// 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 <see cref="ChatHistoryProvider"/> is configured for the agent.
/// </summary>
/// <value>
/// Default is <see langword="true"/>.
/// </value>
public bool WarnOnChatHistoryProviderConflict { get; set; } = true;
/// <summary>
/// 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 <see cref="ChatHistoryProvider"/> is configured for the agent.
/// </summary>
/// <value>
/// Default is <see langword="true"/>.
/// </value>
public bool ThrowOnChatHistoryProviderConflict { get; set; } = true;
/// <summary>
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
/// </summary>
@@ -71,5 +101,9 @@ public sealed class ChatClientAgentOptions
ChatOptions = this.ChatOptions?.Clone(),
ChatHistoryProvider = this.ChatHistoryProvider,
AIContextProviders = this.AIContextProviders is null ? null : new List<AIContextProvider>(this.AIContextProviders),
UseProvidedChatClientAsIs = this.UseProvidedChatClientAsIs,
ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict,
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
};
}
@@ -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;
/// <summary>
/// Tests for <see cref="AgentSessionExtensions"/>.
/// </summary>
public class AgentSessionExtensionsTests
{
#region TryGetInMemoryChatHistory Tests
[Fact]
public void TryGetInMemoryChatHistory_WithNullSession_ThrowsArgumentNullException()
{
// Arrange
AgentSession session = null!;
// Act & Assert
Assert.Throws<ArgumentNullException>(() => session.TryGetInMemoryChatHistory(out _));
}
[Fact]
public void TryGetInMemoryChatHistory_WhenStateExists_ReturnsTrueAndMessages()
{
// Arrange
var session = new Mock<AgentSession>().Object;
var expectedMessages = new List<ChatMessage>
{
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<AgentSession>().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<AgentSession>().Object;
const string CustomKey = "custom-history-key";
var expectedMessages = new List<ChatMessage>
{
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<AgentSession>().Object;
var expectedMessages = new List<ChatMessage>
{
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<AgentSession>().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<ChatMessage>();
// Act & Assert
Assert.Throws<ArgumentNullException>(() => session.SetInMemoryChatHistory(messages));
}
[Fact]
public void SetInMemoryChatHistory_WhenNoExistingState_CreatesNewState()
{
// Arrange
var session = new Mock<AgentSession>().Object;
var messages = new List<ChatMessage>
{
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<AgentSession>().Object;
var originalMessages = new List<ChatMessage>
{
new(ChatRole.User, "Original")
};
var newMessages = new List<ChatMessage>
{
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<AgentSession>().Object;
const string CustomKey = "custom-history-key";
var messages = new List<ChatMessage>
{
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<AgentSession>().Object;
var messages = new List<ChatMessage>();
// Act
session.SetInMemoryChatHistory(messages);
// Assert
var result = session.TryGetInMemoryChatHistory(out var retrievedMessages);
Assert.True(result);
Assert.NotNull(retrievedMessages);
Assert.Empty(retrievedMessages);
}
#endregion
}
@@ -23,6 +23,10 @@ public class ChatClientAgentOptionsTests
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 class ChatClientAgentOptionsTests
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 class ChatClientAgentOptionsTests
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);
@@ -291,6 +291,124 @@ public class ChatClientAgent_ChatHistoryManagementTests
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);
}
/// <summary>
/// Verify that RunAsync clears the ChatHistoryProvider when ThrowOnChatHistoryProviderConflict is false
/// and ClearOnChatHistoryProviderConflict is true.
/// </summary>
[Fact]
public async Task RunAsync_ClearsChatHistoryProvider_WhenThrowDisabledAndClearEnabledAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).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);
}
/// <summary>
/// Verify that RunAsync does not throw and does not clear the ChatHistoryProvider when both
/// ThrowOnChatHistoryProviderConflict and ClearOnChatHistoryProviderConflict are false.
/// </summary>
[Fact]
public async Task RunAsync_KeepsChatHistoryProvider_WhenThrowAndClearDisabledAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).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);
}
/// <summary>
/// Verify that RunAsync still throws when ThrowOnChatHistoryProviderConflict is true
/// even if ClearOnChatHistoryProviderConflict is also true (throw takes precedence).
/// </summary>
[Fact]
public async Task RunAsync_Throws_WhenThrowEnabledRegardlessOfClearSettingAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).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<InvalidOperationException>(() => agent.RunAsync([new(ChatRole.User, "test")], session));
}
/// <summary>
/// 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).
/// </summary>
[Fact]
public async Task RunAsync_DoesNotThrow_WhenNoChatHistoryProviderInOptionsAndConversationIdReturnedAsync()
{
// Arrange
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).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