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