From 6ce0447ff6d23a48729451cd8dc69ecd51f271f4 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Thu, 5 Mar 2026 03:00:32 -0800 Subject: [PATCH] Tests --- .../Compaction/CompactionStrategyTests.cs | 166 ++++++++++ .../Compaction/CompactionTriggersTests.cs | 25 ++ .../Compaction/MessageIndexTests.cs | 154 +++++++++ .../SlidingWindowCompactionStrategyTests.cs | 34 ++ .../SummarizationCompactionStrategyTests.cs | 302 ++++++++++++++++++ .../ToolResultCompactionStrategyTests.cs | 42 +++ .../TruncationCompactionStrategyTests.cs | 56 ++++ 7 files changed, 779 insertions(+) create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs new file mode 100644 index 0000000000..a89ac0702e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.UnitTests.Compaction; + +/// +/// Contains tests for the abstract base class. +/// +public class CompactionStrategyTests +{ + [Fact] + public void ConstructorNullTriggerThrows() + { + // Act & Assert + Assert.Throws(() => new TestStrategy(null!)); + } + + [Fact] + public async Task CompactAsyncTriggerNotMetReturnsFalseAsync() + { + // Arrange — trigger never fires + TestStrategy strategy = new(_ => false); + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert + Assert.False(result); + Assert.Equal(0, strategy.ApplyCallCount); + } + + [Fact] + public async Task CompactAsyncTriggerMetCallsApplyAsync() + { + // Arrange — trigger always fires + TestStrategy strategy = new(_ => true, applyFunc: _ => true); + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert + Assert.True(result); + Assert.Equal(1, strategy.ApplyCallCount); + } + + [Fact] + public async Task CompactAsyncReturnsFalseWhenApplyReturnsFalseAsync() + { + // Arrange — trigger fires but Apply does nothing + TestStrategy strategy = new(_ => true, applyFunc: _ => false); + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert + Assert.False(result); + Assert.Equal(1, strategy.ApplyCallCount); + } + + [Fact] + public async Task CompactAsyncDefaultTargetIsInverseOfTriggerAsync() + { + // Arrange — trigger fires when groups > 2 + // Default target should be: stop when groups <= 2 (i.e., !trigger) + CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2); + TestStrategy strategy = new(trigger, applyFunc: index => + { + // Exclude oldest non-system group one at a time + foreach (MessageGroup group in index.Groups) + { + if (!group.IsExcluded && group.Kind != MessageGroupKind.System) + { + group.IsExcluded = true; + // Target (default = !trigger) returns true when groups <= 2 + // So the strategy would check Target after this exclusion + break; + } + } + + return true; + }); + + 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 + bool result = await strategy.CompactAsync(index); + + // Assert — trigger fires (4 > 2), Apply is called + Assert.True(result); + Assert.Equal(1, strategy.ApplyCallCount); + } + + [Fact] + public async Task CompactAsyncCustomTargetIsPassedToStrategyAsync() + { + // Arrange — custom target that always signals stop + bool targetCalled = false; + CompactionTrigger customTarget = _ => + { + targetCalled = true; + return true; + }; + + TestStrategy strategy = new(_ => true, customTarget, _ => + { + // Access the target from within the strategy + return true; + }); + + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]); + + // Act + await strategy.CompactAsync(index); + + // Assert — the custom target is accessible (verified by TestStrategy checking it) + Assert.Equal(1, strategy.ApplyCallCount); + // The target is accessible to derived classes via the protected property + Assert.True(strategy.InvokeTarget(index)); + Assert.True(targetCalled); + } + + /// + /// A concrete test implementation of for testing the base class. + /// + private sealed class TestStrategy : CompactionStrategy + { + private readonly Func? _applyFunc; + + public TestStrategy( + CompactionTrigger trigger, + CompactionTrigger? target = null, + Func? applyFunc = null) + : base(trigger, target) + { + this._applyFunc = applyFunc; + } + + public int ApplyCallCount { get; private set; } + + /// + /// Exposes the protected Target property for test verification. + /// + public bool InvokeTarget(MessageIndex index) => this.Target(index); + + protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken) + { + this.ApplyCallCount++; + bool result = this._applyFunc?.Invoke(index) ?? false; + return Task.FromResult(result); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs index fc41d7b2f1..fe4ae320a9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs @@ -152,4 +152,29 @@ public class CompactionTriggersTests MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); Assert.False(trigger(index)); } + + [Fact] + public void TokensBelowReturnsTrueWhenBelowThreshold() + { + CompactionTrigger trigger = CompactionTriggers.TokensBelow(999_999); + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]); + + Assert.True(trigger(index)); + } + + [Fact] + public void TokensBelowReturnsFalseWhenAboveThreshold() + { + CompactionTrigger trigger = CompactionTriggers.TokensBelow(0); + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]); + + Assert.False(trigger(index)); + } + + [Fact] + public void AlwaysReturnsTrue() + { + MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]); + Assert.True(CompactionTriggers.Always(index)); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs index 3504e1a3d9..9f0eabf9d8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs @@ -521,4 +521,158 @@ public class MessageIndexTests Assert.Equal(1, groups.TotalTurnCount); Assert.Equal(1, groups.IncludedTurnCount); } + + [Fact] + public void UpdateAppendsNewMessagesIncrementally() + { + // Arrange — create with 2 messages + List messages = + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + ]; + MessageIndex index = MessageIndex.Create(messages); + Assert.Equal(2, index.Groups.Count); + Assert.Equal(2, index.ProcessedMessageCount); + + // Act — add 2 more messages and update + messages.Add(new ChatMessage(ChatRole.User, "Q2")); + messages.Add(new ChatMessage(ChatRole.Assistant, "A2")); + index.Update(messages); + + // Assert — should have 4 groups total, processed count updated + Assert.Equal(4, index.Groups.Count); + Assert.Equal(4, index.ProcessedMessageCount); + Assert.Equal(MessageGroupKind.User, index.Groups[2].Kind); + Assert.Equal(MessageGroupKind.AssistantText, index.Groups[3].Kind); + } + + [Fact] + public void UpdateNoOpWhenNoNewMessages() + { + // Arrange + List messages = + [ + new ChatMessage(ChatRole.User, "Q1"), + ]; + MessageIndex index = MessageIndex.Create(messages); + int originalCount = index.Groups.Count; + + // Act — update with same count + index.Update(messages); + + // Assert — nothing changed + Assert.Equal(originalCount, index.Groups.Count); + } + + [Fact] + public void UpdateRebuildsWhenMessagesShrink() + { + // Arrange — create with 3 messages + List messages = + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + ]; + MessageIndex index = MessageIndex.Create(messages); + Assert.Equal(3, index.Groups.Count); + + // Exclude a group to verify rebuild clears state + index.Groups[0].IsExcluded = true; + + // Act — update with fewer messages (simulates storage compaction) + List shortened = + [ + new ChatMessage(ChatRole.User, "Q2"), + ]; + index.Update(shortened); + + // Assert — rebuilt from scratch + Assert.Single(index.Groups); + Assert.False(index.Groups[0].IsExcluded); + Assert.Equal(1, index.ProcessedMessageCount); + } + + [Fact] + public void UpdatePreservesExistingGroupExclusionState() + { + // Arrange + List messages = + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + ]; + MessageIndex index = MessageIndex.Create(messages); + index.Groups[0].IsExcluded = true; + index.Groups[0].ExcludeReason = "Test exclusion"; + + // Act — append new messages + messages.Add(new ChatMessage(ChatRole.User, "Q2")); + index.Update(messages); + + // Assert — original exclusion state preserved + Assert.True(index.Groups[0].IsExcluded); + Assert.Equal("Test exclusion", index.Groups[0].ExcludeReason); + Assert.Equal(3, index.Groups.Count); + } + + [Fact] + public void InsertGroupInsertsAtSpecifiedIndex() + { + // Arrange + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.User, "Q2"), + ]); + + // Act — insert between Q1 and Q2 + ChatMessage summaryMsg = new(ChatRole.Assistant, "[Summary]"); + MessageGroup inserted = index.InsertGroup(1, MessageGroupKind.Summary, [summaryMsg], turnIndex: 1); + + // Assert + Assert.Equal(3, index.Groups.Count); + Assert.Same(inserted, index.Groups[1]); + Assert.Equal(MessageGroupKind.Summary, index.Groups[1].Kind); + Assert.Equal("[Summary]", index.Groups[1].Messages[0].Text); + Assert.Equal(1, inserted.TurnIndex); + } + + [Fact] + public void AddGroupAppendsToEnd() + { + // Arrange + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + ]); + + // Act + ChatMessage msg = new(ChatRole.Assistant, "Appended"); + MessageGroup added = index.AddGroup(MessageGroupKind.AssistantText, [msg], turnIndex: 1); + + // Assert + Assert.Equal(2, index.Groups.Count); + Assert.Same(added, index.Groups[1]); + Assert.Equal("Appended", index.Groups[1].Messages[0].Text); + } + + [Fact] + public void InsertGroupComputesByteAndTokenCounts() + { + // Arrange + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + ]); + + // Act — insert a group with known text + ChatMessage msg = new(ChatRole.Assistant, "Hello"); // 5 bytes, ~1 token (5/4) + MessageGroup inserted = index.InsertGroup(0, MessageGroupKind.AssistantText, [msg]); + + // Assert + Assert.Equal(5, inserted.ByteCount); + Assert.Equal(1, inserted.TokenCount); // 5 / 4 = 1 (integer division) + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs index 1c23d9fddd..74d057e9f0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs @@ -157,4 +157,38 @@ public class SlidingWindowCompactionStrategyTests Assert.Equal("Q2", included[1].Text); Assert.Equal("A2", included[2].Text); } + + [Fact] + public async Task CompactAsyncCustomTargetStopsExcludingEarlyAsync() + { + // Arrange — 4 turns, maxTurns=1 means 3 should be excluded + // But custom target stops after removing 1 turn + int removeCount = 0; + CompactionTrigger targetAfterOne = _ => ++removeCount >= 1; + + SlidingWindowCompactionStrategy strategy = new( + maximumTurns: 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.Assistant, "A2"), + new ChatMessage(ChatRole.User, "Q3"), + new ChatMessage(ChatRole.Assistant, "A3"), + new ChatMessage(ChatRole.User, "Q4"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — only turn 1 excluded (target stopped after 1 removal) + Assert.True(result); + Assert.True(index.Groups[0].IsExcluded); // Q1 (turn 1) + Assert.True(index.Groups[1].IsExcluded); // A1 (turn 1) + Assert.False(index.Groups[2].IsExcluded); // Q2 (turn 2) — kept + Assert.False(index.Groups[3].IsExcluded); // A2 (turn 2) + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs new file mode 100644 index 0000000000..a71fc4697f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs @@ -0,0 +1,302 @@ +// 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 +{ + private static readonly CompactionTrigger AlwaysTrigger = _ => true; + + /// + /// 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), + preserveRecentGroups: 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."), + AlwaysTrigger, + preserveRecentGroups: 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(), + AlwaysTrigger, + preserveRecentGroups: 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."), + AlwaysTrigger, + preserveRecentGroups: 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(" "), + AlwaysTrigger, + preserveRecentGroups: 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(), + AlwaysTrigger, + preserveRecentGroups: 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, + AlwaysTrigger, + preserveRecentGroups: 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(), + AlwaysTrigger, + preserveRecentGroups: 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; + CompactionTrigger targetAfterOne = _ => ++exclusionCount >= 1; + + SummarizationCompactionStrategy strategy = new( + CreateMockChatClient("Partial summary."), + AlwaysTrigger, + preserveRecentGroups: 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."), + AlwaysTrigger, + preserveRecentGroups: 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); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs index ad4fa89bb5..39c03f2631 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs @@ -191,4 +191,46 @@ public class ToolResultCompactionStrategyTests // Assert Assert.True(result); } + + [Fact] + public async Task CompactAsyncTargetStopsCollapsingEarlyAsync() + { + // Arrange — 2 tool groups, target met after first collapse + int collapseCount = 0; + CompactionTrigger targetAfterOne = _ => ++collapseCount >= 1; + + ToolResultCompactionStrategy strategy = new( + trigger: _ => true, + preserveRecentGroups: 1, + target: targetAfterOne); + + MessageIndex index = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn1")]), + new ChatMessage(ChatRole.Tool, "result1"), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c2", "fn2")]), + new ChatMessage(ChatRole.Tool, "result2"), + new ChatMessage(ChatRole.User, "Q3"), + ]); + + // Act + bool result = await strategy.CompactAsync(index); + + // Assert — only first tool group collapsed, second left intact + Assert.True(result); + + // Count collapsed tool groups (excluded with ToolCall kind) + int collapsedToolGroups = 0; + foreach (MessageGroup group in index.Groups) + { + if (group.IsExcluded && group.Kind == MessageGroupKind.ToolCall) + { + collapsedToolGroups++; + } + } + + Assert.Equal(1, collapsedToolGroups); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs index 5d4ac003f6..1799758ddd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs @@ -220,4 +220,60 @@ public class TruncationCompactionStrategyTests // Assert Assert.False(result); } + + [Fact] + public async Task CompactAsyncCustomTargetStopsEarlyAsync() + { + // Arrange — always trigger, custom target stops after 1 exclusion + int targetChecks = 0; + CompactionTrigger targetAfterOne = _ => ++targetChecks >= 1; + + TruncationCompactionStrategy strategy = new( + s_alwaysTrigger, + preserveRecentGroups: 1, + target: targetAfterOne); + + MessageIndex groups = MessageIndex.Create( + [ + new ChatMessage(ChatRole.User, "Q1"), + new ChatMessage(ChatRole.Assistant, "A1"), + new ChatMessage(ChatRole.User, "Q2"), + new ChatMessage(ChatRole.User, "Q3"), + ]); + + // Act + bool result = await strategy.CompactAsync(groups); + + // Assert — only 1 group excluded (target met after first) + Assert.True(result); + Assert.True(groups.Groups[0].IsExcluded); + Assert.False(groups.Groups[1].IsExcluded); + Assert.False(groups.Groups[2].IsExcluded); + Assert.False(groups.Groups[3].IsExcluded); + } + + [Fact] + public async Task CompactAsyncIncrementalStopsAtTargetAsync() + { + // Arrange — trigger on groups > 2, target is default (inverse of trigger: groups <= 2) + TruncationCompactionStrategy strategy = new( + CompactionTriggers.GroupsExceed(2), + preserveRecentGroups: 1); + + MessageIndex groups = 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"), + ]); + + // Act — 5 groups, trigger fires (5 > 2), compacts until groups <= 2 + bool result = await strategy.CompactAsync(groups); + + // Assert — should stop at 2 included groups (not go all the way to 1) + Assert.True(result); + Assert.Equal(2, groups.IncludedGroupCount); + } }