diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 75888768fa..6aa7337ba8 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -56,6 +56,7 @@
+
diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj
new file mode 100644
index 0000000000..0f9de7c359
--- /dev/null
+++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net10.0
+
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs
new file mode 100644
index 0000000000..d51fc75621
--- /dev/null
+++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs
@@ -0,0 +1,112 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample demonstrates how to use a ChatHistoryCompactionPipeline as the ChatReducer for an agent's
+// in-memory chat history. The pipeline chains multiple compaction strategies from gentle to aggressive:
+// 1. ToolResultCompactionStrategy - Collapses old tool-call groups into concise summaries
+// 2. SummarizationCompactionStrategy - LLM-compresses older conversation spans
+// 3. SlidingWindowCompactionStrategy - Keeps only the most recent N user turns
+// 4. TruncationCompactionStrategy - Emergency token-budget backstop
+
+using System.ComponentModel;
+using Azure.AI.OpenAI;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Compaction;
+using Microsoft.Extensions.AI;
+
+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";
+
+// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
+// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
+// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
+AzureOpenAIClient openAIClient = new(new Uri(endpoint), new DefaultAzureCredential());
+
+// Create a chat client for the agent and a separate one for the summarization strategy.
+// Using the same model for simplicity; in production, use a smaller/cheaper model for summarization.
+IChatClient agentChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
+IChatClient summarizerChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
+
+// Define a tool the agent can use, so we can see tool-result compaction in action.
+[Description("Look up the current price of a product by name.")]
+static string LookupPrice([Description("The product name to look up.")] string productName) =>
+ productName.ToUpperInvariant() switch
+ {
+ "LAPTOP" => "The laptop costs $999.99.",
+ "KEYBOARD" => "The keyboard costs $79.99.",
+ "MOUSE" => "The mouse costs $29.99.",
+ _ => $"Sorry, I don't have pricing for '{productName}'."
+ };
+
+// Configure the compaction pipeline with one of each strategy, ordered least to most aggressive.
+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),
+
+ // 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),
+
+ // 4. Emergency: drop oldest groups until under the token budget
+ new TruncationCompactionStrategy(MaxTokens, preserveRecentGroups: 1));
+
+// TODO: PRECONFIGURED PIPELINE
+////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, 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();
+
+// Helper to print chat history size
+void PrintChatHistory()
+{
+ if (session.TryGetInMemoryChatHistory(out var history))
+ {
+ Console.WriteLine($" [Chat history: {history.Count} messages]\n");
+ }
+}
+
+// Run a multi-turn conversation with tool calls to exercise the pipeline.
+string[] prompts =
+[
+ "What's the price of a laptop?",
+ "How about a keyboard?",
+ "And a mouse?",
+ "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)
+{
+ Console.WriteLine($"User: {prompt}");
+ Console.WriteLine($"Agent: {await agent.RunAsync(prompt, session)}");
+ PrintChatHistory();
+}
diff --git a/dotnet/samples/02-agents/Agents/README.md b/dotnet/samples/02-agents/Agents/README.md
index 116cbfc06b..4ac53ba246 100644
--- a/dotnet/samples/02-agents/Agents/README.md
+++ b/dotnet/samples/02-agents/Agents/README.md
@@ -44,6 +44,7 @@ Before you begin, ensure you have the following prerequisites:
|[Deep research with an agent](./Agent_Step15_DeepResearch/)|This sample demonstrates how to use the Deep Research Tool to perform comprehensive research on complex topics|
|[Declarative agent](./Agent_Step16_Declarative/)|This sample demonstrates how to declaratively define an agent.|
|[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.|
+|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
## Running the samples from the console
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
new file mode 100644
index 0000000000..9dc4c4e507
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs
@@ -0,0 +1,114 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// Executes a chain of instances in order
+/// against a mutable message list.
+///
+///
+///
+/// Each strategy's trigger is evaluated against the metrics as they stand after prior strategies,
+/// so earlier strategies can bring the conversation within thresholds that cause later strategies to skip.
+///
+///
+/// The pipeline is fully standalone — it can be used without any agent, session, or context provider.
+/// It also implements so it can be used directly anywhere a reducer is
+/// accepted (e.g., ).
+///
+///
+public partial class ChatHistoryCompactionPipeline : IChatReducer
+{
+ private readonly ChatHistoryCompactionStrategy[] _strategies;
+ private readonly IChatHistoryMetricsCalculator _metricsCalculator;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The ordered list of compaction strategies to execute.
+ ///
+ /// By default, is used.
+ ///
+ public ChatHistoryCompactionPipeline(
+ params IEnumerable strategies)
+ : this(metricsCalculator: null, strategies) { }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// An optional metrics calculator. When , a
+ /// is used.
+ ///
+ /// The ordered list of compaction strategies to execute.
+ public ChatHistoryCompactionPipeline(
+ IChatHistoryMetricsCalculator? metricsCalculator,
+ params IEnumerable strategies)
+ {
+ this._strategies = [.. Throw.IfNull(strategies)];
+ this._metricsCalculator = metricsCalculator ?? DefaultChatHistoryMetricsCalculator.Instance;
+ }
+
+ ///
+ /// Reduces the given messages by running all strategies in sequence.
+ ///
+ /// The messages to reduce.
+ /// The to monitor for cancellation requests.
+ /// The reduced set of messages.
+ public virtual async Task> ReduceAsync(
+ IEnumerable messages,
+ CancellationToken cancellationToken = default)
+ {
+ List messageBuffer = messages is List messageList ? messageList : [.. messages];
+ await this.CompactAsync(messageBuffer, cancellationToken).ConfigureAwait(false);
+ return messageBuffer;
+ }
+
+ ///
+ /// Run all strategies in sequence against the given messages.
+ ///
+ /// The mutable message list to compact.
+ /// The to monitor for cancellation requests.
+ /// A with aggregate and per-strategy metrics.
+ public async ValueTask CompactAsync(
+ List messages,
+ CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(messages);
+
+ ChatHistoryMetric overallBefore = this._metricsCalculator.Calculate(messages);
+
+ 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)
+ {
+ // %%% 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;
+ }
+
+ Debug.WriteLineIf(overallBefore.TokenCount != overallAfter.TokenCount, $"COMPACTION: TOTAL [{timer.Elapsed}] {overallBefore.TokenCount} => {overallAfter.TokenCount} tokens");
+
+ 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
new file mode 100644
index 0000000000..5d78bfe6e5
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionStrategy.cs
@@ -0,0 +1,120 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// A named compaction strategy with an optional conditional trigger that delegates
+/// actual message reduction to an .
+///
+///
+///
+/// Each strategy wraps an that performs the actual compaction,
+/// while the strategy adds:
+///
+/// - A conditional trigger via that decides whether compaction runs.
+/// - Before/after reporting via .
+///
+///
+///
+/// For simple cases, construct a directly with any
+/// . For custom trigger logic, subclass and override .
+///
+///
+/// Reducers must preserve atomic message groups: an assistant message containing
+/// tool calls and its corresponding tool result messages must be kept or removed together.
+/// Use to identify these groups when authoring custom reducers.
+///
+///
+public abstract class ChatHistoryCompactionStrategy
+{
+ internal static readonly AsyncLocal s_currentMetrics = new();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The that performs the actual message compaction.
+ protected ChatHistoryCompactionStrategy(IChatReducer reducer)
+ {
+ this.Reducer = Throw.IfNull(reducer);
+ }
+
+ ///
+ /// Exposes the current for the executing strategy, allowing to make informed decisions.
+ ///
+ protected static ChatHistoryMetric CurrentMetrics => s_currentMetrics.Value ?? throw new InvalidOperationException($"No active {nameof(ChatHistoryCompactionStrategy)}.");
+
+ ///
+ /// Gets the that performs the actual message compaction.
+ ///
+ public IChatReducer Reducer { get; }
+
+ ///
+ /// Gets the display name of this strategy, used for logging and diagnostics.
+ ///
+ ///
+ /// The default implementation returns the type name of the underlying .
+ ///
+ public virtual string Name => this.Reducer.GetType().Name;
+
+ ///
+ /// Evaluates whether this strategy should execute given the current conversation metrics.
+ ///
+ /// The current conversation metrics.
+ ///
+ /// to proceed with compaction; to skip.
+ ///
+ 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 calculator to use for metric snapshots.
+ /// The to monitor for cancellation requests.
+ /// A reporting the outcome.
+ internal async ValueTask CompactAsync(
+ List history,
+ IChatHistoryMetricsCalculator metricsCalculator,
+ CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(metricsCalculator);
+ Throw.IfNull(history);
+
+ ChatHistoryMetric beforeMetrics = CurrentMetrics;
+ if (!this.ShouldCompact(beforeMetrics))
+ {
+ // %%% VERBOSE - Debug.WriteLine($"COMPACTION: {this.Name} - Skipped");
+ return CompactionResult.Skipped(this.Name, beforeMetrics);
+ }
+
+ Debug.WriteLine($"COMPACTION: {this.Name} - Reducing");
+
+ 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)
+ {
+ history.Clear();
+ history.AddRange(reducedCopy);
+ }
+
+ ChatHistoryMetric afterMetrics = modified
+ ? metricsCalculator.Calculate(reducedCopy)
+ : beforeMetrics;
+
+ 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/ChatHistoryMetric.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryMetric.cs
new file mode 100644
index 0000000000..f2d9694dfe
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryMetric.cs
@@ -0,0 +1,45 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// Immutable snapshot of conversation metrics used for compaction trigger evaluation and reporting.
+///
+public sealed class ChatHistoryMetric
+{
+ ///
+ /// Gets the estimated token count across all messages.
+ ///
+ public int TokenCount { get; init; }
+
+ ///
+ /// Gets the total serialized byte count of all messages.
+ ///
+ public long ByteCount { get; init; }
+
+#pragma warning disable IDE0001 // Simplify Names
+ ///
+ /// Gets the total number of objects.
+ ///
+#pragma warning restore IDE0001 // Simplify Names
+ public int MessageCount { get; init; }
+
+ ///
+ /// Gets the number of tool/function call content items across all messages.
+ ///
+ public int ToolCallCount { get; init; }
+
+ ///
+ /// Gets the number of user turns. A user turn is a user message together with the full
+ /// set of agent responses (including tool calls and results) before the next user input.
+ ///
+ public int UserTurnCount { get; init; }
+
+ ///
+ /// Gets the atomic message group index for the analyzed messages.
+ /// Each group represents a contiguous range of messages that must be kept or removed together.
+ ///
+ public IReadOnlyList Groups { get; init; } = [];
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatMessageGroup.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatMessageGroup.cs
new file mode 100644
index 0000000000..e9bc36bc19
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatMessageGroup.cs
@@ -0,0 +1,64 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// Represents a contiguous range of messages in a conversation that form an atomic group.
+/// Atomic groups must be kept or removed together to maintain API correctness.
+///
+///
+/// For example, an assistant message containing tool calls and the subsequent tool result messages
+/// form an atomic group — removing one without the other causes API errors.
+///
+public readonly struct ChatMessageGroup : IEquatable
+{
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The zero-based index of the first message in this group.
+ /// The number of messages in this group.
+ /// The kind of this message group.
+ public ChatMessageGroup(int startIndex, int count, ChatMessageGroupKind kind)
+ {
+ this.StartIndex = startIndex;
+ this.Count = count;
+ this.Kind = kind;
+ }
+
+ ///
+ /// Gets the zero-based index of the first message in this group within the original message list.
+ ///
+ public int StartIndex { get; }
+
+ ///
+ /// Gets the number of messages in this group.
+ ///
+ public int Count { get; }
+
+ ///
+ /// Gets the kind of this message group.
+ ///
+ public ChatMessageGroupKind Kind { get; }
+
+ ///
+ public bool Equals(ChatMessageGroup other) =>
+ this.StartIndex == other.StartIndex &&
+ this.Count == other.Count &&
+ this.Kind == other.Kind;
+
+ ///
+ public override bool Equals(object? obj) =>
+ obj is ChatMessageGroup other &&
+ this.Equals(other);
+
+ ///
+ public override int GetHashCode() => HashCode.Combine(this.StartIndex, this.Count, (int)this.Kind);
+
+ /// Determines whether two instances are equal.
+ public static bool operator ==(ChatMessageGroup left, ChatMessageGroup right) => left.Equals(right);
+
+ /// Determines whether two instances are not equal.
+ public static bool operator !=(ChatMessageGroup left, ChatMessageGroup right) => !left.Equals(right);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatMessageGroupKind.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatMessageGroupKind.cs
new file mode 100644
index 0000000000..8a42f88ebd
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatMessageGroupKind.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// Identifies the kind of an atomic message group in a conversation.
+///
+public enum ChatMessageGroupKind
+{
+ /// A system message.
+ System,
+
+ /// A user message (start of a user turn).
+ UserTurn,
+
+ /// An assistant message with tool calls and their corresponding tool result messages.
+ AssistantToolGroup,
+
+ /// An assistant message without tool calls.
+ AssistantPlain,
+
+ /// A tool result message that is not part of a recognized group.
+ ToolResult,
+
+ /// A message with an unrecognized role.
+ Other
+}
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
new file mode 100644
index 0000000000..c416528a83
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionPipelineResult.cs
@@ -0,0 +1,49 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// Reports the aggregate outcome of a execution.
+///
+public sealed class CompactionPipelineResult
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Metrics of the conversation before any strategy ran.
+ /// Metrics of the conversation after all strategies ran.
+ /// Per-strategy results in execution order.
+ internal CompactionPipelineResult(
+ ChatHistoryMetric before,
+ ChatHistoryMetric after,
+ IReadOnlyList strategyResults)
+ {
+ this.Before = Throw.IfNull(before);
+ this.After = Throw.IfNull(after);
+ this.StrategyResults = Throw.IfNull(strategyResults);
+ }
+
+ ///
+ /// Gets the conversation metrics before any compaction strategy ran.
+ ///
+ public ChatHistoryMetric Before { get; }
+
+ ///
+ /// Gets the conversation metrics after all compaction strategies ran.
+ ///
+ public ChatHistoryMetric After { get; }
+
+ ///
+ /// Gets the per-strategy results in execution order.
+ ///
+ public IReadOnlyList StrategyResults { get; }
+
+ ///
+ /// Gets a value indicating whether any strategy modified the message list.
+ ///
+ public bool AnyApplied => this.StrategyResults.Any(r => r.Applied);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionResult.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionResult.cs
new file mode 100644
index 0000000000..2c4b2ad13e
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionResult.cs
@@ -0,0 +1,55 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// Reports the outcome of a single execution.
+///
+public sealed class CompactionResult
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The name of the strategy that produced this result.
+ /// Whether the strategy modified the message list.
+ /// Metrics before the strategy ran.
+ /// Metrics after the strategy ran.
+ public CompactionResult(string strategyName, bool applied, ChatHistoryMetric before, ChatHistoryMetric after)
+ {
+ this.StrategyName = Throw.IfNullOrWhitespace(strategyName);
+ this.Applied = applied;
+ this.Before = Throw.IfNull(before);
+ this.After = Throw.IfNull(after);
+ }
+
+ ///
+ /// Gets the name of the strategy that produced this result.
+ ///
+ public string StrategyName { get; }
+
+ ///
+ /// Gets a value indicating whether the strategy modified the message list.
+ ///
+ public bool Applied { get; }
+
+ ///
+ /// Gets the conversation metrics before the strategy executed.
+ ///
+ public ChatHistoryMetric Before { get; }
+
+ ///
+ /// Gets the conversation metrics after the strategy executed.
+ ///
+ public ChatHistoryMetric After { get; }
+
+ ///
+ /// Creates a representing a skipped strategy.
+ ///
+ /// The name of the skipped strategy.
+ /// The current conversation metrics.
+ /// A result indicating no compaction was applied.
+ 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
new file mode 100644
index 0000000000..2491f99591
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/DefaultChatHistoryMetricsCalculator.cs
@@ -0,0 +1,161 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// Default implementation of that uses
+/// JSON serialization length heuristics for token and byte estimation.
+///
+///
+///
+/// Token estimation uses a configurable characters-per-token ratio (default ~4) since
+/// precise tokenization requires a model-specific tokenizer. For production workloads
+/// requiring accurate token counts, implement
+/// with a model-appropriate tokenizer.
+///
+///
+public sealed class DefaultChatHistoryMetricsCalculator : IChatHistoryMetricsCalculator
+{
+ ///
+ /// Gets the singleton instance of the chat history metrics calculator.
+ ///
+ ///
+ /// can be safety accessed by
+ /// concurrent threads.
+ ///
+ public static readonly DefaultChatHistoryMetricsCalculator Instance = new();
+
+ private const int DefaultCharsPerToken = 4;
+ private const int PerMessageOverheadTokens = 4;
+
+ private readonly int _charsPerToken;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// The approximate number of characters per token used for estimation. Default is 4.
+ ///
+ public DefaultChatHistoryMetricsCalculator(int charsPerToken = DefaultCharsPerToken)
+ {
+ this._charsPerToken = charsPerToken > 0 ? charsPerToken : DefaultCharsPerToken;
+ }
+
+ ///
+ public ChatHistoryMetric Calculate(IReadOnlyList messages)
+ {
+ if (messages is null || messages.Count == 0)
+ {
+ return new();
+ }
+
+ int totalTokens = 0;
+ long totalBytes = 0;
+ int toolCallCount = 0;
+ int userTurnCount = 0;
+ bool inUserTurn = false;
+ List groups = [];
+ int index = 0;
+
+ while (index < messages.Count)
+ {
+ ChatMessage message = messages[index];
+
+ // Accumulate per-message metrics
+ this.AccumulateMessageMetrics(message, ref totalTokens, ref totalBytes, ref toolCallCount);
+
+ if (message.Role == ChatRole.User)
+ {
+ if (!inUserTurn)
+ {
+ userTurnCount++;
+ inUserTurn = true;
+ }
+ }
+ else
+ {
+ inUserTurn = false;
+ }
+
+ // Identify the group starting at this message
+ if (message.Role == ChatRole.System)
+ {
+ groups.Add(new(index, 1, ChatMessageGroupKind.System));
+ index++;
+ }
+ else if (message.Role == ChatRole.User)
+ {
+ groups.Add(new(index, 1, ChatMessageGroupKind.UserTurn));
+ index++;
+ }
+ else if (message.Role == ChatRole.Assistant)
+ {
+ bool hasToolCalls = message.Contents!.Any(c => c is FunctionCallContent);
+
+ if (hasToolCalls)
+ {
+ int groupStart = index;
+ index++;
+
+ while (index < messages.Count && messages[index].Role == ChatRole.Tool)
+ {
+ this.AccumulateMessageMetrics(messages[index], ref totalTokens, ref totalBytes, ref toolCallCount);
+ inUserTurn = false;
+ index++;
+ }
+
+ groups.Add(new(groupStart, index - groupStart, ChatMessageGroupKind.AssistantToolGroup));
+ }
+ else
+ {
+ groups.Add(new(index, 1, ChatMessageGroupKind.AssistantPlain));
+ index++;
+ }
+ }
+ else if (message.Role == ChatRole.Tool)
+ {
+ groups.Add(new(index, 1, ChatMessageGroupKind.ToolResult));
+ index++;
+ }
+ else
+ {
+ groups.Add(new(index, 1, ChatMessageGroupKind.Other));
+ index++;
+ }
+ }
+
+ return new()
+ {
+ TokenCount = totalTokens,
+ ByteCount = totalBytes,
+ MessageCount = messages.Count,
+ ToolCallCount = toolCallCount,
+ UserTurnCount = userTurnCount,
+ Groups = groups
+ };
+ }
+
+ private void AccumulateMessageMetrics(ChatMessage message, ref int totalTokens, ref long totalBytes, ref int toolCallCount)
+ {
+ string serialized = message.Text;
+
+ int charCount = serialized.Length;
+ totalBytes += System.Text.Encoding.UTF8.GetByteCount(serialized);
+ totalTokens += (charCount / this._charsPerToken) + PerMessageOverheadTokens;
+
+ if (message.Contents is not null)
+ {
+ foreach (AIContent content in message.Contents)
+ {
+ if (content is FunctionCallContent)
+ {
+ toolCallCount++;
+ }
+ }
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/IChatHistoryMetricsCalculator.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/IChatHistoryMetricsCalculator.cs
new file mode 100644
index 0000000000..3c4c124444
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/IChatHistoryMetricsCalculator.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+// %%% TODO: Is this interface needed? Consider whether the default implementation is sufficient
+// and whether custom metrics calculators are a realistic extension point.
+
+///
+/// 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
+{
+ ///
+ /// Compute metrics for the given messages.
+ ///
+ /// The messages to analyze.
+ /// 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
new file mode 100644
index 0000000000..51b5b90a1d
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SlidingWindowCompactionStrategy.cs
@@ -0,0 +1,91 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// A compaction strategy that keeps only the most recent user turns and their
+/// associated response groups, removing older turns to bound conversation length.
+///
+///
+///
+/// This strategy always preserves system messages. It identifies user turns in the
+/// conversation and keeps the last maxTurns turns along with all response groups
+/// (assistant replies, tool call groups) that follow each kept turn.
+///
+///
+/// The trigger condition fires only when the number of user turns exceeds maxTurns.
+///
+///
+/// This strategy is more predictable than token-based truncation for bounding conversation
+/// length, since it operates on logical turn boundaries rather than estimated token counts.
+///
+///
+public class SlidingWindowCompactionStrategy : ChatHistoryCompactionStrategy
+{
+ private readonly int _maxTurns;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// The maximum number of user turns to keep. Older turns and their associated responses are removed.
+ ///
+ public SlidingWindowCompactionStrategy(int maxTurns)
+ : base(new SlidingWindowReducer(maxTurns))
+ {
+ this._maxTurns = maxTurns;
+ }
+
+ ///
+ protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
+ metrics.UserTurnCount > this._maxTurns;
+
+ ///
+ /// An that keeps system messages and the last N user turns
+ /// with all their associated response groups.
+ ///
+ private sealed class SlidingWindowReducer(int maxTurns) : IChatReducer
+ {
+ public Task> ReduceAsync(
+ IEnumerable messages,
+ CancellationToken cancellationToken = default)
+ {
+ IReadOnlyList messageList = [.. messages]; // %%% PERFORMANCE
+ IReadOnlyList groups = CurrentMetrics.Groups;
+
+ // Find the group-list indices where each user turn starts
+ 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.Length - maxTurns;
+ int firstKeptGroupIndex = turnGroupIndices[firstKeptTurnIndex];
+
+ List result = new(messageList.Count); // %%% PERFORMANCE
+ for (int gi = 0; gi < groups.Count; gi++)
+ {
+ ChatMessageGroup group = groups[gi];
+
+ // Always keep system messages; keep groups at or after the window start
+ if (group.Kind == ChatMessageGroupKind.System || gi >= firstKeptGroupIndex)
+ {
+ for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
+ {
+ result.Add(messageList[j]);
+ }
+ }
+ }
+
+ return Task.FromResult>(result);
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SummarizationCompactionStrategy.cs
new file mode 100644
index 0000000000..028077beae
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SummarizationCompactionStrategy.cs
@@ -0,0 +1,166 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// A compaction strategy that uses an LLM to summarize older portions of the conversation,
+/// replacing them with a concise summary message that preserves key facts and context.
+///
+///
+///
+/// This strategy sits between tool-result clearing (gentle) and truncation (aggressive) in the
+/// compaction ladder. Unlike truncation which discards messages entirely, summarization preserves
+/// the essential information in compressed form, allowing the agent to maintain awareness of
+/// earlier context.
+///
+///
+/// The strategy protects system messages and the most recent preserveRecentGroups
+/// non-system groups. All older groups are collected and sent to the
+/// for summarization. The resulting summary replaces those messages as a single assistant message.
+///
+///
+public class SummarizationCompactionStrategy : ChatHistoryCompactionStrategy
+{
+ private readonly int _maxTokens;
+
+ ///
+ /// The default summarization prompt used when none is provided.
+ ///
+ public const string DefaultSummarizationPrompt =
+ """
+ You are a conversation summarizer. Produce a concise summary of the conversation that preserves:
+
+ - Key facts, decisions, and user preferences
+ - Important context needed for future turns
+ - Tool call outcomes and their significance
+
+ Omit pleasantries and redundant exchanges. Be factual and brief.
+ """;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The to use for generating summaries. A smaller, faster model is recommended.
+ /// The maximum token budget. Summarization is triggered when the token count exceeds this value.
+ ///
+ /// The number of most-recent non-system message groups to protect from summarization.
+ /// Defaults to 4, preserving the current and recent exchanges.
+ ///
+ ///
+ /// An optional custom system prompt for the summarization LLM call. When ,
+ /// a default prompt that emphasizes fact-preservation is used.
+ ///
+ public SummarizationCompactionStrategy(
+ IChatClient chatClient,
+ int maxTokens,
+ int preserveRecentGroups = 4,
+ string? summarizationPrompt = null)
+ : base(new SummarizationReducer(chatClient, preserveRecentGroups, summarizationPrompt ?? DefaultSummarizationPrompt))
+ {
+ this._maxTokens = maxTokens;
+ }
+
+ ///
+ protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
+ metrics.TokenCount > this._maxTokens;
+
+ ///
+ /// An that sends older message groups to an LLM for summarization,
+ /// then replaces them with a single summary message.
+ ///
+ private sealed class SummarizationReducer : IChatReducer
+ {
+ private readonly IChatClient _chatClient;
+ private readonly int _preserveRecentGroups;
+ private readonly string _summarizationPrompt;
+
+ public SummarizationReducer(IChatClient chatClient, int preserveRecentGroups, string summarizationPrompt)
+ {
+ this._chatClient = Throw.IfNull(chatClient);
+ this._preserveRecentGroups = preserveRecentGroups;
+ this._summarizationPrompt = Throw.IfNullOrEmpty(summarizationPrompt);
+ }
+
+ public async Task> ReduceAsync(
+ IEnumerable messages,
+ CancellationToken cancellationToken = default)
+ {
+ IReadOnlyList messageList = [.. messages];
+ IReadOnlyList groups = CurrentMetrics.Groups;
+
+ List nonSystemGroups = [.. groups.Where(g => g.Kind != ChatMessageGroupKind.System)];
+ int protectedFromIndex = Math.Max(0, nonSystemGroups.Count - this._preserveRecentGroups);
+
+ if (protectedFromIndex == 0)
+ {
+ // Nothing to summarize — all groups are protected
+ return messageList;
+ }
+
+ // Collect messages from groups that will be summarized
+ List toSummarize = [];
+ for (int i = 0; i < protectedFromIndex; i++)
+ {
+ ChatMessageGroup group = nonSystemGroups[i];
+ for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
+ {
+ toSummarize.Add(messageList[j]);
+ }
+ }
+
+ if (toSummarize.Count == 0)
+ {
+ return messageList;
+ }
+
+ // Build the summarization request
+ List summarizationRequest =
+ [
+ new(ChatRole.System, this._summarizationPrompt),
+ .. toSummarize,
+ new(ChatRole.User, "Summarize the conversation above concisely."),
+ ];
+
+ ChatResponse response = await this._chatClient.GetResponseAsync(summarizationRequest, cancellationToken: cancellationToken).ConfigureAwait(false);
+ string summaryText = string.IsNullOrWhiteSpace(response.Text) ? "[Summary unavailable]" : response.Text;
+
+ // Build result: system groups + summary + protected groups
+ List result = [];
+
+ // Keep system messages
+ foreach (ChatMessageGroup group in groups)
+ {
+ if (group.Kind == ChatMessageGroupKind.System)
+ {
+ for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
+ {
+ result.Add(messageList[j]);
+ }
+ }
+ }
+
+ // Insert summary
+ result.Add(new ChatMessage(ChatRole.Assistant, $"[Summary]\n{summaryText}"));
+
+ // Keep protected groups
+ for (int i = protectedFromIndex; i < nonSystemGroups.Count; i++)
+ {
+ ChatMessageGroup group = nonSystemGroups[i];
+ for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
+ {
+ result.Add(messageList[j]);
+ }
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ToolResultCompactionStrategy.cs
new file mode 100644
index 0000000000..26380d6d6d
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ToolResultCompactionStrategy.cs
@@ -0,0 +1,118 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// A compaction strategy that collapses old assistant-tool-call groups into single
+/// concise assistant messages, removing the detailed tool results while preserving
+/// a record of which tools were called.
+///
+///
+///
+/// This is the gentlest compaction strategy — it does not remove any user messages or
+/// plain assistant responses. It only targets
+/// entries outside the protected recent window, replacing each multi-message group
+/// (assistant call + tool results) with a single assistant message like
+/// [Tool calls: get_weather, search_docs].
+///
+///
+/// The trigger condition fires only when token count exceeds maxTokens and
+/// there is at least one tool call in the conversation.
+///
+///
+public class ToolResultCompactionStrategy : ChatHistoryCompactionStrategy
+{
+ ///
+ /// The default value for `preserveRecentGroups` used when constructing .
+ ///
+ public const int DefaultPreserveRecentGroups = 2;
+
+ private readonly int _maxTokens;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The maximum token budget. Tool groups are collapsed when the token count exceeds this value.
+ ///
+ /// 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 = DefaultPreserveRecentGroups)
+ : base(new ToolResultClearingReducer(preserveRecentGroups))
+ {
+ this._maxTokens = maxTokens;
+ }
+
+ ///
+ protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
+ metrics.TokenCount > this._maxTokens && metrics.ToolCallCount > 0;
+
+ ///
+ /// An that collapses
+ /// entries into single summary messages, preserving the most recent groups.
+ ///
+ private sealed class ToolResultClearingReducer(int preserveRecentGroups) : IChatReducer
+ {
+ public Task> ReduceAsync(
+ IEnumerable messages,
+ CancellationToken cancellationToken = default)
+ {
+ IReadOnlyList messageList = [.. messages];
+ IReadOnlyList groups = CurrentMetrics.Groups;
+
+ 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++)
+ {
+ protectedGroupStarts.Add(nonSystemGroups[i].StartIndex);
+ }
+
+ List result = new(messageList.Count);
+ bool anyCollapsed = false;
+
+ foreach (ChatMessageGroup group in groups)
+ {
+ if (group.Kind == ChatMessageGroupKind.AssistantToolGroup && !protectedGroupStarts.Contains(group.StartIndex))
+ {
+ // Collapse this tool group into a single summary message
+ List toolNames = [];
+ for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
+ {
+ if (messageList[j].Contents is not null)
+ {
+ foreach (AIContent content in messageList[j].Contents)
+ {
+ if (content is FunctionCallContent fcc)
+ {
+ toolNames.Add(fcc.Name);
+ }
+ }
+ }
+ }
+
+ string summary = $"[Tool calls: {string.Join(", ", toolNames)}]";
+ result.Add(new ChatMessage(ChatRole.Assistant, summary));
+ anyCollapsed = true;
+ }
+ else
+ {
+ // Keep this group as-is
+ for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
+ {
+ result.Add(messageList[j]);
+ }
+ }
+ }
+
+ return Task.FromResult>(anyCollapsed ? result : messageList);
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/TruncationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/TruncationCompactionStrategy.cs
new file mode 100644
index 0000000000..a2a6b07acf
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/TruncationCompactionStrategy.cs
@@ -0,0 +1,97 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// A compaction strategy that removes the oldest message groups until the estimated
+/// token count is within a specified budget.
+///
+///
+///
+/// This strategy preserves system messages and removes the oldest non-system message groups first.
+/// It respects atomic group boundaries — an assistant message with tool calls and its
+/// corresponding tool result messages are always removed together.
+///
+///
+/// The trigger condition fires only when the current token count exceeds maxTokens.
+///
+///
+public class TruncationCompactionStrategy : ChatHistoryCompactionStrategy
+{
+ private readonly int _maxTokens;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The maximum token budget. Groups are removed until the token count is at or below this value.
+ ///
+ /// The minimum number of most-recent non-system message groups to keep.
+ /// Defaults to 1 so that at least the latest exchange is always preserved.
+ ///
+ public TruncationCompactionStrategy(int maxTokens, int preserveRecentGroups = 1)
+ : base(new TruncationReducer(preserveRecentGroups))
+ {
+ this._maxTokens = maxTokens;
+ }
+
+ ///
+ protected override bool ShouldCompact(ChatHistoryMetric metrics) =>
+ metrics.TokenCount > this._maxTokens;
+
+ ///
+ /// An that removes the oldest non-system message groups,
+ /// keeping at least the most recent group.
+ ///
+ private sealed class TruncationReducer(int preserveRecentGroups) : IChatReducer
+ {
+ public Task> ReduceAsync(
+ IEnumerable messages,
+ CancellationToken cancellationToken = default)
+ {
+ IReadOnlyList messageList = [.. messages];
+
+ ChatMessageGroup[] removableGroups = [.. CurrentMetrics.Groups.Where(g => g.Kind != ChatMessageGroupKind.System)];
+
+ if (removableGroups.Length == 0)
+ {
+ return Task.FromResult>(messageList);
+ }
+
+ // Remove oldest non-system groups, keeping at least preserveRecentGroups.
+ int maxRemovable = removableGroups.Length - preserveRecentGroups;
+
+ if (maxRemovable <= 0)
+ {
+ return Task.FromResult>(messageList);
+ }
+
+ HashSet removedGroupStarts = [];
+ for (int ri = 0; ri < maxRemovable; ri++)
+ {
+ removedGroupStarts.Add(removableGroups[ri].StartIndex);
+ }
+
+ List messagesToKeep = new(messageList.Count);
+ foreach (ChatMessageGroup group in CurrentMetrics.Groups)
+ {
+ if (removedGroupStarts.Contains(group.StartIndex))
+ {
+ continue;
+ }
+
+ for (int j = group.StartIndex; j < group.StartIndex + group.Count; j++)
+ {
+ messagesToKeep.Add(messageList[j]);
+ }
+ }
+
+ return Task.FromResult>(messagesToKeep);
+ }
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj b/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj
index e31093e174..01fd5db648 100644
--- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj
@@ -31,6 +31,10 @@
+
+
+
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionPipelineTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionPipelineTests.cs
new file mode 100644
index 0000000000..75658054d1
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionPipelineTests.cs
@@ -0,0 +1,136 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
+using Microsoft.Agents.AI.Compaction;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
+
+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);
+ Assert.Empty(result.StrategyResults);
+ }
+
+ [Fact]
+ public async Task ChainsStrategies_InOrderAsync()
+ {
+ // Arrange
+ ChatHistoryCompactionStrategy[] strategies =
+ [
+ new NeverCompactStrategy(),
+ new RemoveFirstMessageStrategy(),
+ ];
+ ChatHistoryCompactionPipeline pipeline = new(strategies);
+ List messages =
+ [
+ new(ChatRole.User, "First"),
+ 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);
+ Assert.True(result.StrategyResults[1].Applied);
+ Assert.Single(messages);
+ }
+
+ [Fact]
+ public async Task ReportsOverallMetricsAsync()
+ {
+ // Arrange
+ ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]);
+ List messages =
+ [
+ new(ChatRole.User, "First"),
+ new(ChatRole.User, "Second"),
+ new(ChatRole.User, "Third"),
+ ];
+
+ // Act
+ CompactionPipelineResult result = await pipeline.CompactAsync(messages);
+
+ // Assert
+ Assert.Equal(3, result.Before.MessageCount);
+ Assert.Equal(2, result.After.MessageCount);
+ }
+
+ [Fact]
+ public async Task CustomMetricsCalculator_IsUsedAsync()
+ {
+ // Arrange
+ Moq.Mock calcMock = new();
+ calcMock
+ .Setup(c => c.Calculate(Moq.It.IsAny>()))
+ .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.Once);
+ }
+
+ [Fact]
+ public async Task ReduceAsync_DelegatesCompactionAsync()
+ {
+ // Arrange
+ ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]);
+ List messages =
+ [
+ new(ChatRole.User, "First"),
+ new(ChatRole.User, "Second"),
+ new(ChatRole.User, "Third"),
+ ];
+
+ // 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);
+ }
+
+ [Fact]
+ public async Task ReduceAsync_EmptyStrategies_ReturnsAllMessagesAsync()
+ {
+ // Arrange
+ ChatHistoryCompactionPipeline pipeline = new([]);
+ ChatMessage[] messages =
+ [
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.User, "World"),
+ ];
+
+ // 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
new file mode 100644
index 0000000000..a0635de76e
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionStrategyTests.cs
@@ -0,0 +1,144 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
+using Microsoft.Agents.AI.Compaction;
+using Microsoft.Extensions.AI;
+using Moq;
+
+namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
+
+public class ChatHistoryCompactionStrategyTests
+{
+ [Fact]
+ public async Task ShouldCompactReturnsFalse_SkipsAsync()
+ {
+ // Arrange
+ List messages = [new(ChatRole.User, "Hello")];
+ NeverCompactStrategy strategy = new();
+
+ // Act
+ CompactionResult result = await RunCompactionStrategyAsync(strategy, messages);
+
+ // Assert
+ Assert.False(result.Applied);
+ }
+
+ [Fact]
+ public async Task ShouldCompactReturnsTrue_RunsCompactionAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.User, "First"),
+ new(ChatRole.User, "Second"),
+ ];
+ RemoveFirstMessageStrategy strategy = new();
+
+ // Act
+ CompactionResult result = await RunCompactionStrategyAsync(strategy, messages);
+
+ // Assert
+ Assert.True(result.Applied);
+ Assert.Single(messages);
+ Assert.Equal("Second", messages[0].Text);
+ Assert.Equal(2, result.Before.MessageCount);
+ Assert.Equal(1, result.After.MessageCount);
+ }
+
+ [Fact]
+ public async Task DelegatesToReducerAsync()
+ {
+ // 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 messages, CancellationToken _) => messages.Skip(1));
+ TestCompactionStrategy strategy = new(reducerMock.Object);
+
+ // Act
+ CompactionResult result = await RunCompactionStrategyAsync(strategy, messages);
+
+ // Assert
+ Assert.True(result.Applied);
+ Assert.Single(messages);
+ Assert.Equal("Second", messages[0].Text);
+ reducerMock.Verify(r => r.ReduceAsync(It.IsAny>(), It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task ReducerNoChange_ReturnsFalseAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.User, "Hello"),
+ ];
+ Mock reducerMock = new();
+ reducerMock
+ .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny()))
+ .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs);
+ TestCompactionStrategy strategy = new(reducerMock.Object, shouldCompact: false);
+
+ // Act
+ CompactionResult result = await RunCompactionStrategyAsync(strategy, messages);
+
+ // Assert
+ Assert.False(result.Applied);
+ Assert.Single(messages);
+ }
+
+ [Fact]
+ public void ReducerLifecycle()
+ {
+ // Arrange
+ Mock reducerMock = new();
+
+ // Act
+ TestCompactionStrategy strategy = new(reducerMock.Object);
+
+ // Assert
+ Assert.Same(reducerMock.Object, strategy.Reducer);
+ Assert.NotNull(strategy.Name);
+ Assert.NotEmpty(strategy.Name);
+ 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;
+
+ public TestCompactionStrategy(IChatReducer reducer, bool shouldCompact = true)
+ : base(reducer)
+ {
+ this._shouldCompact = shouldCompact;
+ }
+
+ protected override bool ShouldCompact(ChatHistoryMetric metrics) => this._shouldCompact;
+
+ 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
new file mode 100644
index 0000000000..ace35e3607
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionMetricTests.cs
@@ -0,0 +1,44 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Agents.AI.Compaction;
+
+namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
+
+public class CompactionMetricTests
+{
+ [Fact]
+ public void DefaultValues_AreZero()
+ {
+ // Arrange & Act
+ ChatHistoryMetric metrics = new();
+
+ // Assert
+ Assert.Equal(0, metrics.TokenCount);
+ Assert.Equal(0L, metrics.ByteCount);
+ Assert.Equal(0, metrics.MessageCount);
+ Assert.Equal(0, metrics.ToolCallCount);
+ Assert.Equal(0, metrics.UserTurnCount);
+ Assert.Empty(metrics.Groups);
+ }
+
+ [Fact]
+ public void InitProperties_SetCorrectly()
+ {
+ // Arrange & Act
+ ChatHistoryMetric metrics = new()
+ {
+ TokenCount = 100,
+ ByteCount = 500,
+ MessageCount = 5,
+ ToolCallCount = 2,
+ UserTurnCount = 3
+ };
+
+ // Assert
+ Assert.Equal(100, metrics.TokenCount);
+ Assert.Equal(500L, metrics.ByteCount);
+ Assert.Equal(5, metrics.MessageCount);
+ Assert.Equal(2, metrics.ToolCallCount);
+ Assert.Equal(3, metrics.UserTurnCount);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionPipelineResultTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionPipelineResultTests.cs
new file mode 100644
index 0000000000..7edc8426f5
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionPipelineResultTests.cs
@@ -0,0 +1,57 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+
+using Microsoft.Agents.AI.Compaction;
+
+namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
+
+public class CompactionPipelineResultTests
+{
+ [Fact]
+ public void Properties_AreReadable()
+ {
+ // 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);
+ }
+
+ [Fact]
+ public void AnyApplied_AllFalse_ReturnsFalse()
+ {
+ // 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()
+ {
+ // 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
new file mode 100644
index 0000000000..bc36898a4d
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionResultTests.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Agents.AI.Compaction;
+
+namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
+
+public class CompactionResultTests
+{
+ [Fact]
+ public void Skipped_HasSameBeforeAndAfter()
+ {
+ // 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);
+ Assert.Same(metrics, result.After);
+ }
+}
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
new file mode 100644
index 0000000000..2edbe6a4ff
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/DefaultChatHistoryMetricsCalculatorTests.cs
@@ -0,0 +1,399 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using Microsoft.Agents.AI.Compaction;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
+
+public class DefaultChatHistoryMetricsCalculatorTests
+{
+ [Fact]
+ public void EmptyList_ReturnsZeroMetrics()
+ {
+ // Arrange
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ // Act
+ ChatHistoryMetric metrics = calculator.Calculate([]);
+
+ // Assert
+ Assert.Equal(0, metrics.TokenCount);
+ Assert.Equal(0L, metrics.ByteCount);
+ Assert.Equal(0, metrics.MessageCount);
+ Assert.Equal(0, metrics.ToolCallCount);
+ Assert.Equal(0, metrics.UserTurnCount);
+ }
+
+ [Fact]
+ public void CountsMessages()
+ {
+ // Arrange
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ List messages =
+ [
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.Assistant, "Hi there"),
+ ];
+
+ // Act
+ ChatHistoryMetric metrics = calculator.Calculate(messages);
+
+ // Assert
+ Assert.Equal(2, metrics.MessageCount);
+ }
+
+ [Fact]
+ public void CountsUserTurns()
+ {
+ // Arrange
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ List messages =
+ [
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.Assistant, "Hi"),
+ new(ChatRole.User, "How are you?"),
+ new(ChatRole.Assistant, "Good"),
+ ];
+
+ // 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" }),
+ new FunctionCallContent("call2", "get_time"),
+ ]);
+ List messages =
+ [
+ new(ChatRole.User, "What's the weather?"),
+ assistantMsg,
+ ];
+
+ // Act
+ ChatHistoryMetric metrics = calculator.Calculate(messages);
+
+ // Assert
+ Assert.Equal(2, metrics.ToolCallCount);
+ }
+
+ [Fact]
+ public void ConsecutiveUserMessages_CountAsOneTurn()
+ {
+ // Arrange
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ List messages =
+ [
+ new(ChatRole.User, "First"),
+ new(ChatRole.User, "Second"),
+ new(ChatRole.Assistant, "Reply"),
+ ];
+
+ // 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"),
+ ];
+
+ // Act
+ ChatHistoryMetric metrics = calculator.Calculate(messages);
+
+ // Assert
+ Assert.True(metrics.TokenCount > 0);
+ Assert.True(metrics.ByteCount > 0);
+ }
+
+ [Fact]
+ public void NullInput_ReturnsZeroMetrics()
+ {
+ // Arrange
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ // Act
+ ChatHistoryMetric metrics = calculator.Calculate(null!);
+
+ // Assert
+ Assert.Equal(0, metrics.TokenCount);
+ Assert.Equal(0L, metrics.ByteCount);
+ Assert.Equal(0, metrics.MessageCount);
+ Assert.Empty(metrics.Groups);
+ }
+
+ [Fact]
+ public void InvalidCharsPerToken_UsesDefault()
+ {
+ // Arrange
+ DefaultChatHistoryMetricsCalculator calculator = new(charsPerToken: 0);
+ List messages =
+ [
+ new(ChatRole.User, "Hello world"),
+ ];
+
+ // Act
+ ChatHistoryMetric metrics = calculator.Calculate(messages);
+
+ // Assert
+ Assert.True(metrics.TokenCount > 0);
+ }
+
+ [Fact]
+ public void NullMessageText_HandledGracefully()
+ {
+ // Arrange
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ ChatMessage msg = new() { Role = ChatRole.User };
+ List messages = [msg];
+
+ // Act
+ ChatHistoryMetric metrics = calculator.Calculate(messages);
+
+ // Assert
+ Assert.Equal(1, metrics.MessageCount);
+ 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];
+
+ // Act
+ ChatHistoryMetric metrics = calculator.Calculate(messages);
+
+ // Assert
+ Assert.Equal(1, metrics.MessageCount);
+ Assert.Equal(0, metrics.ToolCallCount);
+ }
+
+ [Fact]
+ public void MessageWithOnlyNonTextContent_NullTextHandled()
+ {
+ // Arrange
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ ChatMessage msg = new(ChatRole.Assistant,
+ [
+ new FunctionCallContent("c1", "func"),
+ ]);
+ List messages = [msg];
+
+ // Act
+ ChatHistoryMetric metrics = calculator.Calculate(messages);
+
+ // Assert
+ Assert.Equal(1, metrics.MessageCount);
+ Assert.Equal(1, metrics.ToolCallCount);
+ }
+
+ [Fact]
+ public void Calculate_PopulatesGroupIndex()
+ {
+ // Arrange
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ List messages =
+ [
+ new(ChatRole.System, "System prompt"),
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.Assistant, "Hi there"),
+ ];
+
+ // 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);
+ Assert.Equal(ChatMessageGroupKind.AssistantPlain, metrics.Groups[2].Kind);
+ }
+
+ [Fact]
+ public void EmptyList_GroupIndexIsEmpty()
+ {
+ // Arrange
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ // 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);
+ Assert.Equal(1, groups[0].Count);
+ }
+
+ [Fact]
+ public void GroupIndex_AssistantWithToolCalls_GroupedWithResults()
+ {
+ // Arrange
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ ChatMessage assistantMsg = new(ChatRole.Assistant, [
+ new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "NYC" }),
+ ]);
+ ChatMessage toolResult = new(ChatRole.Tool, [
+ new FunctionResultContent("call1", "Sunny, 72°F"),
+ ]);
+ List messages =
+ [
+ new(ChatRole.User, "What's the weather?"),
+ assistantMsg,
+ 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);
+ }
+
+ [Fact]
+ public void GroupIndex_MultipleToolResults_GroupedTogether()
+ {
+ // Arrange
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ ChatMessage assistantMsg = new(ChatRole.Assistant, [
+ new FunctionCallContent("c1", "func1"),
+ new FunctionCallContent("c2", "func2"),
+ ]);
+ ChatMessage tool1 = new(ChatRole.Tool, [new FunctionResultContent("c1", "result1")]);
+ 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);
+ }
+
+ [Fact]
+ public void GroupIndex_ComplexConversation_CorrectGrouping()
+ {
+ // Arrange
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ List messages =
+ [
+ new(ChatRole.System, "You are a helper"),
+ new(ChatRole.User, "Hi"),
+ new(ChatRole.Assistant, "Hello!"),
+ new(ChatRole.User, "Get weather"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
+ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
+ 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);
+ 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);
+ }
+
+ [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);
+ }
+
+ [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/AgentRunContextHarness.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/AgentRunContextHarness.cs
new file mode 100644
index 0000000000..6d9ae2eac1
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/AgentRunContextHarness.cs
@@ -0,0 +1,52 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
+
+///
+/// Provides a way to set in unit tests
+/// so that the underlying AsyncLocal is populated for code that reads it.
+///
+internal static class AgentRunContextHarness
+{
+ private static readonly ContextAgentShim s_instance = new();
+
+ ///
+ /// Sets and invokes the provided action.
+ ///
+ public static void ExecuteWithRunContext(AgentRunContext context, Action action)
+ {
+ Assert.NotNull(context);
+ Assert.NotNull(action);
+ //AgentRunContext context = new(agent, session, messages ?? [], options); // %%% TODO
+ s_instance.Set(context);
+ action.Invoke();
+ }
+
+ // Derived class that exposes the protected setter.
+ private sealed class ContextAgentShim : AIAgent
+ {
+ public void Set(AgentRunContext? value) => CurrentRunContext = value;
+
+ protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+
+ protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+
+ protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+
+ protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+
+ protected override IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
+ => throw new NotSupportedException();
+ }
+}
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
new file mode 100644
index 0000000000..f126ed5db2
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NeverCompactStrategy.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Compaction;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
+
+internal sealed class NeverCompactStrategy : ChatHistoryCompactionStrategy
+{
+ public NeverCompactStrategy()
+ : base(new NoOpReducer())
+ {
+ }
+
+ public override string Name => "NeverCompact";
+
+ protected override bool ShouldCompact(ChatHistoryMetric metrics) => false;
+
+ private sealed class NoOpReducer : IChatReducer
+ {
+ public Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken = default)
+ => Task.FromResult(messages);
+ }
+}
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
new file mode 100644
index 0000000000..477a7815a0
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/RemoveFirstMessageStrategy.cs
@@ -0,0 +1,36 @@
+// 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;
+
+namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
+
+internal sealed class RemoveFirstMessageStrategy : ChatHistoryCompactionStrategy
+{
+ public RemoveFirstMessageStrategy()
+ : base(new RemoveFirstReducer())
+ {
+ }
+
+ public override string Name => "RemoveFirst";
+
+ protected override bool ShouldCompact(ChatHistoryMetric metrics) => metrics.MessageCount > 0;
+
+ private sealed class RemoveFirstReducer : IChatReducer
+ {
+ public Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken = default)
+ {
+ List list = messages.ToList();
+ if (list.Count > 1)
+ {
+ list.RemoveAt(0);
+ }
+
+ return Task.FromResult>(list);
+ }
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupTests.cs
new file mode 100644
index 0000000000..c84ebbb1ac
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupTests.cs
@@ -0,0 +1,65 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Agents.AI.Compaction;
+
+namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
+
+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);
+ Assert.True(a != c);
+ }
+
+ [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
new file mode 100644
index 0000000000..b3e75920ba
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs
@@ -0,0 +1,135 @@
+// 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 class SlidingWindowCompactionStrategyTests : CompactionStrategyTestBase
+{
+ [Fact]
+ public async Task UnderLimit_NoChangeAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.Assistant, "Hi"),
+ ];
+ SlidingWindowCompactionStrategy strategy = new(maxTurns: 10);
+
+ // Act & Assert
+ await RunCompactionStrategySkippedAsync(strategy, messages);
+ }
+
+ [Fact]
+ public async Task KeepsLastNTurnsAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.User, "Turn 1"),
+ new(ChatRole.Assistant, "Reply 1"),
+ new(ChatRole.User, "Turn 2"),
+ new(ChatRole.Assistant, "Reply 2"),
+ new(ChatRole.User, "Turn 3"),
+ new(ChatRole.Assistant, "Reply 3"),
+ ];
+ SlidingWindowCompactionStrategy strategy = new(maxTurns: 2);
+
+ // Act & Assert
+ await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4);
+
+ // Assert
+ Assert.Equal("Turn 2", messages[0].Text);
+ Assert.Equal("Reply 2", messages[1].Text);
+ Assert.Equal("Turn 3", messages[2].Text);
+ Assert.Equal("Reply 3", messages[3].Text);
+ }
+
+ [Fact]
+ public async Task PreservesSystemMessagesAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.System, "You are a helper"),
+ new(ChatRole.User, "Turn 1"),
+ new(ChatRole.Assistant, "Reply 1"),
+ new(ChatRole.User, "Turn 2"),
+ new(ChatRole.Assistant, "Reply 2"),
+ ];
+ SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
+
+ // Act & Assert
+ await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3);
+
+ // Assert
+ Assert.Equal(ChatRole.System, messages[0].Role);
+ Assert.Equal("You are a helper", messages[0].Text);
+ Assert.Equal("Turn 2", messages[1].Text);
+ Assert.Equal("Reply 2", messages[2].Text);
+ }
+
+ [Fact]
+ public async Task PreservesToolGroupsWithinKeptTurnsAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.User, "Turn 1"),
+ new(ChatRole.Assistant, "Reply 1"),
+ new(ChatRole.User, "Get weather"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
+ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
+ new(ChatRole.Assistant, "It's sunny!"),
+ ];
+ SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
+
+ // Act & Assert
+ await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4);
+
+ // Assert
+ Assert.Equal("Get weather", messages[0].Text);
+ }
+
+ [Fact]
+ public async Task SingleTurn_AtLimit_NoChangeAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.Assistant, "Hi"),
+ ];
+ SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
+
+ // Act & Assert
+ await RunCompactionStrategySkippedAsync(strategy, messages);
+ }
+
+ [Fact]
+ public async Task DropsResponseGroupsFromOldTurnsAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.User, "Turn 1"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "search")]),
+ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "result")]),
+ new(ChatRole.Assistant, "Here's what I found"),
+ new(ChatRole.User, "Turn 2"),
+ new(ChatRole.Assistant, "Reply 2"),
+ ];
+ SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
+
+ // Act & Assert
+ await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2);
+
+ // 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
new file mode 100644
index 0000000000..018d35ce48
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs
@@ -0,0 +1,164 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+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 SummarizationCompactionStrategyTests : CompactionStrategyTestBase
+{
+ [Fact]
+ public async Task UnderLimit_NoChangeAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.Assistant, "Hi"),
+ ];
+ Mock chatClientMock = new();
+ SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100000);
+
+ // Act & Assert
+ await RunCompactionStrategySkippedAsync(strategy, messages);
+
+ // Assert
+ chatClientMock.Verify(
+ c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task SummarizesOldGroupsAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.User, "What's the weather?"),
+ new(ChatRole.Assistant, "The weather is sunny and 72°F."),
+ new(ChatRole.User, "How about tomorrow?"),
+ new(ChatRole.Assistant, "Tomorrow will be cloudy."),
+ new(ChatRole.User, "Thanks!"),
+ new(ChatRole.Assistant, "You're welcome!"),
+ ];
+ 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);
+
+ // Act & Assert
+ await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3);
+
+ // Assert
+ Assert.Contains("[Summary]", messages[0].Text);
+ Assert.Contains("sunny", messages[0].Text);
+ Assert.Equal("Thanks!", messages[1].Text);
+ Assert.Equal("You're welcome!", messages[2].Text);
+ }
+
+ [Fact]
+ public async Task PreservesSystemMessagesAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.System, "You are a helper"),
+ new(ChatRole.User, "Turn 1"),
+ new(ChatRole.Assistant, "Reply 1"),
+ new(ChatRole.User, "Turn 2"),
+ new(ChatRole.Assistant, "Reply 2"),
+ ];
+ 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);
+
+ // Act & Assert
+ await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 3);
+
+ // Assert
+ Assert.Equal(ChatRole.System, messages[0].Role);
+ Assert.Equal("You are a helper", messages[0].Text);
+ Assert.Contains("[Summary]", messages[1].Text);
+ }
+
+ [Fact]
+ public async Task AllGroupsProtected_NoChangeAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.Assistant, "Hi"),
+ ];
+ Mock chatClientMock = new();
+ SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 10);
+
+ // Act & Assert
+ await RunCompactionStrategySkippedAsync(strategy, messages);
+
+ // Assert
+ chatClientMock.Verify(
+ c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task CustomPrompt_UsedInRequestAsync()
+ {
+ // Arrange
+ const string CustomPrompt = "Summarize briefly.";
+ List messages =
+ [
+ new(ChatRole.User, "First"),
+ new(ChatRole.Assistant, "Reply"),
+ new(ChatRole.User, "Second"),
+ new(ChatRole.Assistant, "Reply 2"),
+ ];
+ 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);
+
+ // Act & Assert
+ await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2);
+
+ // Assert
+ Assert.NotNull(capturedMessages);
+ Assert.Equal(ChatRole.System, capturedMessages![0].Role);
+ Assert.Equal(CustomPrompt, capturedMessages[0].Text);
+ }
+
+ [Fact]
+ public async Task NullResponseText_UsesFallbackAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.User, "First"),
+ new(ChatRole.Assistant, "Reply"),
+ new(ChatRole.User, "Second"),
+ new(ChatRole.Assistant, "Reply 2"),
+ ];
+ 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);
+
+ // Act & Assert
+ await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2);
+
+ // 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
new file mode 100644
index 0000000000..d2b3113ec6
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs
@@ -0,0 +1,131 @@
+// 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 class ToolResultCompactionStrategyTests : CompactionStrategyTestBase
+{
+ [Fact]
+ public async Task UnderLimit_NoChangeAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.User, "Hello"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
+ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
+ ];
+ ToolResultCompactionStrategy strategy = new(maxTokens: 100000);
+
+ // Act & Assert
+ await RunCompactionStrategySkippedAsync(strategy, messages);
+ }
+
+ [Fact]
+ public async Task CollapsesOldToolGroupAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.User, "Check weather"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
+ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny, 72°F")]),
+ new(ChatRole.User, "Thanks"),
+ new(ChatRole.Assistant, "You're welcome!"),
+ ];
+ ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
+
+ // Act & Assert
+ await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4);
+
+ // Assert
+ Assert.Contains("[Tool calls: get_weather]", messages[1].Text);
+ Assert.Equal(ChatRole.Assistant, messages[1].Role);
+ }
+
+ [Fact]
+ public async Task ProtectsRecentGroupsAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.User, "Check weather"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
+ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
+ new(ChatRole.User, "Thanks"),
+ new(ChatRole.Assistant, "You're welcome!"),
+ ];
+ ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 10);
+
+ // Act & Assert
+ await RunCompactionStrategySkippedAsync(strategy, messages);
+ }
+
+ [Fact]
+ public async Task PreservesSystemMessagesAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.System, "You are a helper"),
+ new(ChatRole.User, "Check weather"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
+ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
+ new(ChatRole.User, "Thanks"),
+ new(ChatRole.Assistant, "You're welcome!"),
+ ];
+ ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
+
+ // Act & Assert
+ await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 5);
+
+ // Assert
+ Assert.Equal(ChatRole.System, messages[0].Role);
+ Assert.Equal("You are a helper", messages[0].Text);
+ }
+
+ [Fact]
+ public async Task MultipleToolCalls_ListedInSummaryAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.User, "Do research"),
+ new ChatMessage(ChatRole.Assistant, [
+ new FunctionCallContent("c1", "search"),
+ new FunctionCallContent("c2", "fetch_page"),
+ ]),
+ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "results...")]),
+ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c2", "page content...")]),
+ new(ChatRole.User, "Summarize"),
+ new(ChatRole.Assistant, "Here's the summary."),
+ ];
+ ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
+
+ // Act & Assert
+ await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 4);
+
+ // Assert
+ Assert.Contains("search", messages[1].Text);
+ Assert.Contains("fetch_page", messages[1].Text);
+ }
+
+ [Fact]
+ public async Task NoToolGroups_NoChangeAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.Assistant, "Hi there"),
+ ];
+ ToolResultCompactionStrategy strategy = new(maxTokens: 1);
+
+ // 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
new file mode 100644
index 0000000000..b4be4422a5
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/TruncationCompactionStrategyTests.cs
@@ -0,0 +1,93 @@
+// 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 class TruncationCompactionStrategyTests : CompactionStrategyTestBase
+{
+ [Fact]
+ public async Task UnderLimit_NoChangeAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.User, "Hello"),
+ ];
+ TruncationCompactionStrategy strategy = new(maxTokens: 100000);
+
+ // Act & Assert
+ await RunCompactionStrategySkippedAsync(strategy, messages);
+ }
+
+ [Fact]
+ public async Task OverLimit_RemovesOldestGroupsAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.User, "First message"),
+ new(ChatRole.Assistant, "First reply"),
+ new(ChatRole.User, "Second message"),
+ new(ChatRole.Assistant, "Second reply"),
+ ];
+ TruncationCompactionStrategy strategy = new(maxTokens: 1);
+
+ // Act & Assert
+ await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 1);
+ }
+
+ [Fact]
+ public async Task SystemOnlyMessages_NoChangeAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.System, "You are a helper"),
+ ];
+ TruncationCompactionStrategy strategy = new(maxTokens: 1);
+
+ // Act & Assert
+ await RunCompactionStrategySkippedAsync(strategy, messages);
+ }
+
+ [Fact]
+ public async Task SingleNonSystemGroup_NoChangeAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.System, "System prompt"),
+ new(ChatRole.User, "Only user message"),
+ ];
+ TruncationCompactionStrategy strategy = new(maxTokens: 1);
+
+ // Act & Assert
+ await RunCompactionStrategySkippedAsync(strategy, messages);
+ }
+
+ [Fact]
+ public async Task PreserveRecentGroups_KeepsMultipleGroupsAsync()
+ {
+ // Arrange
+ List messages =
+ [
+ new(ChatRole.User, "Turn 1"),
+ new(ChatRole.Assistant, "Reply 1"),
+ new(ChatRole.User, "Turn 2"),
+ new(ChatRole.Assistant, "Reply 2"),
+ new(ChatRole.User, "Turn 3"),
+ new(ChatRole.Assistant, "Reply 3"),
+ ];
+ TruncationCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 2);
+
+ // Act & Assert
+ await RunCompactionStrategyReducedAsync(strategy, messages, expectedCount: 2);
+
+ // Assert
+ Assert.Equal("Reply 3", messages[^1].Text);
+ }
+}