This commit is contained in:
Chris Rickman
2026-03-05 03:00:32 -08:00
Unverified
parent defb9dd51c
commit 6ce0447ff6
7 changed files with 779 additions and 0 deletions
@@ -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;
/// <summary>
/// Contains tests for the <see cref="CompactionStrategy"/> abstract base class.
/// </summary>
public class CompactionStrategyTests
{
[Fact]
public void ConstructorNullTriggerThrows()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => 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);
}
/// <summary>
/// A concrete test implementation of <see cref="CompactionStrategy"/> for testing the base class.
/// </summary>
private sealed class TestStrategy : CompactionStrategy
{
private readonly Func<MessageIndex, bool>? _applyFunc;
public TestStrategy(
CompactionTrigger trigger,
CompactionTrigger? target = null,
Func<MessageIndex, bool>? applyFunc = null)
: base(trigger, target)
{
this._applyFunc = applyFunc;
}
public int ApplyCallCount { get; private set; }
/// <summary>
/// Exposes the protected Target property for test verification.
/// </summary>
public bool InvokeTarget(MessageIndex index) => this.Target(index);
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
{
this.ApplyCallCount++;
bool result = this._applyFunc?.Invoke(index) ?? false;
return Task.FromResult(result);
}
}
}
@@ -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));
}
}
@@ -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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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)
}
}
@@ -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)
}
}
@@ -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;
/// <summary>
/// Contains tests for the <see cref="SummarizationCompactionStrategy"/> class.
/// </summary>
public class SummarizationCompactionStrategyTests
{
private static readonly CompactionTrigger AlwaysTrigger = _ => true;
/// <summary>
/// Creates a mock <see cref="IChatClient"/> that returns the specified summary text.
/// </summary>
private static IChatClient CreateMockChatClient(string summaryText = "Summary of conversation.")
{
Mock<IChatClient> mock = new();
mock.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.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<ChatMessage> 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<ChatMessage> 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<ChatMessage> 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<ChatMessage>? capturedMessages = null;
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, 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<ChatMessage> 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);
}
}
@@ -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);
}
}
@@ -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);
}
}