.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
co-authored by Copilot
parent c9cd067be6
commit 822a3eb5ea
8 changed files with 510 additions and 29 deletions
@@ -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