// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Linq; 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 class SummarizationCompactionStrategyTests { /// /// Creates a mock that returns the specified summary text. /// private static IChatClient CreateMockChatClient(string summaryText = "Summary of conversation.") { Mock mock = new(); mock.Setup(c => c.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, summaryText)])); return mock.Object; } [Fact] public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() { // Arrange — trigger requires > 100000 tokens SummarizationCompactionStrategy strategy = new( CreateMockChatClient(), CompactionTriggers.TokensExceed(100000), minimumPreserved: 1); MessageIndex index = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), ]); // Act bool result = await strategy.CompactAsync(index); // Assert Assert.False(result); Assert.Equal(2, index.IncludedGroupCount); } [Fact] public async Task CompactAsyncSummarizesOldGroupsAsync() { // Arrange — always trigger, preserve 1 recent group SummarizationCompactionStrategy strategy = new( CreateMockChatClient("Key facts from earlier."), CompactionTriggers.Always, minimumPreserved: 1); MessageIndex index = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "First question"), new ChatMessage(ChatRole.Assistant, "First answer"), new ChatMessage(ChatRole.User, "Second question"), ]); // Act bool result = await strategy.CompactAsync(index); // Assert Assert.True(result); List included = [.. index.GetIncludedMessages()]; // Should have: summary + preserved recent group (Second question) Assert.Equal(2, included.Count); Assert.Contains("[Summary]", included[0].Text); Assert.Contains("Key facts from earlier.", included[0].Text); Assert.Equal("Second question", included[1].Text); } [Fact] public async Task CompactAsyncPreservesSystemMessagesAsync() { // Arrange SummarizationCompactionStrategy strategy = new( CreateMockChatClient(), CompactionTriggers.Always, minimumPreserved: 1); MessageIndex index = MessageIndex.Create( [ new ChatMessage(ChatRole.System, "You are helpful."), new ChatMessage(ChatRole.User, "Old question"), new ChatMessage(ChatRole.Assistant, "Old answer"), new ChatMessage(ChatRole.User, "Recent question"), ]); // Act await strategy.CompactAsync(index); // Assert List included = [.. index.GetIncludedMessages()]; Assert.Equal("You are helpful.", included[0].Text); Assert.Equal(ChatRole.System, included[0].Role); } [Fact] public async Task CompactAsyncInsertsSummaryGroupAtCorrectPositionAsync() { // Arrange SummarizationCompactionStrategy strategy = new( CreateMockChatClient("Summary text."), CompactionTriggers.Always, minimumPreserved: 1); MessageIndex index = MessageIndex.Create( [ new ChatMessage(ChatRole.System, "System prompt."), new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), new ChatMessage(ChatRole.User, "Q2"), ]); // Act await strategy.CompactAsync(index); // Assert — summary should be inserted after system, before preserved group MessageGroup summaryGroup = index.Groups.First(g => g.Kind == MessageGroupKind.Summary); Assert.NotNull(summaryGroup); Assert.Contains("[Summary]", summaryGroup.Messages[0].Text); Assert.True(summaryGroup.Messages[0].AdditionalProperties!.ContainsKey(MessageGroup.SummaryPropertyKey)); } [Fact] public async Task CompactAsyncHandlesEmptyLlmResponseAsync() { // Arrange — LLM returns whitespace SummarizationCompactionStrategy strategy = new( CreateMockChatClient(" "), CompactionTriggers.Always, minimumPreserved: 1); MessageIndex index = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.User, "Q2"), ]); // Act await strategy.CompactAsync(index); // Assert — should use fallback text List included = [.. index.GetIncludedMessages()]; Assert.Contains("[Summary unavailable]", included[0].Text); } [Fact] public async Task CompactAsyncNothingToSummarizeReturnsFalseAsync() { // Arrange — preserve 5 but only 2 non-system groups SummarizationCompactionStrategy strategy = new( CreateMockChatClient(), CompactionTriggers.Always, minimumPreserved: 5); MessageIndex index = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, "Hi!"), ]); // Act bool result = await strategy.CompactAsync(index); // Assert Assert.False(result); } [Fact] public async Task CompactAsyncUsesCustomPromptAsync() { // Arrange — capture the messages sent to the chat client List? capturedMessages = null; Mock mockClient = new(); mockClient.Setup(c => c.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .Callback, ChatOptions?, CancellationToken>((msgs, _, _) => capturedMessages = [.. msgs]) .ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Custom summary.")])); const string CustomPrompt = "Summarize in bullet points only."; SummarizationCompactionStrategy strategy = new( mockClient.Object, CompactionTriggers.Always, minimumPreserved: 1, summarizationPrompt: CustomPrompt); MessageIndex index = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.User, "Q2"), ]); // Act await strategy.CompactAsync(index); // Assert — the custom prompt should be the first message sent to the LLM Assert.NotNull(capturedMessages); Assert.Equal(CustomPrompt, capturedMessages![0].Text); } [Fact] public async Task CompactAsyncSetsExcludeReasonAsync() { // Arrange SummarizationCompactionStrategy strategy = new( CreateMockChatClient(), CompactionTriggers.Always, minimumPreserved: 1); MessageIndex index = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Old"), new ChatMessage(ChatRole.User, "New"), ]); // Act await strategy.CompactAsync(index); // Assert MessageGroup excluded = index.Groups.First(g => g.IsExcluded); Assert.NotNull(excluded.ExcludeReason); Assert.Contains("SummarizationCompactionStrategy", excluded.ExcludeReason); } [Fact] public async Task CompactAsyncTargetStopsMarkingEarlyAsync() { // Arrange — 4 non-system groups, preserve 1, target met after 1 exclusion int exclusionCount = 0; bool TargetAfterOne(MessageIndex _) => ++exclusionCount >= 1; SummarizationCompactionStrategy strategy = new( CreateMockChatClient("Partial summary."), CompactionTriggers.Always, minimumPreserved: 1, target: TargetAfterOne); MessageIndex index = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), new ChatMessage(ChatRole.User, "Q2"), new ChatMessage(ChatRole.User, "Q3"), ]); // Act await strategy.CompactAsync(index); // Assert — only 1 group should have been summarized (target met after first exclusion) int excludedCount = index.Groups.Count(g => g.IsExcluded); Assert.Equal(1, excludedCount); } [Fact] public async Task CompactAsyncPreservesMultipleRecentGroupsAsync() { // Arrange — preserve 2 SummarizationCompactionStrategy strategy = new( CreateMockChatClient("Summary."), CompactionTriggers.Always, minimumPreserved: 2); MessageIndex index = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), new ChatMessage(ChatRole.User, "Q2"), new ChatMessage(ChatRole.Assistant, "A2"), ]); // Act await strategy.CompactAsync(index); // Assert — 2 oldest excluded, 2 newest preserved + 1 summary inserted List included = [.. index.GetIncludedMessages()]; Assert.Equal(3, included.Count); // summary + Q2 + A2 Assert.Contains("[Summary]", included[0].Text); Assert.Equal("Q2", included[1].Text); Assert.Equal("A2", included[2].Text); } [Fact] public async Task CompactAsyncWithSystemBetweenSummarizableGroupsAsync() { // Arrange — system group between user/assistant groups to exercise skip logic in loop IChatClient mockClient = CreateMockChatClient("[Summary]"); SummarizationCompactionStrategy strategy = new( mockClient, CompactionTriggers.Always, minimumPreserved: 1); MessageIndex index = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.System, "System note"), new ChatMessage(ChatRole.Assistant, "A1"), new ChatMessage(ChatRole.User, "Q2"), ]); // Act bool result = await strategy.CompactAsync(index); // Assert — summary inserted at 0, system group shifted to index 2 Assert.True(result); Assert.Equal(MessageGroupKind.Summary, index.Groups[0].Kind); Assert.Equal(MessageGroupKind.System, index.Groups[2].Kind); Assert.False(index.Groups[2].IsExcluded); // System never excluded } [Fact] public async Task CompactAsyncMaxSummarizableBoundsLoopExitAsync() { // Arrange — large MinimumPreserved so maxSummarizable is small, target never stops IChatClient mockClient = CreateMockChatClient("[Summary]"); SummarizationCompactionStrategy strategy = new( mockClient, CompactionTriggers.Always, minimumPreserved: 3, target: _ => false); MessageIndex index = MessageIndex.Create( [ 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"), new ChatMessage(ChatRole.Assistant, "A3"), ]); // Act — should only summarize 6-3 = 3 groups (not all 6) bool result = await strategy.CompactAsync(index); // Assert — 3 preserved + 1 summary = 4 included Assert.True(result); Assert.Equal(4, index.IncludedGroupCount); } [Fact] public async Task CompactAsyncWithPreExcludedGroupAsync() { // Arrange — pre-exclude a group so the count and loop both must skip it IChatClient mockClient = CreateMockChatClient("[Summary]"); SummarizationCompactionStrategy strategy = new( mockClient, CompactionTriggers.Always, minimumPreserved: 1); MessageIndex index = MessageIndex.Create( [ new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), new ChatMessage(ChatRole.User, "Q2"), new ChatMessage(ChatRole.Assistant, "A2"), ]); index.Groups[0].IsExcluded = true; // Pre-exclude Q1 // Act bool result = await strategy.CompactAsync(index); // Assert Assert.True(result); Assert.True(index.Groups[0].IsExcluded); // Still excluded } [Fact] public async Task CompactAsyncWithEmptyTextMessageInGroupAsync() { // Arrange — a message with null text (FunctionCallContent) in a summarized group IChatClient mockClient = CreateMockChatClient("[Summary]"); SummarizationCompactionStrategy strategy = new( mockClient, CompactionTriggers.Always, minimumPreserved: 1); List messages = [ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]), new ChatMessage(ChatRole.User, "Q1"), new ChatMessage(ChatRole.Assistant, "A1"), ]; MessageIndex index = MessageIndex.Create(messages); // Act — the tool-call group's message has null text bool result = await strategy.CompactAsync(index); // Assert — compaction succeeded despite null text Assert.True(result); } }