// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Reflection; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; using Moq; namespace Microsoft.Agents.AI.UnitTests.Compaction; /// /// Contains tests for the class. /// public sealed class CompactingChatClientTests : IDisposable { /// /// Restores the static after each test. /// public void Dispose() { SetCurrentRunContext(null); } [Fact] public void ConstructorThrowsOnNullStrategyAsync() { Mock mockInner = new(); Assert.Throws(() => new CompactingChatClient(mockInner.Object, null!)); } [Fact] public async Task GetResponseAsyncNoContextPassesThroughAsync() { // Arrange — no CurrentRunContext set → passthrough ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]); Mock mockInner = new(); mockInner.Setup(c => c.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .ReturnsAsync(expectedResponse); TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); CompactingChatClient client = new(mockInner.Object, strategy); List messages = [ new ChatMessage(ChatRole.User, "Hello"), ]; // Act ChatResponse response = await client.GetResponseAsync(messages); // Assert Assert.Same(expectedResponse, response); mockInner.Verify(c => c.GetResponseAsync( messages, It.IsAny(), It.IsAny()), Times.Once); } [Fact] public async Task GetResponseAsyncWithContextAppliesCompactionAsync() { // Arrange — set CurrentRunContext so compaction runs ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Done")]); List? capturedMessages = null; Mock mockInner = new(); mockInner.Setup(c => c.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .Callback, ChatOptions?, CancellationToken>((msgs, _, _) => capturedMessages = [.. msgs]) .ReturnsAsync(expectedResponse); // Strategy that always triggers and keeps only 1 group TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1); CompactingChatClient client = new(mockInner.Object, strategy); TestAgentSession session = new(); SetRunContext(session); List messages = [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), new ChatMessage(ChatRole.User, "Q2"), ]; // Act ChatResponse response = await client.GetResponseAsync(messages); // Assert — compaction should have removed oldest groups Assert.Same(expectedResponse, response); Assert.NotNull(capturedMessages); Assert.True(capturedMessages!.Count < messages.Count); } [Fact] public async Task GetResponseAsyncNoCompactionNeededReturnsOriginalMessagesAsync() { // Arrange — trigger never fires → no compaction ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]); List? capturedMessages = null; Mock mockInner = new(); mockInner.Setup(c => c.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .Callback, ChatOptions?, CancellationToken>((msgs, _, _) => capturedMessages = [.. msgs]) .ReturnsAsync(expectedResponse); TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); CompactingChatClient client = new(mockInner.Object, strategy); TestAgentSession session = new(); SetRunContext(session); List messages = [ new ChatMessage(ChatRole.User, "Hello"), ]; // Act await client.GetResponseAsync(messages); // Assert — original messages passed through Assert.NotNull(capturedMessages); Assert.Single(capturedMessages!); Assert.Equal("Hello", capturedMessages[0].Text); } [Fact] public async Task GetResponseAsyncWithExistingIndexUpdatesAsync() { // Arrange — call twice to exercise the "existing index" path (state.MessageIndex.Count > 0) Mock mockInner = new(); mockInner.Setup(c => c.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "OK")])); // Strategy that always triggers, keeping 1 group TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1); CompactingChatClient client = new(mockInner.Object, strategy); TestAgentSession session = new(); SetRunContext(session); List messages1 = [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), new ChatMessage(ChatRole.User, "Q2"), ]; // First call — initializes state await client.GetResponseAsync(messages1); List messages2 = [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), new ChatMessage(ChatRole.User, "Q2"), new ChatMessage(ChatRole.Assistant, "A2"), new ChatMessage(ChatRole.User, "Q3"), ]; // Act — second call exercises the update path ChatResponse response = await client.GetResponseAsync(messages2); // Assert Assert.NotNull(response); } [Fact] public async Task GetResponseAsyncNullSessionReturnsOriginalAsync() { // Arrange — CurrentRunContext exists but Session is null ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]); Mock mockInner = new(); mockInner.Setup(c => c.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .ReturnsAsync(expectedResponse); TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); CompactingChatClient client = new(mockInner.Object, strategy); // Set context with null session SetRunContext(null); List messages = [new ChatMessage(ChatRole.User, "Hello")]; // Act ChatResponse response = await client.GetResponseAsync(messages); // Assert Assert.Same(expectedResponse, response); } [Fact] public async Task GetStreamingResponseAsyncNoContextPassesThroughAsync() { // Arrange — no CurrentRunContext Mock mockInner = new(); ChatResponseUpdate[] updates = [new(ChatRole.Assistant, "Hi")]; mockInner.Setup(c => c.GetStreamingResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .Returns(ToAsyncEnumerableAsync(updates)); TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); CompactingChatClient client = new(mockInner.Object, strategy); List messages = [new ChatMessage(ChatRole.User, "Hello")]; // Act List results = []; await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(messages)) { results.Add(update); } // Assert Assert.Single(results); Assert.Equal("Hi", results[0].Text); } [Fact] public async Task GetStreamingResponseAsyncWithContextAppliesCompactionAsync() { // Arrange Mock mockInner = new(); ChatResponseUpdate[] updates = [new(ChatRole.Assistant, "Done")]; mockInner.Setup(c => c.GetStreamingResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .Returns(ToAsyncEnumerableAsync(updates)); TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1); CompactingChatClient client = new(mockInner.Object, strategy); TestAgentSession session = new(); SetRunContext(session); List messages = [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), new ChatMessage(ChatRole.User, "Q2"), ]; // Act List results = []; await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(messages)) { results.Add(update); } // Assert Assert.Single(results); Assert.Equal("Done", results[0].Text); } [Fact] public void GetServiceReturnsStrategyForMatchingType() { // Arrange Mock mockInner = new(); TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000)); CompactingChatClient client = new(mockInner.Object, strategy); // Act — typeof(Type).IsInstanceOfType(typeof(CompactionStrategy)) is true object? result = client.GetService(typeof(Type)); // Assert Assert.Same(strategy, result); } [Fact] public void GetServiceDelegatesToBaseForNonMatchingType() { // Arrange Mock mockInner = new(); TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000)); CompactingChatClient client = new(mockInner.Object, strategy); // Act — typeof(string) doesn't match object? result = client.GetService(typeof(string)); // Assert — delegates to base (which returns null for unregistered types) Assert.Null(result); } [Fact] public void GetServiceThrowsOnNullType() { Mock mockInner = new(); TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000)); CompactingChatClient client = new(mockInner.Object, strategy); Assert.Throws(() => client.GetService(null!)); } [Fact] public void GetServiceWithServiceKeyDelegatesToBase() { // Arrange — non-null serviceKey always delegates Mock mockInner = new(); TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000)); CompactingChatClient client = new(mockInner.Object, strategy); // Act object? result = client.GetService(typeof(Type), serviceKey: "mykey"); // Assert — delegates to base because serviceKey is non-null Assert.Null(result); } [Fact] public async Task GetResponseAsyncMessagesNotListCreatesListCopyAsync() { // Arrange — pass IEnumerable (not List) to exercise the list copy branch ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]); Mock mockInner = new(); mockInner.Setup(c => c.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .ReturnsAsync(expectedResponse); TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000)); CompactingChatClient client = new(mockInner.Object, strategy); TestAgentSession session = new(); SetRunContext(session); // Use an IEnumerable (not a List) to trigger the copy path IEnumerable messages = [new(ChatRole.User, "Hello")]; // Act ChatResponse response = await client.GetResponseAsync(messages); // Assert Assert.Same(expectedResponse, response); } /// /// Sets via reflection. /// private static void SetCurrentRunContext(AgentRunContext? context) { FieldInfo? field = typeof(AIAgent).GetField("s_currentContext", BindingFlags.NonPublic | BindingFlags.Static); Assert.NotNull(field); object? asyncLocal = field!.GetValue(null); Assert.NotNull(asyncLocal); PropertyInfo? valueProp = asyncLocal!.GetType().GetProperty("Value"); Assert.NotNull(valueProp); valueProp!.SetValue(asyncLocal, context); } /// /// Creates an with the given session and sets it as the current context. /// private static void SetRunContext(AgentSession? session) { Mock mockAgent = new() { CallBase = true }; AgentRunContext context = new( mockAgent.Object, session, [new(ChatRole.User, "test")], null); SetCurrentRunContext(context); } private static async IAsyncEnumerable ToAsyncEnumerableAsync( ChatResponseUpdate[] updates, [EnumeratorCancellation] CancellationToken cancellationToken = default) { foreach (ChatResponseUpdate update in updates) { cancellationToken.ThrowIfCancellationRequested(); yield return update; await Task.CompletedTask; } } private sealed class TestAgentSession : AgentSession; }