diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs
index 44a426f1e2..d2064c4449 100644
--- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs
@@ -20,6 +20,12 @@ public static class CompactionTriggers
public static readonly CompactionTrigger Always =
_ => true;
+ ///
+ /// Always trigger compaction, regardless of the message index state.
+ ///
+ public static readonly CompactionTrigger Never =
+ _ => false;
+
///
/// Creates a trigger that fires when the included token count is below the specified maximum.
///
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs
index 80292c7ed8..c165d8c1e6 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs
@@ -1,8 +1,7 @@
-// Copyright (c) Microsoft. All rights reserved.
+// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
-using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Threading;
@@ -348,7 +347,7 @@ public sealed class CompactingChatClientTests : IDisposable
SetRunContext(session);
// Use an IEnumerable (not a List) to trigger the copy path
- IEnumerable messages = new ChatMessage[] { new(ChatRole.User, "Hello") };
+ IEnumerable messages = [new(ChatRole.User, "Hello")];
// Act
ChatResponse response = await client.GetResponseAsync(messages);
@@ -380,7 +379,7 @@ public sealed class CompactingChatClientTests : IDisposable
AgentRunContext context = new(
mockAgent.Object,
session,
- new List { new(ChatRole.User, "test") },
+ [new(ChatRole.User, "test")],
null);
SetCurrentRunContext(context);
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs
index b1fa5c59cb..b17b0d1b95 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs
@@ -800,6 +800,68 @@ public class MessageIndexTests
Assert.Equal(4, inserted.TokenCount);
}
+ [Fact]
+ public void CreateWithStandaloneToolMessageGroupsAsAssistantText()
+ {
+ // A Tool message not preceded by an assistant tool-call falls through to the else branch
+ List messages =
+ [
+ new ChatMessage(ChatRole.Tool, "Orphaned tool result"),
+ ];
+
+ MessageIndex index = MessageIndex.Create(messages);
+
+ // The Tool message should be grouped as AssistantText (the default fallback)
+ Assert.Single(index.Groups);
+ Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind);
+ }
+
+ [Fact]
+ public void CreateWithAssistantNonSummaryWithPropertiesFallsToAssistantText()
+ {
+ // Assistant message with AdditionalProperties but NOT a summary
+ ChatMessage assistant = new(ChatRole.Assistant, "Regular response");
+ (assistant.AdditionalProperties ??= [])["someOtherKey"] = "value";
+
+ MessageIndex index = MessageIndex.Create([assistant]);
+
+ Assert.Single(index.Groups);
+ Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind);
+ }
+
+ [Fact]
+ public void ComputeByteCountHandlesNullAndNonNullText()
+ {
+ // Mix of messages: one with text (non-null), one without (null Text)
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Hello"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
+ ];
+
+ int byteCount = MessageIndex.ComputeByteCount(messages);
+
+ // Only "Hello" contributes bytes (5 bytes UTF-8)
+ Assert.Equal(5, byteCount);
+ }
+
+ [Fact]
+ public void ComputeTokenCountHandlesNullAndNonNullText()
+ {
+ // Mix: one with text, one without
+ SimpleWordTokenizer tokenizer = new();
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Hello world"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
+ ];
+
+ int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer);
+
+ // Only "Hello world" contributes tokens (2 words)
+ Assert.Equal(2, tokenCount);
+ }
+
///
/// A simple tokenizer that counts whitespace-separated words as tokens.
///
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs
index 08c3b50ad3..709a44e0a4 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs
@@ -221,4 +221,30 @@ public class SlidingWindowCompactionStrategyTests
Assert.False(index.Groups[4].IsExcluded); // Q3
Assert.False(index.Groups[5].IsExcluded); // A3
}
+
+ [Fact]
+ public async Task CompactAsyncSkipsExcludedAndSystemGroupsInEnumerationAsync()
+ {
+ // Arrange — includes system and pre-excluded groups that must be skipped
+ SlidingWindowCompactionStrategy strategy = new(
+ CompactionTriggers.TurnsExceed(1),
+ minimumPreserved: 0);
+
+ 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"),
+ ]);
+ // Pre-exclude one group
+ index.Groups[1].IsExcluded = true;
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert — system preserved, pre-excluded skipped
+ Assert.True(result);
+ Assert.False(index.Groups[0].IsExcluded); // System preserved
+ }
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs
index 9d6bfdb05b..0bf225ae34 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs
@@ -246,7 +246,7 @@ public class SummarizationCompactionStrategyTests
{
// Arrange — 4 non-system groups, preserve 1, target met after 1 exclusion
int exclusionCount = 0;
- CompactionTrigger TargetAfterOne = _ => ++exclusionCount >= 1;
+ bool TargetAfterOne(MessageIndex _) => ++exclusionCount >= 1;
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient("Partial summary."),
@@ -297,4 +297,114 @@ public class SummarizationCompactionStrategyTests
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);
+ }
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs
index 66300e69f0..2b925aef76 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs
@@ -233,4 +233,30 @@ public class ToolResultCompactionStrategyTests
Assert.Equal(1, collapsedToolGroups);
}
+
+ [Fact]
+ public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync()
+ {
+ // Arrange — pre-excluded and system groups in the enumeration
+ ToolResultCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 0);
+
+ List messages =
+ [
+ new ChatMessage(ChatRole.System, "System prompt"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
+ new ChatMessage(ChatRole.Tool, "Result 1"),
+ new ChatMessage(ChatRole.User, "Q1"),
+ ];
+
+ MessageIndex index = MessageIndex.Create(messages);
+ // Pre-exclude the user group
+ index.Groups[index.Groups.Count - 1].IsExcluded = true;
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert — system never excluded, pre-excluded skipped
+ Assert.True(result);
+ Assert.False(index.Groups[0].IsExcluded); // System stays
+ }
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs
index ceb40b7495..2783fa029c 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs
@@ -274,4 +274,55 @@ public class TruncationCompactionStrategyTests
Assert.True(result);
Assert.Equal(2, groups.IncludedGroupCount);
}
+
+ [Fact]
+ public async Task CompactAsyncLoopExitsWhenMaxRemovableReachedAsync()
+ {
+ // Arrange — target never stops (always false), so the loop must exit via removed >= maxRemovable
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 2, target: CompactionTriggers.Never);
+ MessageIndex groups = 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(groups);
+
+ // Assert — only 2 removed (maxRemovable = 4 - 2 = 2), 2 preserved
+ Assert.True(result);
+ Assert.Equal(2, groups.IncludedGroupCount);
+ Assert.True(groups.Groups[0].IsExcluded);
+ Assert.True(groups.Groups[1].IsExcluded);
+ Assert.False(groups.Groups[2].IsExcluded);
+ Assert.False(groups.Groups[3].IsExcluded);
+ }
+
+ [Fact]
+ public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync()
+ {
+ // Arrange — has excluded + system groups that the loop must skip
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.System, "System"),
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ]);
+ // Pre-exclude one group
+ groups.Groups[1].IsExcluded = true;
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert — system preserved, pre-excluded skipped, A1 removed, Q2 preserved
+ Assert.True(result);
+ Assert.False(groups.Groups[0].IsExcluded); // System
+ Assert.True(groups.Groups[1].IsExcluded); // Pre-excluded Q1
+ Assert.True(groups.Groups[2].IsExcluded); // Newly excluded A1
+ Assert.False(groups.Groups[3].IsExcluded); // Preserved Q2
+ }
}