From d6c4dbea96d3ce185dcac9612e682432a08eabc6 Mon Sep 17 00:00:00 2001 From: Chris Rickman Date: Mon, 2 Mar 2026 22:00:21 -0800 Subject: [PATCH] Checkpoint --- .../Program.cs | 54 +++++--- .../ChatHistoryCompactionPipeline.Factory.cs | 102 ++++++++++++++ .../ChatHistoryCompactionPipeline.cs | 44 +++--- .../ChatHistoryCompactionStrategy.cs | 57 ++++---- ...mpactionMetric.cs => ChatHistoryMetric.cs} | 2 +- .../ChatReducerCompactionStrategy.cs | 35 +++++ .../Compaction/CompactionPipelineResult.cs | 8 +- .../Compaction/CompactionResult.cs | 8 +- .../DefaultChatHistoryMetricsCalculator.cs | 2 +- .../IChatHistoryMetricsCalculator.cs | 8 +- .../SlidingWindowCompactionStrategy.cs | 23 ++-- .../SummarizationCompactionStrategy.cs | 4 +- .../ToolResultCompactionStrategy.cs | 11 +- .../TruncationCompactionStrategy.cs | 8 +- .../ChatHistoryCompactionPipelineTests.cs | 47 ++++--- .../ChatHistoryCompactionStrategyTests.cs | 118 ++++++---------- .../ChatReducerCompactionStrategyTests.cs | 114 ++++++++++++++++ .../Compaction/CompactionMetricTests.cs | 9 +- .../CompactionPipelineResultTests.cs | 21 ++- .../Compaction/CompactionResultTests.cs | 6 +- .../Compaction/CompactionStrategyTestBase.cs | 40 ++++++ ...efaultChatHistoryMetricsCalculatorTests.cs | 100 +++++++++++--- .../Internal/NeverCompactStrategy.cs | 3 +- .../Compaction/Internal/NonReadOnlyList.cs | 40 ------ .../Internal/RemoveFirstMessageStrategy.cs | 2 +- .../Compaction/MessageGroupTests.cs | 10 ++ .../SlidingWindowCompactionStrategyTests.cs | 83 ++++-------- .../SummarizationCompactionStrategyTests.cs | 128 +++++++----------- .../ToolResultCompactionStrategyTests.cs | 95 ++++--------- .../TruncationCompactionStrategyTests.cs | 83 ++++-------- 30 files changed, 740 insertions(+), 525 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.Factory.cs rename dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/{CompactionMetric.cs => ChatHistoryMetric.cs} (97%) create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatReducerCompactionStrategy.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionStrategyTestBase.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NonReadOnlyList.cs diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs index 7849cb4656..9a3e935e01 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs @@ -13,6 +13,7 @@ using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; +using static Microsoft.Agents.AI.Compaction.ChatHistoryCompactionPipeline; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; @@ -39,33 +40,45 @@ static string LookupPrice([Description("The product name to look up.")] string p }; // Configure the compaction pipeline with one of each strategy, ordered least to most aggressive. -const int MaxTokens = 512; -const int MaxTurns = 4; +//const int MaxTokens = 512; +//const int MaxTurns = 4; ChatHistoryCompactionPipeline compactionPipeline = - new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" - new ToolResultCompactionStrategy(MaxTokens, preserveRecentGroups: 2), + //new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" + // new ToolResultCompactionStrategy(MaxTokens, preserveRecentGroups: 2), - // 2. Moderate: use an LLM to summarize older conversation spans into a concise message - new SummarizationCompactionStrategy(summarizerChatClient, MaxTokens, preserveRecentGroups: 2), + // // 2. Moderate: use an LLM to summarize older conversation spans into a concise message + // new SummarizationCompactionStrategy(summarizerChatClient, MaxTokens, preserveRecentGroups: 2), - // 3. Aggressive: keep only the last N user turns and their responses - new SlidingWindowCompactionStrategy(MaxTurns), + // // 3. Aggressive: keep only the last N user turns and their responses + // new SlidingWindowCompactionStrategy(MaxTurns), - // 4. Emergency: drop oldest groups until under the token budget - new TruncationCompactionStrategy(MaxTokens, preserveRecentGroups: 1)); + // // 4. Emergency: drop oldest groups until under the token budget + // new TruncationCompactionStrategy(MaxTokens, preserveRecentGroups: 1)); + Create( + Approach.Balanced, + Size.Compact, + summarizerChatClient); // Create the agent with an in-memory chat history provider whose reducer is the compaction pipeline. -AIAgent agent = agentChatClient.AsAIAgent(new ChatClientAgentOptions -{ - Name = "ShoppingAssistant", - ChatOptions = new() - { - Instructions = "You are a helpful shopping assistant. Help the user look up prices and compare products.", - Tools = [AIFunctionFactory.Create(LookupPrice)], - }, - ChatHistoryProvider = new InMemoryChatHistoryProvider(new() { ChatReducer = compactionPipeline }), -}); +AIAgent agent = + agentChatClient.AsAIAgent( + new ChatClientAgentOptions + { + Name = "ShoppingAssistant", + ChatOptions = new() + { + Instructions = + """ + You are a helpful, but long winded, shopping assistant. + Help the user look up prices and compare products. + When responding, Be sure to be extra descriptive and use as + many words as possible without sounding ridiculous. + """, + Tools = [AIFunctionFactory.Create(LookupPrice)], + }, + ChatHistoryProvider = new InMemoryChatHistoryProvider(new() { ChatReducer = compactionPipeline }), + }); AgentSession session = await agent.CreateSessionAsync(); @@ -87,6 +100,7 @@ string[] prompts = "Which product is the cheapest?", "Can you compare the laptop and the keyboard for me?", "What was the first product I asked about?", + "Thank you!", ]; foreach (string prompt in prompts) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.Factory.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.Factory.cs new file mode 100644 index 0000000000..2e9cafd5a3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.Factory.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Compaction; + +public partial class ChatHistoryCompactionPipeline +{ + /// + /// %%% COMMENT + /// + public enum Size + { + /// + /// %%% COMMENT + /// + Compact, + /// + /// %%% COMMENT + /// + Adequate, + /// + /// %%% COMMENT + /// + Accomodating, + } + + /// + /// %%% COMMENT + /// + public enum Approach + { + /// + /// %%% COMMENT + /// + Aggressive, + /// + /// %%% COMMENT + /// + Balanced, + /// + /// %%% COMMENT + /// + Gentle, + } + + /// + /// %%% COMMENT + /// + /// + /// + /// + /// + /// + public static ChatHistoryCompactionPipeline Create(Approach approach, Size size, IChatClient chatClient) => + approach switch + { + Approach.Aggressive => CreateAgressive(size, chatClient), + Approach.Balanced => CreateBalanced(size), + Approach.Gentle => CreateGentle(size), + _ => throw new NotImplementedException(), // %%% EXCEPTION + }; + + private static ChatHistoryCompactionPipeline CreateAgressive(Size size, IChatClient chatClient) => + new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" + new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2), + // 2. Moderate: use an LLM to summarize older conversation spans into a concise message + new SummarizationCompactionStrategy(chatClient, MaxTokens(size), preserveRecentGroups: 2), + // 3. Aggressive: keep only the last N user turns and their responses + new SlidingWindowCompactionStrategy(MaxTurns(size)), + // 4. Emergency: drop oldest groups until under the token budget + new TruncationCompactionStrategy(MaxTokens(size), preserveRecentGroups: 1)); + + private static ChatHistoryCompactionPipeline CreateBalanced(Size size) => + new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" + new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2), + // 2. Aggressive: keep only the last N user turns and their responses + new SlidingWindowCompactionStrategy(MaxTurns(size))); + + private static ChatHistoryCompactionPipeline CreateGentle(Size size) => + new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]" + new ToolResultCompactionStrategy(MaxTokens(size), preserveRecentGroups: 2)); + + private static int MaxTokens(Size size) => + size switch + { + Size.Compact => 500, + Size.Adequate => 1000, + Size.Accomodating => 2000, + _ => throw new NotImplementedException(), // %%% EXCEPTION + }; + + private static int MaxTurns(Size size) => + size switch + { + Size.Compact => 10, + Size.Adequate => 50, + Size.Accomodating => 100, + _ => throw new NotImplementedException(), // %%% EXCEPTION + }; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs index 8b678602a1..9dc4c4e507 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs @@ -1,7 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; -using System.Linq; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -24,7 +25,7 @@ namespace Microsoft.Agents.AI.Compaction; /// accepted (e.g., ). /// /// -public class ChatHistoryCompactionPipeline : IChatReducer +public partial class ChatHistoryCompactionPipeline : IChatReducer { private readonly ChatHistoryCompactionStrategy[] _strategies; private readonly IChatHistoryMetricsCalculator _metricsCalculator; @@ -52,7 +53,7 @@ public class ChatHistoryCompactionPipeline : IChatReducer IChatHistoryMetricsCalculator? metricsCalculator, params IEnumerable strategies) { - this._strategies = Throw.IfNull(strategies).ToArray(); + this._strategies = [.. Throw.IfNull(strategies)]; this._metricsCalculator = metricsCalculator ?? DefaultChatHistoryMetricsCalculator.Instance; } @@ -66,9 +67,9 @@ public class ChatHistoryCompactionPipeline : IChatReducer IEnumerable messages, CancellationToken cancellationToken = default) { - List messageList = messages.ToList(); // %%% HAXX - await this.CompactAsync(messageList, cancellationToken).ConfigureAwait(false); - return messageList; + List messageBuffer = messages is List messageList ? messageList : [.. messages]; + await this.CompactAsync(messageBuffer, cancellationToken).ConfigureAwait(false); + return messageBuffer; } /// @@ -77,26 +78,37 @@ public class ChatHistoryCompactionPipeline : IChatReducer /// The mutable message list to compact. /// The to monitor for cancellation requests. /// A with aggregate and per-strategy metrics. - public async ValueTask CompactAsync( // %%% SCOPE - IList messages, + public async ValueTask CompactAsync( + List messages, CancellationToken cancellationToken = default) { Throw.IfNull(messages); - IReadOnlyList readOnlyMessages = messages as IReadOnlyList ?? [.. messages]; // %%% TYPE CONSISTENCY - CompactionMetric overallBefore = this._metricsCalculator.Calculate(readOnlyMessages); + ChatHistoryMetric overallBefore = this._metricsCalculator.Calculate(messages); - List results = new(this._strategies.Length); + Debug.WriteLine($"COMPACTION: BEGIN x{overallBefore.MessageCount}/#{overallBefore.UserTurnCount} ({overallBefore.TokenCount} tokens)"); + List compactionResults = new(this._strategies.Length); + + Stopwatch timer = new(); + TimeSpan startTime = TimeSpan.Zero; + ChatHistoryMetric overallAfter = overallBefore; + ChatHistoryMetric currentBefore = overallBefore; foreach (ChatHistoryCompactionStrategy strategy in this._strategies) { - CompactionResult result = await strategy.CompactAsync(messages, this._metricsCalculator, cancellationToken).ConfigureAwait(false); - results.Add(result); + // %%% VERBOSE - Debug.WriteLine($"COMPACTION: {strategy.Name} START"); + timer.Start(); + ChatHistoryCompactionStrategy.s_currentMetrics.Value = currentBefore; + CompactionResult strategyResult = await strategy.CompactAsync(messages, this._metricsCalculator, cancellationToken).ConfigureAwait(false); + timer.Stop(); + TimeSpan elapsedTime = timer.Elapsed - startTime; + // %%% VERBOSE - Debug.WriteLine($"COMPACTION: {strategy.Name} FINISH [{elapsedTime}]"); + compactionResults.Add(strategyResult); + overallAfter = currentBefore = strategyResult.After; } - readOnlyMessages = messages as IReadOnlyList ?? [.. messages]; - CompactionMetric overallAfter = this._metricsCalculator.Calculate(readOnlyMessages); + Debug.WriteLineIf(overallBefore.TokenCount != overallAfter.TokenCount, $"COMPACTION: TOTAL [{timer.Elapsed}] {overallBefore.TokenCount} => {overallAfter.TokenCount} tokens"); - return new(overallBefore, overallAfter, results); + return new(overallBefore, overallAfter, compactionResults); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionStrategy.cs index 2bb8e01e40..5d78bfe6e5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionStrategy.cs @@ -2,8 +2,7 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -21,7 +20,7 @@ namespace Microsoft.Agents.AI.Compaction; /// while the strategy adds: /// /// A conditional trigger via that decides whether compaction runs. -/// Before/after reporting via . +/// Before/after reporting via . /// /// /// @@ -36,7 +35,7 @@ namespace Microsoft.Agents.AI.Compaction; /// public abstract class ChatHistoryCompactionStrategy { - private static readonly AsyncLocal s_currentMetrics = new(); + internal static readonly AsyncLocal s_currentMetrics = new(); /// /// Initializes a new instance of the class. @@ -48,9 +47,9 @@ public abstract class ChatHistoryCompactionStrategy } /// - /// Exposes the current for the executing strategy, allowing to make informed decisions. + /// Exposes the current for the executing strategy, allowing to make informed decisions. /// - protected static CompactionMetric CurrentMetrics => s_currentMetrics.Value ?? throw new InvalidOperationException($"No active {nameof(ChatHistoryCompactionStrategy)}."); + protected static ChatHistoryMetric CurrentMetrics => s_currentMetrics.Value ?? throw new InvalidOperationException($"No active {nameof(ChatHistoryCompactionStrategy)}."); /// /// Gets the that performs the actual message compaction. @@ -72,48 +71,50 @@ public abstract class ChatHistoryCompactionStrategy /// /// to proceed with compaction; to skip. /// - public abstract bool ShouldCompact(CompactionMetric metrics); + protected abstract bool ShouldCompact(ChatHistoryMetric metrics); /// /// Execute this strategy: check the trigger, delegate to the , and report metrics. /// - /// The mutable message list to compact. + /// The mutable message list to compact. /// The calculator to use for metric snapshots. /// The to monitor for cancellation requests. /// A reporting the outcome. - public async ValueTask CompactAsync( - IList messages, + internal async ValueTask CompactAsync( + List history, IChatHistoryMetricsCalculator metricsCalculator, CancellationToken cancellationToken = default) { - messages = Throw.IfNull(messages); Throw.IfNull(metricsCalculator); + Throw.IfNull(history); - List? messageList = messages as List; - ReadOnlyCollection snapshot = messageList is not null ? messageList.AsReadOnly() : new(messages); - CompactionMetric before = metricsCalculator.Calculate(snapshot); - s_currentMetrics.Value = before; - if (!this.ShouldCompact(before)) + ChatHistoryMetric beforeMetrics = CurrentMetrics; + if (!this.ShouldCompact(beforeMetrics)) { - return CompactionResult.Skipped(this.Name, before); + // %%% VERBOSE - Debug.WriteLine($"COMPACTION: {this.Name} - Skipped"); + return CompactionResult.Skipped(this.Name, beforeMetrics); } - ChatMessage[] reduced = (await this.Reducer.ReduceAsync(snapshot, cancellationToken).ConfigureAwait(false)).ToArray(); + Debug.WriteLine($"COMPACTION: {this.Name} - Reducing"); - bool modified = reduced.Length != snapshot.Count; + IEnumerable reducerResult = await this.Reducer.ReduceAsync(history, cancellationToken).ConfigureAwait(false); + + // Ensure we have a concrete collection to avoid multiple enumerations of the reducer result, which could be costly if it's an iterator. + ChatMessage[] reducedCopy = [.. reducerResult]; + + bool modified = reducedCopy.Length != history.Count; if (modified) { - messages.Clear(); - foreach (ChatMessage message in reduced) - { - messages.Add(message); - } + history.Clear(); + history.AddRange(reducedCopy); } - CompactionMetric after = modified - ? metricsCalculator.Calculate(reduced) - : before; + ChatHistoryMetric afterMetrics = modified + ? metricsCalculator.Calculate(reducedCopy) + : beforeMetrics; - return new(this.Name, applied: modified, before, after); + Debug.WriteLine($"COMPACTION: {this.Name} - Tokens {beforeMetrics.TokenCount} => {afterMetrics.TokenCount}"); + + return new(this.Name, applied: modified, beforeMetrics, afterMetrics); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionMetric.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryMetric.cs similarity index 97% rename from dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionMetric.cs rename to dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryMetric.cs index e4d641623b..f2d9694dfe 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionMetric.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryMetric.cs @@ -7,7 +7,7 @@ namespace Microsoft.Agents.AI.Compaction; /// /// Immutable snapshot of conversation metrics used for compaction trigger evaluation and reporting. /// -public sealed class CompactionMetric +public sealed class ChatHistoryMetric { /// /// Gets the estimated token count across all messages. diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatReducerCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatReducerCompactionStrategy.cs new file mode 100644 index 0000000000..7305e86cd5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatReducerCompactionStrategy.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Represents a chat history compaction strategy that uses a condition function to determine when compaction should +/// occur. +/// +/// +/// This strategy evaluates a user-provided condition against compaction metrics to decide whether to +/// compact the chat history. It is useful for scenarios where compaction should be triggered based on custom thresholds +/// or criteria. Inherits from ChatHistoryCompactionStrategy. +/// +public class ChatReducerCompactionStrategy : ChatHistoryCompactionStrategy +{ + private readonly Func _condition; + + /// + /// Initializes a new instance of the class. + /// + public ChatReducerCompactionStrategy( + IChatReducer reducer, + Func condition) + : base(reducer) + { + this._condition = Throw.IfNull(condition); + } + + /// + protected override bool ShouldCompact(ChatHistoryMetric metrics) => this._condition(metrics); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionPipelineResult.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionPipelineResult.cs index ab7e6448ae..c416528a83 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionPipelineResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionPipelineResult.cs @@ -18,8 +18,8 @@ public sealed class CompactionPipelineResult /// Metrics of the conversation after all strategies ran. /// Per-strategy results in execution order. internal CompactionPipelineResult( - CompactionMetric before, - CompactionMetric after, + ChatHistoryMetric before, + ChatHistoryMetric after, IReadOnlyList strategyResults) { this.Before = Throw.IfNull(before); @@ -30,12 +30,12 @@ public sealed class CompactionPipelineResult /// /// Gets the conversation metrics before any compaction strategy ran. /// - public CompactionMetric Before { get; } + public ChatHistoryMetric Before { get; } /// /// Gets the conversation metrics after all compaction strategies ran. /// - public CompactionMetric After { get; } + public ChatHistoryMetric After { get; } /// /// Gets the per-strategy results in execution order. diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionResult.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionResult.cs index c6eca6625e..2c4b2ad13e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionResult.cs @@ -16,7 +16,7 @@ public sealed class CompactionResult /// Whether the strategy modified the message list. /// Metrics before the strategy ran. /// Metrics after the strategy ran. - public CompactionResult(string strategyName, bool applied, CompactionMetric before, CompactionMetric after) + public CompactionResult(string strategyName, bool applied, ChatHistoryMetric before, ChatHistoryMetric after) { this.StrategyName = Throw.IfNullOrWhitespace(strategyName); this.Applied = applied; @@ -37,12 +37,12 @@ public sealed class CompactionResult /// /// Gets the conversation metrics before the strategy executed. /// - public CompactionMetric Before { get; } + public ChatHistoryMetric Before { get; } /// /// Gets the conversation metrics after the strategy executed. /// - public CompactionMetric After { get; } + public ChatHistoryMetric After { get; } /// /// Creates a representing a skipped strategy. @@ -50,6 +50,6 @@ public sealed class CompactionResult /// The name of the skipped strategy. /// The current conversation metrics. /// A result indicating no compaction was applied. - internal static CompactionResult Skipped(string strategyName, CompactionMetric metrics) + internal static CompactionResult Skipped(string strategyName, ChatHistoryMetric metrics) => new(strategyName, applied: false, metrics, metrics); } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/DefaultChatHistoryMetricsCalculator.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/DefaultChatHistoryMetricsCalculator.cs index 19b9258584..2491f99591 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/DefaultChatHistoryMetricsCalculator.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/DefaultChatHistoryMetricsCalculator.cs @@ -46,7 +46,7 @@ public sealed class DefaultChatHistoryMetricsCalculator : IChatHistoryMetricsCal } /// - public CompactionMetric Calculate(IReadOnlyList messages) + public ChatHistoryMetric Calculate(IReadOnlyList messages) { if (messages is null || messages.Count == 0) { diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/IChatHistoryMetricsCalculator.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/IChatHistoryMetricsCalculator.cs index 830268dc76..3c4c124444 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/IChatHistoryMetricsCalculator.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/IChatHistoryMetricsCalculator.cs @@ -9,18 +9,18 @@ namespace Microsoft.Agents.AI.Compaction; // and whether custom metrics calculators are a realistic extension point. /// -/// Computes for a list of messages. +/// Computes for a list of messages. /// /// /// Token counting is model-specific. Implementations can provide precise tokenization /// (e.g., using tiktoken or a model-specific tokenizer) or use estimation heuristics. /// -public interface IChatHistoryMetricsCalculator // %%% NEEDED ??? +public interface IChatHistoryMetricsCalculator { /// /// Compute metrics for the given messages. /// /// The messages to analyze. - /// A snapshot. - CompactionMetric Calculate(IReadOnlyList messages); + /// A snapshot. + ChatHistoryMetric Calculate(IReadOnlyList messages); } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SlidingWindowCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SlidingWindowCompactionStrategy.cs index 9d8f12fc62..51b5b90a1d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SlidingWindowCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SlidingWindowCompactionStrategy.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -44,7 +43,7 @@ public class SlidingWindowCompactionStrategy : ChatHistoryCompactionStrategy } /// - public override bool ShouldCompact(CompactionMetric metrics) => + protected override bool ShouldCompact(ChatHistoryMetric metrics) => metrics.UserTurnCount > this._maxTurns; /// @@ -57,25 +56,21 @@ public class SlidingWindowCompactionStrategy : ChatHistoryCompactionStrategy IEnumerable messages, CancellationToken cancellationToken = default) { - IReadOnlyList messageList = [.. messages]; + IReadOnlyList messageList = [.. messages]; // %%% PERFORMANCE IReadOnlyList groups = CurrentMetrics.Groups; // Find the group-list indices where each user turn starts - //int[] turnGroupIndices = groups.Where(group => group.Kind == ChatMessageGroupKind.UserTurn).Select(group => group.StartIndex).ToArray(); // %%% TODO - List turnGroupIndices = []; - for (int i = 0; i < groups.Count; i++) - { - if (groups[i].Kind == ChatMessageGroupKind.UserTurn) - { - turnGroupIndices.Add(i); - } - } + int[] turnGroupIndices = + [.. CurrentMetrics.Groups + .Select((group, index) => (group, index)) + .Where(t => t.group.Kind == ChatMessageGroupKind.UserTurn) + .Select(t => t.index)]; // Keep the last maxTurns user turns and everything after the first kept turn - int firstKeptTurnIndex = turnGroupIndices.Count - maxTurns; + int firstKeptTurnIndex = turnGroupIndices.Length - maxTurns; int firstKeptGroupIndex = turnGroupIndices[firstKeptTurnIndex]; - List result = new(messageList.Count); + List result = new(messageList.Count); // %%% PERFORMANCE for (int gi = 0; gi < groups.Count; gi++) { ChatMessageGroup group = groups[gi]; diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SummarizationCompactionStrategy.cs index 433b7c0f04..028077beae 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SummarizationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SummarizationCompactionStrategy.cs @@ -69,7 +69,7 @@ public class SummarizationCompactionStrategy : ChatHistoryCompactionStrategy } /// - public override bool ShouldCompact(CompactionMetric metrics) => + protected override bool ShouldCompact(ChatHistoryMetric metrics) => metrics.TokenCount > this._maxTokens; /// @@ -96,7 +96,7 @@ public class SummarizationCompactionStrategy : ChatHistoryCompactionStrategy IReadOnlyList messageList = [.. messages]; IReadOnlyList groups = CurrentMetrics.Groups; - List nonSystemGroups = groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList(); + List nonSystemGroups = [.. groups.Where(g => g.Kind != ChatMessageGroupKind.System)]; int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - this._preserveRecentGroups); if (protectedFromIndex == 0) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ToolResultCompactionStrategy.cs index 424943262b..26380d6d6d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ToolResultCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ToolResultCompactionStrategy.cs @@ -29,6 +29,11 @@ namespace Microsoft.Agents.AI.Compaction; /// public class ToolResultCompactionStrategy : ChatHistoryCompactionStrategy { + /// + /// The default value for `preserveRecentGroups` used when constructing . + /// + public const int DefaultPreserveRecentGroups = 2; + private readonly int _maxTokens; /// @@ -39,14 +44,14 @@ public class ToolResultCompactionStrategy : ChatHistoryCompactionStrategy /// The number of most-recent non-system message groups to protect from collapsing. /// Defaults to 2, ensuring the current turn's tool interactions remain visible. /// - public ToolResultCompactionStrategy(int maxTokens, int preserveRecentGroups = 2) + public ToolResultCompactionStrategy(int maxTokens, int preserveRecentGroups = DefaultPreserveRecentGroups) : base(new ToolResultClearingReducer(preserveRecentGroups)) { this._maxTokens = maxTokens; } /// - public override bool ShouldCompact(CompactionMetric metrics) => + protected override bool ShouldCompact(ChatHistoryMetric metrics) => metrics.TokenCount > this._maxTokens && metrics.ToolCallCount > 0; /// @@ -62,7 +67,7 @@ public class ToolResultCompactionStrategy : ChatHistoryCompactionStrategy IReadOnlyList messageList = [.. messages]; IReadOnlyList groups = CurrentMetrics.Groups; - List nonSystemGroups = groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList(); + List nonSystemGroups = [.. groups.Where(g => g.Kind != ChatMessageGroupKind.System)]; int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - preserveRecentGroups); HashSet protectedGroupStarts = []; for (int i = protectedFromIndex; i < nonSystemGroups.Count; i++) diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/TruncationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/TruncationCompactionStrategy.cs index 1390f463fc..a2a6b07acf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/TruncationCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/TruncationCompactionStrategy.cs @@ -41,7 +41,7 @@ public class TruncationCompactionStrategy : ChatHistoryCompactionStrategy } /// - public override bool ShouldCompact(CompactionMetric metrics) => + protected override bool ShouldCompact(ChatHistoryMetric metrics) => metrics.TokenCount > this._maxTokens; /// @@ -56,15 +56,15 @@ public class TruncationCompactionStrategy : ChatHistoryCompactionStrategy { IReadOnlyList messageList = [.. messages]; - List removableGroups = CurrentMetrics.Groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList(); + ChatMessageGroup[] removableGroups = [.. CurrentMetrics.Groups.Where(g => g.Kind != ChatMessageGroupKind.System)]; - if (removableGroups.Count == 0) + if (removableGroups.Length == 0) { return Task.FromResult>(messageList); } // Remove oldest non-system groups, keeping at least preserveRecentGroups. - int maxRemovable = removableGroups.Count - preserveRecentGroups; + int maxRemovable = removableGroups.Length - preserveRecentGroups; if (maxRemovable <= 0) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionPipelineTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionPipelineTests.cs index d5d58d7ba9..75658054d1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionPipelineTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionPipelineTests.cs @@ -14,11 +14,14 @@ public class ChatHistoryCompactionPipelineTests [Fact] public async Task EmptyStrategies_ReturnsUnmodifiedAsync() { + // Arrange ChatHistoryCompactionPipeline pipeline = new([]); List messages = [new(ChatRole.User, "Hello")]; + // Act CompactionPipelineResult result = await pipeline.CompactAsync(messages); + // Assert Assert.False(result.AnyApplied); Assert.Equal(1, result.Before.MessageCount); Assert.Equal(1, result.After.MessageCount); @@ -28,6 +31,7 @@ public class ChatHistoryCompactionPipelineTests [Fact] public async Task ChainsStrategies_InOrderAsync() { + // Arrange ChatHistoryCompactionStrategy[] strategies = [ new NeverCompactStrategy(), @@ -40,8 +44,10 @@ public class ChatHistoryCompactionPipelineTests new(ChatRole.User, "Second"), ]; + // Act CompactionPipelineResult result = await pipeline.CompactAsync(messages); + // Assert Assert.True(result.AnyApplied); Assert.Equal(2, result.StrategyResults.Count); Assert.False(result.StrategyResults[0].Applied); @@ -52,6 +58,7 @@ public class ChatHistoryCompactionPipelineTests [Fact] public async Task ReportsOverallMetricsAsync() { + // Arrange ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]); List messages = [ @@ -60,8 +67,10 @@ public class ChatHistoryCompactionPipelineTests new(ChatRole.User, "Third"), ]; + // Act CompactionPipelineResult result = await pipeline.CompactAsync(messages); + // Assert Assert.Equal(3, result.Before.MessageCount); Assert.Equal(2, result.After.MessageCount); } @@ -69,51 +78,39 @@ public class ChatHistoryCompactionPipelineTests [Fact] public async Task CustomMetricsCalculator_IsUsedAsync() { + // Arrange Moq.Mock calcMock = new(); calcMock .Setup(c => c.Calculate(Moq.It.IsAny>())) - .Returns(new CompactionMetric { MessageCount = 42 }); - + .Returns(new ChatHistoryMetric { MessageCount = 42 }); ChatHistoryCompactionPipeline pipeline = new(calcMock.Object, []); List messages = [new(ChatRole.User, "Hello")]; + // Act CompactionPipelineResult result = await pipeline.CompactAsync(messages); + // Assert Assert.Equal(42, result.Before.MessageCount); - calcMock.Verify(c => c.Calculate(Moq.It.IsAny>()), Moq.Times.AtLeast(2)); - } - - [Fact] - public async Task CompactAsync_NonReadOnlyListMessages_WorksAsync() - { - ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]); - NonReadOnlyList messages = new( - [ - new(ChatRole.User, "First"), - new(ChatRole.User, "Second"), - ]); - - CompactionPipelineResult result = await pipeline.CompactAsync(messages); - - Assert.True(result.AnyApplied); - Assert.Single(messages); + calcMock.Verify(c => c.Calculate(Moq.It.IsAny>()), Moq.Times.Once); } [Fact] public async Task ReduceAsync_DelegatesCompactionAsync() { + // Arrange ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]); - - ChatMessage[] messages = + List messages = [ new(ChatRole.User, "First"), new(ChatRole.User, "Second"), new(ChatRole.User, "Third"), ]; - IEnumerable result = await ((IChatReducer)pipeline).ReduceAsync(messages, default); + // Act + IEnumerable result = await pipeline.ReduceAsync(messages, default); List resultList = result.ToList(); + // Assert Assert.Equal(2, resultList.Count); Assert.Equal("Second", resultList[0].Text); Assert.Equal("Third", resultList[1].Text); @@ -122,16 +119,18 @@ public class ChatHistoryCompactionPipelineTests [Fact] public async Task ReduceAsync_EmptyStrategies_ReturnsAllMessagesAsync() { + // Arrange ChatHistoryCompactionPipeline pipeline = new([]); - ChatMessage[] messages = [ new(ChatRole.User, "Hello"), new(ChatRole.User, "World"), ]; - IEnumerable result = await ((IChatReducer)pipeline).ReduceAsync(messages, default); + // Act + IEnumerable result = await pipeline.ReduceAsync(messages, default); + // Assert Assert.Equal(2, result.Count()); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionStrategyTests.cs index 5c2e006350..a0635de76e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionStrategyTests.cs @@ -17,28 +17,32 @@ public class ChatHistoryCompactionStrategyTests [Fact] public async Task ShouldCompactReturnsFalse_SkipsAsync() { - NeverCompactStrategy strategy = new(); + // Arrange List messages = [new(ChatRole.User, "Hello")]; - DefaultChatHistoryMetricsCalculator calculator = new(); + NeverCompactStrategy strategy = new(); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act + CompactionResult result = await RunCompactionStrategyAsync(strategy, messages); + // Assert Assert.False(result.Applied); } [Fact] public async Task ShouldCompactReturnsTrue_RunsCompactionAsync() { - RemoveFirstMessageStrategy strategy = new(); + // Arrange List messages = [ new(ChatRole.User, "First"), new(ChatRole.User, "Second"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + RemoveFirstMessageStrategy strategy = new(); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act + CompactionResult result = await RunCompactionStrategyAsync(strategy, messages); + // Assert Assert.True(result.Applied); Assert.Single(messages); Assert.Equal("Second", messages[0].Text); @@ -47,23 +51,24 @@ public class ChatHistoryCompactionStrategyTests } [Fact] - public async Task DelegatesToIChatReducerAsync() + public async Task DelegatesToReducerAsync() { - Mock reducerMock = new(); - reducerMock - .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) - .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs.Skip(1)); - - TestCompactionStrategy strategy = new(reducerMock.Object); + // Arrange List messages = [ new(ChatRole.User, "First"), new(ChatRole.User, "Second"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + Mock reducerMock = new(); + reducerMock + .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IEnumerable messages, CancellationToken _) => messages.Skip(1)); + TestCompactionStrategy strategy = new(reducerMock.Object); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act + CompactionResult result = await RunCompactionStrategyAsync(strategy, messages); + // Assert Assert.True(result.Applied); Assert.Single(messages); Assert.Equal("Second", messages[0].Text); @@ -73,88 +78,55 @@ public class ChatHistoryCompactionStrategyTests [Fact] public async Task ReducerNoChange_ReturnsFalseAsync() { - Mock reducerMock = new(); - reducerMock - .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) - .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs); - - TestCompactionStrategy strategy = new(reducerMock.Object); + // Arrange List messages = [ new(ChatRole.User, "Hello"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + Mock reducerMock = new(); + reducerMock + .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs); + TestCompactionStrategy strategy = new(reducerMock.Object, shouldCompact: false); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act + CompactionResult result = await RunCompactionStrategyAsync(strategy, messages); + // Assert Assert.False(result.Applied); Assert.Single(messages); } [Fact] - public void ExposesReducer() + public void ReducerLifecycle() { + // Arrange Mock reducerMock = new(); + + // Act TestCompactionStrategy strategy = new(reducerMock.Object); + // Assert Assert.Same(reducerMock.Object, strategy.Reducer); - } - - [Fact] - public void DefaultName_IsReducerTypeName() - { - Mock reducerMock = new(); - TestCompactionStrategy strategy = new(reducerMock.Object); - - // Moq proxy type name is used since we're using a mock Assert.NotNull(strategy.Name); Assert.NotEmpty(strategy.Name); - } - - [Fact] - public void ConditionDelegate_ReturnsTrue_ShouldCompactReturnsTrue() - { - Mock reducerMock = new(); - TestCompactionStrategy strategy = new(reducerMock.Object); - - CompactionMetric metrics = new() { TokenCount = 100 }; - Assert.True(strategy.ShouldCompact(metrics)); - } - - [Fact] - public void ConditionDelegate_ReturnsFalse_ShouldCompactReturnsFalse() - { - Mock reducerMock = new(); - TestCompactionStrategy strategy = new(reducerMock.Object, shouldCompact: false); - - CompactionMetric metrics = new() { TokenCount = 100 }; - Assert.False(strategy.ShouldCompact(metrics)); - } - - [Fact] - public async Task CompactAsync_NonReadOnlyListMessages_WorksAsync() - { - RemoveFirstMessageStrategy strategy = new(); - NonReadOnlyList messages = new( - [ - new(ChatRole.User, "First"), - new(ChatRole.User, "Second"), - ]); - DefaultChatHistoryMetricsCalculator calculator = new(); - - CompactionResult result = await strategy.CompactAsync(messages, calculator); - - Assert.True(result.Applied); - Assert.Single(messages); - Assert.Equal("Second", messages[0].Text); + Assert.Equal(reducerMock.Object.GetType().Name, strategy.Name); } [Fact] public void CurrentMetrics_OutsideStrategy_Throws() { + // Act & Assert Assert.Throws(() => TestCompactionStrategy.GetCurrentMetrics()); } + public static async ValueTask RunCompactionStrategyAsync(ChatHistoryCompactionStrategy strategy, List messages) + { + // Act + ChatHistoryCompactionStrategy.s_currentMetrics.Value = DefaultChatHistoryMetricsCalculator.Instance.Calculate(messages); + return await strategy.CompactAsync(messages, DefaultChatHistoryMetricsCalculator.Instance); + } + private sealed class TestCompactionStrategy : ChatHistoryCompactionStrategy { private readonly bool _shouldCompact; @@ -165,8 +137,8 @@ public class ChatHistoryCompactionStrategyTests this._shouldCompact = shouldCompact; } - public override bool ShouldCompact(CompactionMetric metrics) => this._shouldCompact; + protected override bool ShouldCompact(ChatHistoryMetric metrics) => this._shouldCompact; - public static CompactionMetric GetCurrentMetrics() => CurrentMetrics; + public static ChatHistoryMetric GetCurrentMetrics() => CurrentMetrics; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs new file mode 100644 index 0000000000..844f7f55ea --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs @@ -0,0 +1,114 @@ +// 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.Abstractions.UnitTests.Compaction; + +public class ChatReducerCompactionStrategyTests : CompactionStrategyTestBase +{ + [Fact] + public async Task ConditionFalse_SkipsAsync() + { + // Arrange + List messages = [new(ChatRole.User, "Hello")]; + Mock reducerMock = new(); + ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => false); + + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); + + // Assert + reducerMock.Verify( + r => r.ReduceAsync(It.IsAny>(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ConditionTrue_RunsReducerAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "First"), + new(ChatRole.User, "Second"), + ]; + Mock reducerMock = new(); + reducerMock + .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs.Skip(1)); + ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => true); + + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 1); + + // Assert + Assert.Equal("Second", messages[0].Text); + reducerMock.Verify( + r => r.ReduceAsync(It.IsAny>(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ConditionReceivesMetricsAsync() + { + // Arrange + List messages = + [ + new(ChatRole.User, "Hello"), + new(ChatRole.Assistant, "Hi"), + ]; + ChatHistoryMetric? capturedMetrics = null; + Mock reducerMock = new(); + reducerMock + .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs); + ChatReducerCompactionStrategy strategy = new( + reducerMock.Object, + metrics => + { + capturedMetrics = metrics; + return false; + }); + + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); + + // Assert + Assert.NotNull(capturedMetrics); + Assert.Equal(2, capturedMetrics!.MessageCount); + } + + [Fact] + public async Task ReducerNoChange_AppliedFalseAsync() + { + // Arrange + List messages = [new(ChatRole.User, "Hello")]; + Mock reducerMock = new(); + reducerMock + .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs); + ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => true); + + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); + } + + [Fact] + public void Name_ReturnsReducerTypeName() + { + // Arrange + Mock reducerMock = new(); + + // Act + ChatReducerCompactionStrategy strategy = new(reducerMock.Object, _ => true); + + // Assert + Assert.Equal(reducerMock.Object.GetType().Name, strategy.Name); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionMetricTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionMetricTests.cs index c2287512cd..ace35e3607 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionMetricTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionMetricTests.cs @@ -9,7 +9,10 @@ public class CompactionMetricTests [Fact] public void DefaultValues_AreZero() { - CompactionMetric metrics = new(); + // Arrange & Act + ChatHistoryMetric metrics = new(); + + // Assert Assert.Equal(0, metrics.TokenCount); Assert.Equal(0L, metrics.ByteCount); Assert.Equal(0, metrics.MessageCount); @@ -21,7 +24,8 @@ public class CompactionMetricTests [Fact] public void InitProperties_SetCorrectly() { - CompactionMetric metrics = new() + // Arrange & Act + ChatHistoryMetric metrics = new() { TokenCount = 100, ByteCount = 500, @@ -30,6 +34,7 @@ public class CompactionMetricTests UserTurnCount = 3 }; + // Assert Assert.Equal(100, metrics.TokenCount); Assert.Equal(500L, metrics.ByteCount); Assert.Equal(5, metrics.MessageCount); diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionPipelineResultTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionPipelineResultTests.cs index 6921bc26ca..7edc8426f5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionPipelineResultTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionPipelineResultTests.cs @@ -11,13 +11,16 @@ public class CompactionPipelineResultTests [Fact] public void Properties_AreReadable() { - CompactionMetric before = new() { MessageCount = 10 }; - CompactionMetric after = new() { MessageCount = 5 }; + // Arrange + ChatHistoryMetric before = new() { MessageCount = 10 }; + ChatHistoryMetric after = new() { MessageCount = 5 }; CompactionResult strategyResult = new("Test", applied: true, before, after); List results = [strategyResult]; + // Act CompactionPipelineResult pipelineResult = new(before, after, results); + // Assert Assert.Same(before, pipelineResult.Before); Assert.Same(after, pipelineResult.After); Assert.Single(pipelineResult.StrategyResults); @@ -26,21 +29,29 @@ public class CompactionPipelineResultTests [Fact] public void AnyApplied_AllFalse_ReturnsFalse() { - CompactionMetric metrics = new() { MessageCount = 5 }; + // Arrange + ChatHistoryMetric metrics = new() { MessageCount = 5 }; CompactionResult skipped = CompactionResult.Skipped("Skip", metrics); + + // Act CompactionPipelineResult result = new(metrics, metrics, [skipped]); + // Assert Assert.False(result.AnyApplied); } [Fact] public void AnyApplied_SomeTrue_ReturnsTrue() { - CompactionMetric before = new() { MessageCount = 10 }; - CompactionMetric after = new() { MessageCount = 5 }; + // Arrange + ChatHistoryMetric before = new() { MessageCount = 10 }; + ChatHistoryMetric after = new() { MessageCount = 5 }; CompactionResult applied = new("Applied", applied: true, before, after); + + // Act CompactionPipelineResult result = new(before, after, [applied]); + // Assert Assert.True(result.AnyApplied); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionResultTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionResultTests.cs index 1bb476786b..bc36898a4d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionResultTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionResultTests.cs @@ -9,9 +9,13 @@ public class CompactionResultTests [Fact] public void Skipped_HasSameBeforeAndAfter() { - CompactionMetric metrics = new() { MessageCount = 5, TokenCount = 100 }; + // Arrange + ChatHistoryMetric metrics = new() { MessageCount = 5, TokenCount = 100 }; + + // Act CompactionResult result = CompactionResult.Skipped("Test", metrics); + // Assert Assert.Equal("Test", result.StrategyName); Assert.False(result.Applied); Assert.Same(metrics, result.Before); diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionStrategyTestBase.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionStrategyTestBase.cs new file mode 100644 index 0000000000..851713cc83 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionStrategyTestBase.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Compaction; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; + +public abstract class CompactionStrategyTestBase +{ + public static async ValueTask RunCompactionStrategyReducedAsync(ChatHistoryCompactionStrategy strategy, List messages, int expectedCount) + { + // Act + ChatHistoryCompactionStrategy.s_currentMetrics.Value = DefaultChatHistoryMetricsCalculator.Instance.Calculate(messages); + CompactionResult result = await strategy.CompactAsync(messages, DefaultChatHistoryMetricsCalculator.Instance); + + // Assert + Assert.True(result.Applied); + Assert.NotEqual(result.Before, result.After); + Assert.Equal(expectedCount, messages.Count); + + return result; + } + + public static async ValueTask RunCompactionStrategySkippedAsync(ChatHistoryCompactionStrategy strategy, List messages) + { + // Act + int initialCount = messages.Count; + ChatHistoryCompactionStrategy.s_currentMetrics.Value = DefaultChatHistoryMetricsCalculator.Instance.Calculate(messages); + CompactionResult result = await strategy.CompactAsync(messages, DefaultChatHistoryMetricsCalculator.Instance); + + // Assert + Assert.False(result.Applied); + Assert.Equal(result.Before, result.After); + Assert.Equal(initialCount, messages.Count); + + return result; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/DefaultChatHistoryMetricsCalculatorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/DefaultChatHistoryMetricsCalculatorTests.cs index 01387f1eed..2edbe6a4ff 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/DefaultChatHistoryMetricsCalculatorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/DefaultChatHistoryMetricsCalculatorTests.cs @@ -11,9 +11,13 @@ public class DefaultChatHistoryMetricsCalculatorTests [Fact] public void EmptyList_ReturnsZeroMetrics() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); - CompactionMetric metrics = calculator.Calculate([]); + // Act + ChatHistoryMetric metrics = calculator.Calculate([]); + + // Assert Assert.Equal(0, metrics.TokenCount); Assert.Equal(0L, metrics.ByteCount); Assert.Equal(0, metrics.MessageCount); @@ -24,6 +28,7 @@ public class DefaultChatHistoryMetricsCalculatorTests [Fact] public void CountsMessages() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); List messages = [ @@ -31,14 +36,17 @@ public class DefaultChatHistoryMetricsCalculatorTests new(ChatRole.Assistant, "Hi there"), ]; - CompactionMetric metrics = calculator.Calculate(messages); + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + // Assert Assert.Equal(2, metrics.MessageCount); } [Fact] public void CountsUserTurns() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); List messages = [ @@ -48,14 +56,17 @@ public class DefaultChatHistoryMetricsCalculatorTests new(ChatRole.Assistant, "Good"), ]; - CompactionMetric metrics = calculator.Calculate(messages); + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + // Assert Assert.Equal(2, metrics.UserTurnCount); } [Fact] public void CountsToolCalls() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); ChatMessage assistantMsg = new(ChatRole.Assistant, [ new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "NYC" }), @@ -67,14 +78,17 @@ public class DefaultChatHistoryMetricsCalculatorTests assistantMsg, ]; - CompactionMetric metrics = calculator.Calculate(messages); + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + // Assert Assert.Equal(2, metrics.ToolCallCount); } [Fact] public void ConsecutiveUserMessages_CountAsOneTurn() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); List messages = [ @@ -83,22 +97,27 @@ public class DefaultChatHistoryMetricsCalculatorTests new(ChatRole.Assistant, "Reply"), ]; - CompactionMetric metrics = calculator.Calculate(messages); + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + // Assert Assert.Equal(1, metrics.UserTurnCount); } [Fact] public void TokenCount_IsPositive() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); List messages = [ new(ChatRole.User, "Hello world"), ]; - CompactionMetric metrics = calculator.Calculate(messages); + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + // Assert Assert.True(metrics.TokenCount > 0); Assert.True(metrics.ByteCount > 0); } @@ -106,9 +125,13 @@ public class DefaultChatHistoryMetricsCalculatorTests [Fact] public void NullInput_ReturnsZeroMetrics() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); - CompactionMetric metrics = calculator.Calculate(null!); + // Act + ChatHistoryMetric metrics = calculator.Calculate(null!); + + // Assert Assert.Equal(0, metrics.TokenCount); Assert.Equal(0L, metrics.ByteCount); Assert.Equal(0, metrics.MessageCount); @@ -118,45 +141,50 @@ public class DefaultChatHistoryMetricsCalculatorTests [Fact] public void InvalidCharsPerToken_UsesDefault() { - // A non-positive charsPerToken should fall back to the default (4) + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(charsPerToken: 0); List messages = [ new(ChatRole.User, "Hello world"), ]; - CompactionMetric metrics = calculator.Calculate(messages); + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); - // With default 4 chars/token: "Hello world" = 11 chars → 11/4=2 + 4 overhead = 6 tokens + // Assert Assert.True(metrics.TokenCount > 0); } [Fact] public void NullMessageText_HandledGracefully() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); - // Message with no text content — Text returns null ChatMessage msg = new() { Role = ChatRole.User }; List messages = [msg]; - CompactionMetric metrics = calculator.Calculate(messages); + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + // Assert Assert.Equal(1, metrics.MessageCount); - // Null text → empty string → 0 bytes, only overhead tokens - Assert.True(metrics.TokenCount > 0); // per-message overhead + Assert.True(metrics.TokenCount > 0); Assert.Equal(0L, metrics.ByteCount); } [Fact] public void NullContents_SkipsToolCounting() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); ChatMessage msg = new(ChatRole.User, "text"); msg.Contents = null!; List messages = [msg]; - CompactionMetric metrics = calculator.Calculate(messages); + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + // Assert Assert.Equal(1, metrics.MessageCount); Assert.Equal(0, metrics.ToolCallCount); } @@ -164,16 +192,18 @@ public class DefaultChatHistoryMetricsCalculatorTests [Fact] public void MessageWithOnlyNonTextContent_NullTextHandled() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); - // FunctionCallContent-only message has null Text ChatMessage msg = new(ChatRole.Assistant, [ new FunctionCallContent("c1", "func"), ]); List messages = [msg]; - CompactionMetric metrics = calculator.Calculate(messages); + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + // Assert Assert.Equal(1, metrics.MessageCount); Assert.Equal(1, metrics.ToolCallCount); } @@ -181,6 +211,7 @@ public class DefaultChatHistoryMetricsCalculatorTests [Fact] public void Calculate_PopulatesGroupIndex() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); List messages = [ @@ -189,8 +220,10 @@ public class DefaultChatHistoryMetricsCalculatorTests new(ChatRole.Assistant, "Hi there"), ]; - CompactionMetric metrics = calculator.Calculate(messages); + // Act + ChatHistoryMetric metrics = calculator.Calculate(messages); + // Assert Assert.Equal(3, metrics.Groups.Count); Assert.Equal(ChatMessageGroupKind.System, metrics.Groups[0].Kind); Assert.Equal(ChatMessageGroupKind.UserTurn, metrics.Groups[1].Kind); @@ -200,23 +233,30 @@ public class DefaultChatHistoryMetricsCalculatorTests [Fact] public void EmptyList_GroupIndexIsEmpty() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); - CompactionMetric metrics = calculator.Calculate([]); + // Act + ChatHistoryMetric metrics = calculator.Calculate([]); + + // Assert Assert.Empty(metrics.Groups); } [Fact] public void GroupIndex_SystemMessage_IdentifiedCorrectly() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); List messages = [ new(ChatRole.System, "You are a helpful assistant"), ]; + // Act IReadOnlyList groups = calculator.Calculate(messages).Groups; + // Assert Assert.Single(groups); Assert.Equal(ChatMessageGroupKind.System, groups[0].Kind); Assert.Equal(0, groups[0].StartIndex); @@ -226,6 +266,7 @@ public class DefaultChatHistoryMetricsCalculatorTests [Fact] public void GroupIndex_AssistantWithToolCalls_GroupedWithResults() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); ChatMessage assistantMsg = new(ChatRole.Assistant, [ new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "NYC" }), @@ -240,18 +281,21 @@ public class DefaultChatHistoryMetricsCalculatorTests toolResult, ]; + // Act IReadOnlyList groups = calculator.Calculate(messages).Groups; + // Assert Assert.Equal(2, groups.Count); Assert.Equal(ChatMessageGroupKind.UserTurn, groups[0].Kind); Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[1].Kind); Assert.Equal(1, groups[1].StartIndex); - Assert.Equal(2, groups[1].Count); // assistant + tool result + Assert.Equal(2, groups[1].Count); } [Fact] public void GroupIndex_MultipleToolResults_GroupedTogether() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); ChatMessage assistantMsg = new(ChatRole.Assistant, [ new FunctionCallContent("c1", "func1"), @@ -261,8 +305,10 @@ public class DefaultChatHistoryMetricsCalculatorTests ChatMessage tool2 = new(ChatRole.Tool, [new FunctionResultContent("c2", "result2")]); List messages = [assistantMsg, tool1, tool2]; + // Act IReadOnlyList groups = calculator.Calculate(messages).Groups; + // Assert Assert.Single(groups); Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[0].Kind); Assert.Equal(3, groups[0].Count); @@ -271,6 +317,7 @@ public class DefaultChatHistoryMetricsCalculatorTests [Fact] public void GroupIndex_ComplexConversation_CorrectGrouping() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); List messages = [ @@ -283,29 +330,34 @@ public class DefaultChatHistoryMetricsCalculatorTests new(ChatRole.Assistant, "It's sunny!"), ]; + // Act IReadOnlyList groups = calculator.Calculate(messages).Groups; + // Assert Assert.Equal(6, groups.Count); Assert.Equal(ChatMessageGroupKind.System, groups[0].Kind); Assert.Equal(ChatMessageGroupKind.UserTurn, groups[1].Kind); Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[2].Kind); Assert.Equal(ChatMessageGroupKind.UserTurn, groups[3].Kind); Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[4].Kind); - Assert.Equal(2, groups[4].Count); // assistant + tool + Assert.Equal(2, groups[4].Count); Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[5].Kind); } [Fact] public void GroupIndex_OrphanToolResult_IdentifiedCorrectly() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); List messages = [ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "orphan result")]), ]; + // Act IReadOnlyList groups = calculator.Calculate(messages).Groups; + // Assert Assert.Single(groups); Assert.Equal(ChatMessageGroupKind.ToolResult, groups[0].Kind); } @@ -313,14 +365,17 @@ public class DefaultChatHistoryMetricsCalculatorTests [Fact] public void GroupIndex_UnknownRole_IdentifiedAsOther() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); List messages = [ new(new ChatRole("custom"), "custom message"), ]; + // Act IReadOnlyList groups = calculator.Calculate(messages).Groups; + // Assert Assert.Single(groups); Assert.Equal(ChatMessageGroupKind.Other, groups[0].Kind); } @@ -328,13 +383,16 @@ public class DefaultChatHistoryMetricsCalculatorTests [Fact] public void GroupIndex_AssistantWithNullContents_ClassifiedAsPlain() { + // Arrange DefaultChatHistoryMetricsCalculator calculator = new(); ChatMessage msg = new(ChatRole.Assistant, "reply"); msg.Contents = null!; List messages = [msg]; + // Act IReadOnlyList groups = calculator.Calculate(messages).Groups; + // Assert Assert.Single(groups); Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[0].Kind); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NeverCompactStrategy.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NeverCompactStrategy.cs index 9a4429e08a..f126ed5db2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NeverCompactStrategy.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NeverCompactStrategy.cs @@ -16,7 +16,8 @@ internal sealed class NeverCompactStrategy : ChatHistoryCompactionStrategy } public override string Name => "NeverCompact"; - public override bool ShouldCompact(CompactionMetric metrics) => false; + + protected override bool ShouldCompact(ChatHistoryMetric metrics) => false; private sealed class NoOpReducer : IChatReducer { diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NonReadOnlyList.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NonReadOnlyList.cs deleted file mode 100644 index 101388c921..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NonReadOnlyList.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections; -using System.Collections.Generic; -using System.Linq; - -namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal; - -/// -/// An IList<T> that does NOT implement IReadOnlyList<T>, -/// used to test the defensive as IReadOnlyList<T> ?? fallback patterns. -/// -internal sealed class NonReadOnlyList : IList -{ - private readonly List _inner; - - public NonReadOnlyList(IEnumerable items) - { - this._inner = items.ToList(); - } - - public T this[int index] - { - get => this._inner[index]; - set => this._inner[index] = value; - } - - public int Count => this._inner.Count; - public bool IsReadOnly => false; - public void Add(T item) => this._inner.Add(item); - public void Clear() => this._inner.Clear(); - public bool Contains(T item) => this._inner.Contains(item); - public void CopyTo(T[] array, int arrayIndex) => this._inner.CopyTo(array, arrayIndex); - public IEnumerator GetEnumerator() => this._inner.GetEnumerator(); - public int IndexOf(T item) => this._inner.IndexOf(item); - public void Insert(int index, T item) => this._inner.Insert(index, item); - public bool Remove(T item) => this._inner.Remove(item); - public void RemoveAt(int index) => this._inner.RemoveAt(index); - IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/RemoveFirstMessageStrategy.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/RemoveFirstMessageStrategy.cs index f2ca36bc10..477a7815a0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/RemoveFirstMessageStrategy.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/RemoveFirstMessageStrategy.cs @@ -18,7 +18,7 @@ internal sealed class RemoveFirstMessageStrategy : ChatHistoryCompactionStrategy public override string Name => "RemoveFirst"; - public override bool ShouldCompact(CompactionMetric metrics) => metrics.MessageCount > 0; + protected override bool ShouldCompact(ChatHistoryMetric metrics) => metrics.MessageCount > 0; private sealed class RemoveFirstReducer : IChatReducer { diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupTests.cs index 5449b4bbf9..c84ebbb1ac 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupTests.cs @@ -9,10 +9,12 @@ public class MessageGroupTests [Fact] public void Equality_Works() { + // Arrange ChatMessageGroup a = new(0, 2, ChatMessageGroupKind.AssistantToolGroup); ChatMessageGroup b = new(0, 2, ChatMessageGroupKind.AssistantToolGroup); ChatMessageGroup c = new(1, 2, ChatMessageGroupKind.AssistantToolGroup); + // Act & Assert Assert.Equal(a, b); Assert.True(a == b); Assert.NotEqual(a, c); @@ -22,34 +24,42 @@ public class MessageGroupTests [Fact] public void Equals_Object_NullReturnsFalse() { + // Arrange ChatMessageGroup group = new(0, 1, ChatMessageGroupKind.System); + // Act & Assert Assert.False(group.Equals(null)); } [Fact] public void Equals_Object_BoxedMessageGroupReturnsTrue() { + // Arrange ChatMessageGroup group = new(0, 2, ChatMessageGroupKind.AssistantToolGroup); object boxed = new ChatMessageGroup(0, 2, ChatMessageGroupKind.AssistantToolGroup); + // Act & Assert Assert.True(group.Equals(boxed)); } [Fact] public void Equals_Object_WrongTypeReturnsFalse() { + // Arrange ChatMessageGroup group = new(0, 1, ChatMessageGroupKind.System); + // Act & Assert Assert.False(group.Equals("not a MessageGroup")); } [Fact] public void GetHashCode_ConsistentForEqualInstances() { + // Arrange ChatMessageGroup a = new(0, 2, ChatMessageGroupKind.AssistantToolGroup); ChatMessageGroup b = new(0, 2, ChatMessageGroupKind.AssistantToolGroup); + // Act & Assert Assert.Equal(a.GetHashCode(), b.GetHashCode()); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs index dee020130b..b3e75920ba 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Threading.Tasks; @@ -7,47 +7,27 @@ using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; -public class SlidingWindowCompactionStrategyTests +public class SlidingWindowCompactionStrategyTests : CompactionStrategyTestBase { - [Fact] - public void ShouldCompact_UnderLimit_ReturnsFalse() - { - SlidingWindowCompactionStrategy strategy = new(maxTurns: 10); - CompactionMetric metrics = new() { UserTurnCount = 3 }; - - Assert.False(strategy.ShouldCompact(metrics)); - } - - [Fact] - public void ShouldCompact_OverLimit_ReturnsTrue() - { - SlidingWindowCompactionStrategy strategy = new(maxTurns: 2); - CompactionMetric metrics = new() { UserTurnCount = 5 }; - - Assert.True(strategy.ShouldCompact(metrics)); - } - [Fact] public async Task UnderLimit_NoChangeAsync() { - SlidingWindowCompactionStrategy strategy = new(maxTurns: 10); + // Arrange List messages = [ new(ChatRole.User, "Hello"), new(ChatRole.Assistant, "Hi"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + SlidingWindowCompactionStrategy strategy = new(maxTurns: 10); - CompactionResult result = await strategy.CompactAsync(messages, calculator); - - Assert.False(result.Applied); - Assert.Equal(2, messages.Count); + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); } [Fact] public async Task KeepsLastNTurnsAsync() { - SlidingWindowCompactionStrategy strategy = new(maxTurns: 2); + // Arrange List messages = [ new(ChatRole.User, "Turn 1"), @@ -57,12 +37,12 @@ public class SlidingWindowCompactionStrategyTests new(ChatRole.User, "Turn 3"), new(ChatRole.Assistant, "Reply 3"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + SlidingWindowCompactionStrategy strategy = new(maxTurns: 2); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4); - Assert.True(result.Applied); - Assert.Equal(4, messages.Count); + // Assert Assert.Equal("Turn 2", messages[0].Text); Assert.Equal("Reply 2", messages[1].Text); Assert.Equal("Turn 3", messages[2].Text); @@ -72,7 +52,7 @@ public class SlidingWindowCompactionStrategyTests [Fact] public async Task PreservesSystemMessagesAsync() { - SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); + // Arrange List messages = [ new(ChatRole.System, "You are a helper"), @@ -81,12 +61,12 @@ public class SlidingWindowCompactionStrategyTests new(ChatRole.User, "Turn 2"), new(ChatRole.Assistant, "Reply 2"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3); - Assert.True(result.Applied); - Assert.Equal(3, messages.Count); + // Assert Assert.Equal(ChatRole.System, messages[0].Role); Assert.Equal("You are a helper", messages[0].Text); Assert.Equal("Turn 2", messages[1].Text); @@ -96,7 +76,7 @@ public class SlidingWindowCompactionStrategyTests [Fact] public async Task PreservesToolGroupsWithinKeptTurnsAsync() { - SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); + // Arrange List messages = [ new(ChatRole.User, "Turn 1"), @@ -106,37 +86,34 @@ public class SlidingWindowCompactionStrategyTests new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]), new(ChatRole.Assistant, "It's sunny!"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4); - Assert.True(result.Applied); - // Turn 1 dropped, Turn 2 kept (user + assistant-tool-group + plain assistant) - Assert.Equal(4, messages.Count); + // Assert Assert.Equal("Get weather", messages[0].Text); } [Fact] public async Task SingleTurn_AtLimit_NoChangeAsync() { - SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); + // Arrange List messages = [ new(ChatRole.User, "Hello"), new(ChatRole.Assistant, "Hi"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); - CompactionResult result = await strategy.CompactAsync(messages, calculator); - - Assert.False(result.Applied); - Assert.Equal(2, messages.Count); + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); } [Fact] public async Task DropsResponseGroupsFromOldTurnsAsync() { - SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); + // Arrange List messages = [ new(ChatRole.User, "Turn 1"), @@ -146,12 +123,12 @@ public class SlidingWindowCompactionStrategyTests new(ChatRole.User, "Turn 2"), new(ChatRole.Assistant, "Reply 2"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + SlidingWindowCompactionStrategy strategy = new(maxTurns: 1); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2); - Assert.True(result.Applied); - Assert.Equal(2, messages.Count); + // Assert Assert.Equal("Turn 2", messages[0].Text); Assert.Equal("Reply 2", messages[1].Text); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs index 02782ad754..018d35ce48 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs @@ -9,44 +9,24 @@ using Moq; namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; -public class SummarizationCompactionStrategyTests +public class SummarizationCompactionStrategyTests : CompactionStrategyTestBase { - [Fact] - public void ShouldCompact_UnderLimit_ReturnsFalse() - { - Mock chatClientMock = new(); - SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100000); - CompactionMetric metrics = new() { TokenCount = 500 }; - - Assert.False(strategy.ShouldCompact(metrics)); - } - - [Fact] - public void ShouldCompact_OverLimit_ReturnsTrue() - { - Mock chatClientMock = new(); - SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100); - CompactionMetric metrics = new() { TokenCount = 500 }; - - Assert.True(strategy.ShouldCompact(metrics)); - } - [Fact] public async Task UnderLimit_NoChangeAsync() { - Mock chatClientMock = new(); - SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100000); + // Arrange List messages = [ new(ChatRole.User, "Hello"), new(ChatRole.Assistant, "Hi"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + Mock chatClientMock = new(); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100000); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); - Assert.False(result.Applied); - Assert.Equal(2, messages.Count); + // Assert chatClientMock.Verify( c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); @@ -55,13 +35,7 @@ public class SummarizationCompactionStrategyTests [Fact] public async Task SummarizesOldGroupsAsync() { - Mock chatClientMock = new(); - chatClientMock - .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "User asked about weather. It was sunny."))); - - // preserveRecentGroups=2 means keep last 2 non-system groups - SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 2); + // Arrange List messages = [ new(ChatRole.User, "What's the weather?"), @@ -71,14 +45,16 @@ public class SummarizationCompactionStrategyTests new(ChatRole.User, "Thanks!"), new(ChatRole.Assistant, "You're welcome!"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + Mock chatClientMock = new(); + chatClientMock + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "User asked about weather. It was sunny."))); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 2); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3); - Assert.True(result.Applied); - // 6 groups (3 user + 3 assistant), protect last 2 → summarize first 4 groups - // Result: summary + 2 protected groups = 3 messages - Assert.Equal(3, messages.Count); + // Assert Assert.Contains("[Summary]", messages[0].Text); Assert.Contains("sunny", messages[0].Text); Assert.Equal("Thanks!", messages[1].Text); @@ -88,12 +64,7 @@ public class SummarizationCompactionStrategyTests [Fact] public async Task PreservesSystemMessagesAsync() { - Mock chatClientMock = new(); - chatClientMock - .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary of earlier discussion."))); - - SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1); + // Arrange List messages = [ new(ChatRole.System, "You are a helper"), @@ -102,11 +73,16 @@ public class SummarizationCompactionStrategyTests new(ChatRole.User, "Turn 2"), new(ChatRole.Assistant, "Reply 2"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + Mock chatClientMock = new(); + chatClientMock + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Summary of earlier discussion."))); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3); - Assert.True(result.Applied); + // Assert Assert.Equal(ChatRole.System, messages[0].Role); Assert.Equal("You are a helper", messages[0].Text); Assert.Contains("[Summary]", messages[1].Text); @@ -115,20 +91,19 @@ public class SummarizationCompactionStrategyTests [Fact] public async Task AllGroupsProtected_NoChangeAsync() { - Mock chatClientMock = new(); - SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 10); + // Arrange List messages = [ new(ChatRole.User, "Hello"), new(ChatRole.Assistant, "Hi"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + Mock chatClientMock = new(); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 10); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); - // All groups protected → nothing to summarize → no change - Assert.False(result.Applied); - Assert.Equal(2, messages.Count); + // Assert chatClientMock.Verify( c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); @@ -137,16 +112,8 @@ public class SummarizationCompactionStrategyTests [Fact] public async Task CustomPrompt_UsedInRequestAsync() { + // Arrange const string CustomPrompt = "Summarize briefly."; - List? capturedMessages = null; - - Mock chatClientMock = new(); - chatClientMock - .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) - .Callback, ChatOptions, CancellationToken>((msgs, _, _) => capturedMessages = [.. msgs]) - .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Brief summary."))); - - SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1, summarizationPrompt: CustomPrompt); List messages = [ new(ChatRole.User, "First"), @@ -154,12 +121,19 @@ public class SummarizationCompactionStrategyTests new(ChatRole.User, "Second"), new(ChatRole.Assistant, "Reply 2"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + List? capturedMessages = null; + Mock chatClientMock = new(); + chatClientMock + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, _, _) => capturedMessages = [.. msgs]) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Brief summary."))); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1, summarizationPrompt: CustomPrompt); - await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2); + // Assert Assert.NotNull(capturedMessages); - // First message in request should be the custom system prompt Assert.Equal(ChatRole.System, capturedMessages![0].Role); Assert.Equal(CustomPrompt, capturedMessages[0].Text); } @@ -167,12 +141,7 @@ public class SummarizationCompactionStrategyTests [Fact] public async Task NullResponseText_UsesFallbackAsync() { - Mock chatClientMock = new(); - chatClientMock - .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, (string?)null))); - - SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1); + // Arrange List messages = [ new(ChatRole.User, "First"), @@ -180,11 +149,16 @@ public class SummarizationCompactionStrategyTests new(ChatRole.User, "Second"), new(ChatRole.Assistant, "Reply 2"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + Mock chatClientMock = new(); + chatClientMock + .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, (string?)null))); + SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2); - Assert.True(result.Applied); + // Assert Assert.Contains("[Summary unavailable]", messages[0].Text); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs index 8a6065a940..d2b3113ec6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Threading.Tasks; @@ -7,57 +7,28 @@ using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; -public class ToolResultCompactionStrategyTests +public class ToolResultCompactionStrategyTests : CompactionStrategyTestBase { - [Fact] - public void ShouldCompact_UnderLimit_ReturnsFalse() - { - ToolResultCompactionStrategy strategy = new(maxTokens: 100000); - CompactionMetric metrics = new() { TokenCount = 500, ToolCallCount = 3 }; - - Assert.False(strategy.ShouldCompact(metrics)); - } - - [Fact] - public void ShouldCompact_OverLimitNoToolCalls_ReturnsFalse() - { - ToolResultCompactionStrategy strategy = new(maxTokens: 100); - CompactionMetric metrics = new() { TokenCount = 500, ToolCallCount = 0 }; - - Assert.False(strategy.ShouldCompact(metrics)); - } - - [Fact] - public void ShouldCompact_OverLimitWithToolCalls_ReturnsTrue() - { - ToolResultCompactionStrategy strategy = new(maxTokens: 100); - CompactionMetric metrics = new() { TokenCount = 500, ToolCallCount = 2 }; - - Assert.True(strategy.ShouldCompact(metrics)); - } - [Fact] public async Task UnderLimit_NoChangeAsync() { - ToolResultCompactionStrategy strategy = new(maxTokens: 100000); + // Arrange List messages = [ new(ChatRole.User, "Hello"), new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]), new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + ToolResultCompactionStrategy strategy = new(maxTokens: 100000); - CompactionResult result = await strategy.CompactAsync(messages, calculator); - - Assert.False(result.Applied); - Assert.Equal(3, messages.Count); + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); } [Fact] public async Task CollapsesOldToolGroupAsync() { - ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1); + // Arrange List messages = [ new(ChatRole.User, "Check weather"), @@ -66,13 +37,12 @@ public class ToolResultCompactionStrategyTests new(ChatRole.User, "Thanks"), new(ChatRole.Assistant, "You're welcome!"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4); - Assert.True(result.Applied); - // The old tool group (assistant+tool = 2 messages) should be collapsed to 1 - Assert.Equal(4, messages.Count); // user + [collapsed] + user + assistant + // Assert Assert.Contains("[Tool calls: get_weather]", messages[1].Text); Assert.Equal(ChatRole.Assistant, messages[1].Role); } @@ -80,8 +50,7 @@ public class ToolResultCompactionStrategyTests [Fact] public async Task ProtectsRecentGroupsAsync() { - // With preserveRecentGroups=4, all groups are protected (5 groups, protect 4 non-system) - ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 10); + // Arrange List messages = [ new(ChatRole.User, "Check weather"), @@ -90,19 +59,16 @@ public class ToolResultCompactionStrategyTests new(ChatRole.User, "Thanks"), new(ChatRole.Assistant, "You're welcome!"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 10); - CompactionResult result = await strategy.CompactAsync(messages, calculator); - - // All groups protected, so no collapse - Assert.False(result.Applied); - Assert.Equal(5, messages.Count); + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); } [Fact] public async Task PreservesSystemMessagesAsync() { - ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1); + // Arrange List messages = [ new(ChatRole.System, "You are a helper"), @@ -112,11 +78,12 @@ public class ToolResultCompactionStrategyTests new(ChatRole.User, "Thanks"), new(ChatRole.Assistant, "You're welcome!"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 5); - Assert.True(result.Applied); + // Assert Assert.Equal(ChatRole.System, messages[0].Role); Assert.Equal("You are a helper", messages[0].Text); } @@ -124,7 +91,7 @@ public class ToolResultCompactionStrategyTests [Fact] public async Task MultipleToolCalls_ListedInSummaryAsync() { - ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1); + // Arrange List messages = [ new(ChatRole.User, "Do research"), @@ -137,13 +104,12 @@ public class ToolResultCompactionStrategyTests new(ChatRole.User, "Summarize"), new(ChatRole.Assistant, "Here's the summary."), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4); - Assert.True(result.Applied); - // Old tool group (1 assistant + 2 tools = 3 messages) collapsed to 1 - Assert.Equal(4, messages.Count); + // Assert Assert.Contains("search", messages[1].Text); Assert.Contains("fetch_page", messages[1].Text); } @@ -151,18 +117,15 @@ public class ToolResultCompactionStrategyTests [Fact] public async Task NoToolGroups_NoChangeAsync() { - ToolResultCompactionStrategy strategy = new(maxTokens: 1); + // Arrange List messages = [ new(ChatRole.User, "Hello"), new(ChatRole.Assistant, "Hi there"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + ToolResultCompactionStrategy strategy = new(maxTokens: 1); - // ToolCallCount is 0, so ShouldCompact returns false - CompactionResult result = await strategy.CompactAsync(messages, calculator); - - Assert.False(result.Applied); - Assert.Equal(2, messages.Count); + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/TruncationCompactionStrategyTests.cs index 47929d23e5..b4be4422a5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/TruncationCompactionStrategyTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/TruncationCompactionStrategyTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Threading.Tasks; @@ -7,29 +7,26 @@ using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction; -public class TruncationCompactionStrategyTests +public class TruncationCompactionStrategyTests : CompactionStrategyTestBase { [Fact] public async Task UnderLimit_NoChangeAsync() { - TruncationCompactionStrategy strategy = new(maxTokens: 100000); + // Arrange List messages = [ new(ChatRole.User, "Hello"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + TruncationCompactionStrategy strategy = new(maxTokens: 100000); - CompactionResult result = await strategy.CompactAsync(messages, calculator); - - Assert.False(result.Applied); - Assert.Single(messages); + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); } [Fact] public async Task OverLimit_RemovesOldestGroupsAsync() { - // Use a very low max to trigger compaction - TruncationCompactionStrategy strategy = new(maxTokens: 1); + // Arrange List messages = [ new(ChatRole.User, "First message"), @@ -37,77 +34,45 @@ public class TruncationCompactionStrategyTests new(ChatRole.User, "Second message"), new(ChatRole.Assistant, "Second reply"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + TruncationCompactionStrategy strategy = new(maxTokens: 1); - CompactionResult result = await strategy.CompactAsync(messages, calculator); - - Assert.True(result.Applied); - // Should have removed old groups, keeping at least the last one - Assert.True(messages.Count < 4); - Assert.True(messages.Count > 0); - } - - [Fact] - public void ShouldCompact_ReturnsFalseWhenUnderLimit() - { - TruncationCompactionStrategy strategy = new(maxTokens: 10000); - CompactionMetric metrics = new() { TokenCount = 500 }; - - Assert.False(strategy.ShouldCompact(metrics)); - } - - [Fact] - public void ShouldCompact_ReturnsTrueWhenOverLimit() - { - TruncationCompactionStrategy strategy = new(maxTokens: 100); - CompactionMetric metrics = new() { TokenCount = 500 }; - - Assert.True(strategy.ShouldCompact(metrics)); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 1); } [Fact] public async Task SystemOnlyMessages_NoChangeAsync() { - // Only system messages → removableGroups.Count == 0 → no change - TruncationCompactionStrategy strategy = new(maxTokens: 1); + // Arrange List messages = [ new(ChatRole.System, "You are a helper"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + TruncationCompactionStrategy strategy = new(maxTokens: 1); - CompactionResult result = await strategy.CompactAsync(messages, calculator); - - // ShouldCompact triggers but reducer finds nothing removable - Assert.Single(messages); - Assert.Equal(result.After.MessageCount, result.Before.MessageCount); - Assert.Equal(result.After.TokenCount, result.Before.TokenCount); + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); } [Fact] public async Task SingleNonSystemGroup_NoChangeAsync() { - // Only one non-system group → maxRemovable <= 0 → no change - TruncationCompactionStrategy strategy = new(maxTokens: 1); + // Arrange List messages = [ new(ChatRole.System, "System prompt"), new(ChatRole.User, "Only user message"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + TruncationCompactionStrategy strategy = new(maxTokens: 1); - CompactionResult result = await strategy.CompactAsync(messages, calculator); - - Assert.Equal(2, messages.Count); - Assert.Equal(result.After.MessageCount, result.Before.MessageCount); - Assert.Equal(result.After.TokenCount, result.Before.TokenCount); + // Act & Assert + await RunCompactionStrategySkippedAsync(strategy, messages); } [Fact] public async Task PreserveRecentGroups_KeepsMultipleGroupsAsync() { - // preserveRecentGroups=2 means keep at least the last 2 non-system groups - TruncationCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 2); + // Arrange List messages = [ new(ChatRole.User, "Turn 1"), @@ -117,14 +82,12 @@ public class TruncationCompactionStrategyTests new(ChatRole.User, "Turn 3"), new(ChatRole.Assistant, "Reply 3"), ]; - DefaultChatHistoryMetricsCalculator calculator = new(); + TruncationCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 2); - CompactionResult result = await strategy.CompactAsync(messages, calculator); + // Act & Assert + await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2); - Assert.True(result.Applied); - // 6 groups (3 user + 3 assistant), protect last 2 → 4 removable - // Should keep at least: Turn 3 user + Reply 3 assistant (last 2 groups) - Assert.True(messages.Count >= 2); + // Assert Assert.Equal("Reply 3", messages[^1].Text); } }