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);
+ }
}