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..451a171226
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionPipeline.cs
@@ -0,0 +1,102 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+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;
+
+///
+/// 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 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(
+ 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).ToArray();
+ 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 messageList = messages.ToList(); // %%% HAXX
+ await this.CompactAsync(messageList, cancellationToken).ConfigureAwait(false);
+ return messageList;
+ }
+
+ ///
+ /// 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( // %%% SCOPE
+ IList messages,
+ CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(messages);
+
+ IReadOnlyList readOnlyMessages = messages as IReadOnlyList ?? [.. messages]; // %%% TYPE CONSISTENCY
+ CompactionMetric overallBefore = this._metricsCalculator.Calculate(readOnlyMessages);
+
+ List results = new(this._strategies.Length);
+
+ foreach (ChatHistoryCompactionStrategy strategy in this._strategies)
+ {
+ CompactionResult result = await strategy.CompactAsync(messages, this._metricsCalculator, cancellationToken).ConfigureAwait(false);
+ results.Add(result);
+ }
+
+ readOnlyMessages = messages as IReadOnlyList ?? [.. messages];
+ CompactionMetric overallAfter = this._metricsCalculator.Calculate(readOnlyMessages);
+
+ return new(overallBefore, overallAfter, results);
+ }
+}
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..2bb8e01e40
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ChatHistoryCompactionStrategy.cs
@@ -0,0 +1,119 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+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
+{
+ private 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 CompactionMetric 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.
+ ///
+ public abstract bool ShouldCompact(CompactionMetric 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.
+ public async ValueTask CompactAsync(
+ IList messages,
+ IChatHistoryMetricsCalculator metricsCalculator,
+ CancellationToken cancellationToken = default)
+ {
+ messages = Throw.IfNull(messages);
+ Throw.IfNull(metricsCalculator);
+
+ List? messageList = messages as List;
+ ReadOnlyCollection snapshot = messageList is not null ? messageList.AsReadOnly() : new(messages);
+ CompactionMetric before = metricsCalculator.Calculate(snapshot);
+ s_currentMetrics.Value = before;
+ if (!this.ShouldCompact(before))
+ {
+ return CompactionResult.Skipped(this.Name, before);
+ }
+
+ ChatMessage[] reduced = (await this.Reducer.ReduceAsync(snapshot, cancellationToken).ConfigureAwait(false)).ToArray();
+
+ bool modified = reduced.Length != snapshot.Count;
+ if (modified)
+ {
+ messages.Clear();
+ foreach (ChatMessage message in reduced)
+ {
+ messages.Add(message);
+ }
+ }
+
+ CompactionMetric after = modified
+ ? metricsCalculator.Calculate(reduced)
+ : before;
+
+ return new(this.Name, applied: modified, before, after);
+ }
+}
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/CompactionMetric.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionMetric.cs
new file mode 100644
index 0000000000..e4d641623b
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionMetric.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 CompactionMetric
+{
+ ///
+ /// 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/CompactionPipelineResult.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/CompactionPipelineResult.cs
new file mode 100644
index 0000000000..ab7e6448ae
--- /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(
+ CompactionMetric before,
+ CompactionMetric 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 CompactionMetric Before { get; }
+
+ ///
+ /// Gets the conversation metrics after all compaction strategies ran.
+ ///
+ public CompactionMetric 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..c6eca6625e
--- /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, CompactionMetric before, CompactionMetric 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 CompactionMetric Before { get; }
+
+ ///
+ /// Gets the conversation metrics after the strategy executed.
+ ///
+ public CompactionMetric 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, CompactionMetric 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..19b9258584
--- /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 CompactionMetric 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..830268dc76
--- /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 // %%% NEEDED ???
+{
+ ///
+ /// Compute metrics for the given messages.
+ ///
+ /// The messages to analyze.
+ /// A snapshot.
+ CompactionMetric 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..9d8f12fc62
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/SlidingWindowCompactionStrategy.cs
@@ -0,0 +1,96 @@
+// 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 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;
+ }
+
+ ///
+ public override bool ShouldCompact(CompactionMetric 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];
+ IReadOnlyList groups = CurrentMetrics.Groups;
+
+ // Find the group-list indices where each user turn starts
+ //int[] turnGroupIndices = groups.Where(group => group.Kind == ChatMessageGroupKind.UserTurn).Select(group => group.StartIndex).ToArray(); // %%% TODO
+ List turnGroupIndices = [];
+ for (int i = 0; i < groups.Count; i++)
+ {
+ if (groups[i].Kind == ChatMessageGroupKind.UserTurn)
+ {
+ turnGroupIndices.Add(i);
+ }
+ }
+
+ // Keep the last maxTurns user turns and everything after the first kept turn
+ int firstKeptTurnIndex = turnGroupIndices.Count - maxTurns;
+ int firstKeptGroupIndex = turnGroupIndices[firstKeptTurnIndex];
+
+ List result = new(messageList.Count);
+ 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..433b7c0f04
--- /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;
+ }
+
+ ///
+ public override bool ShouldCompact(CompactionMetric 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).ToList();
+ 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..424943262b
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ToolResultCompactionStrategy.cs
@@ -0,0 +1,113 @@
+// 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
+{
+ 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 = 2)
+ : base(new ToolResultClearingReducer(preserveRecentGroups))
+ {
+ this._maxTokens = maxTokens;
+ }
+
+ ///
+ public override bool ShouldCompact(CompactionMetric 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).ToList();
+ 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..1390f463fc
--- /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;
+ }
+
+ ///
+ public override bool ShouldCompact(CompactionMetric 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];
+
+ List removableGroups = CurrentMetrics.Groups.Where(g => g.Kind != ChatMessageGroupKind.System).ToList();
+
+ if (removableGroups.Count == 0)
+ {
+ return Task.FromResult>(messageList);
+ }
+
+ // Remove oldest non-system groups, keeping at least preserveRecentGroups.
+ int maxRemovable = removableGroups.Count - 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..d5d58d7ba9
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionPipelineTests.cs
@@ -0,0 +1,137 @@
+// 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()
+ {
+ ChatHistoryCompactionPipeline pipeline = new([]);
+ List messages = [new(ChatRole.User, "Hello")];
+
+ CompactionPipelineResult result = await pipeline.CompactAsync(messages);
+
+ 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()
+ {
+ ChatHistoryCompactionStrategy[] strategies =
+ [
+ new NeverCompactStrategy(),
+ new RemoveFirstMessageStrategy(),
+ ];
+ ChatHistoryCompactionPipeline pipeline = new(strategies);
+ List messages =
+ [
+ new(ChatRole.User, "First"),
+ new(ChatRole.User, "Second"),
+ ];
+
+ CompactionPipelineResult result = await pipeline.CompactAsync(messages);
+
+ 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()
+ {
+ ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]);
+ List messages =
+ [
+ new(ChatRole.User, "First"),
+ new(ChatRole.User, "Second"),
+ new(ChatRole.User, "Third"),
+ ];
+
+ CompactionPipelineResult result = await pipeline.CompactAsync(messages);
+
+ Assert.Equal(3, result.Before.MessageCount);
+ Assert.Equal(2, result.After.MessageCount);
+ }
+
+ [Fact]
+ public async Task CustomMetricsCalculator_IsUsedAsync()
+ {
+ Moq.Mock calcMock = new();
+ calcMock
+ .Setup(c => c.Calculate(Moq.It.IsAny>()))
+ .Returns(new CompactionMetric { MessageCount = 42 });
+
+ ChatHistoryCompactionPipeline pipeline = new(calcMock.Object, []);
+ List messages = [new(ChatRole.User, "Hello")];
+
+ CompactionPipelineResult result = await pipeline.CompactAsync(messages);
+
+ Assert.Equal(42, result.Before.MessageCount);
+ calcMock.Verify(c => c.Calculate(Moq.It.IsAny>()), Moq.Times.AtLeast(2));
+ }
+
+ [Fact]
+ public async Task CompactAsync_NonReadOnlyListMessages_WorksAsync()
+ {
+ ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]);
+ NonReadOnlyList messages = new(
+ [
+ new(ChatRole.User, "First"),
+ new(ChatRole.User, "Second"),
+ ]);
+
+ CompactionPipelineResult result = await pipeline.CompactAsync(messages);
+
+ Assert.True(result.AnyApplied);
+ Assert.Single(messages);
+ }
+
+ [Fact]
+ public async Task ReduceAsync_DelegatesCompactionAsync()
+ {
+ ChatHistoryCompactionPipeline pipeline = new([new RemoveFirstMessageStrategy()]);
+
+ ChatMessage[] messages =
+ [
+ new(ChatRole.User, "First"),
+ new(ChatRole.User, "Second"),
+ new(ChatRole.User, "Third"),
+ ];
+
+ IEnumerable result = await ((IChatReducer)pipeline).ReduceAsync(messages, default);
+ List resultList = result.ToList();
+
+ Assert.Equal(2, resultList.Count);
+ Assert.Equal("Second", resultList[0].Text);
+ Assert.Equal("Third", resultList[1].Text);
+ }
+
+ [Fact]
+ public async Task ReduceAsync_EmptyStrategies_ReturnsAllMessagesAsync()
+ {
+ ChatHistoryCompactionPipeline pipeline = new([]);
+
+ ChatMessage[] messages =
+ [
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.User, "World"),
+ ];
+
+ IEnumerable result = await ((IChatReducer)pipeline).ReduceAsync(messages, default);
+
+ 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..5c2e006350
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ChatHistoryCompactionStrategyTests.cs
@@ -0,0 +1,172 @@
+// 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()
+ {
+ NeverCompactStrategy strategy = new();
+ List messages = [new(ChatRole.User, "Hello")];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.False(result.Applied);
+ }
+
+ [Fact]
+ public async Task ShouldCompactReturnsTrue_RunsCompactionAsync()
+ {
+ RemoveFirstMessageStrategy strategy = new();
+ List messages =
+ [
+ new(ChatRole.User, "First"),
+ new(ChatRole.User, "Second"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.True(result.Applied);
+ Assert.Single(messages);
+ Assert.Equal("Second", messages[0].Text);
+ Assert.Equal(2, result.Before.MessageCount);
+ Assert.Equal(1, result.After.MessageCount);
+ }
+
+ [Fact]
+ public async Task DelegatesToIChatReducerAsync()
+ {
+ Mock reducerMock = new();
+ reducerMock
+ .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny()))
+ .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs.Skip(1));
+
+ TestCompactionStrategy strategy = new(reducerMock.Object);
+ List messages =
+ [
+ new(ChatRole.User, "First"),
+ new(ChatRole.User, "Second"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.True(result.Applied);
+ Assert.Single(messages);
+ Assert.Equal("Second", messages[0].Text);
+ reducerMock.Verify(r => r.ReduceAsync(It.IsAny>(), It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task ReducerNoChange_ReturnsFalseAsync()
+ {
+ Mock reducerMock = new();
+ reducerMock
+ .Setup(r => r.ReduceAsync(It.IsAny>(), It.IsAny()))
+ .ReturnsAsync((IEnumerable msgs, CancellationToken _) => msgs);
+
+ TestCompactionStrategy strategy = new(reducerMock.Object);
+ List messages =
+ [
+ new(ChatRole.User, "Hello"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.False(result.Applied);
+ Assert.Single(messages);
+ }
+
+ [Fact]
+ public void ExposesReducer()
+ {
+ Mock reducerMock = new();
+ TestCompactionStrategy strategy = new(reducerMock.Object);
+
+ Assert.Same(reducerMock.Object, strategy.Reducer);
+ }
+
+ [Fact]
+ public void DefaultName_IsReducerTypeName()
+ {
+ Mock reducerMock = new();
+ TestCompactionStrategy strategy = new(reducerMock.Object);
+
+ // Moq proxy type name is used since we're using a mock
+ Assert.NotNull(strategy.Name);
+ Assert.NotEmpty(strategy.Name);
+ }
+
+ [Fact]
+ public void ConditionDelegate_ReturnsTrue_ShouldCompactReturnsTrue()
+ {
+ Mock reducerMock = new();
+ TestCompactionStrategy strategy = new(reducerMock.Object);
+
+ CompactionMetric metrics = new() { TokenCount = 100 };
+ Assert.True(strategy.ShouldCompact(metrics));
+ }
+
+ [Fact]
+ public void ConditionDelegate_ReturnsFalse_ShouldCompactReturnsFalse()
+ {
+ Mock reducerMock = new();
+ TestCompactionStrategy strategy = new(reducerMock.Object, shouldCompact: false);
+
+ CompactionMetric metrics = new() { TokenCount = 100 };
+ Assert.False(strategy.ShouldCompact(metrics));
+ }
+
+ [Fact]
+ public async Task CompactAsync_NonReadOnlyListMessages_WorksAsync()
+ {
+ RemoveFirstMessageStrategy strategy = new();
+ NonReadOnlyList messages = new(
+ [
+ new(ChatRole.User, "First"),
+ new(ChatRole.User, "Second"),
+ ]);
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.True(result.Applied);
+ Assert.Single(messages);
+ Assert.Equal("Second", messages[0].Text);
+ }
+
+ [Fact]
+ public void CurrentMetrics_OutsideStrategy_Throws()
+ {
+ Assert.Throws(() => TestCompactionStrategy.GetCurrentMetrics());
+ }
+
+ private sealed class TestCompactionStrategy : ChatHistoryCompactionStrategy
+ {
+ private readonly bool _shouldCompact;
+
+ public TestCompactionStrategy(IChatReducer reducer, bool shouldCompact = true)
+ : base(reducer)
+ {
+ this._shouldCompact = shouldCompact;
+ }
+
+ public override bool ShouldCompact(CompactionMetric metrics) => this._shouldCompact;
+
+ public static CompactionMetric GetCurrentMetrics() => CurrentMetrics;
+ }
+}
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..c2287512cd
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionMetricTests.cs
@@ -0,0 +1,39 @@
+// 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()
+ {
+ CompactionMetric metrics = new();
+ 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()
+ {
+ CompactionMetric metrics = new()
+ {
+ TokenCount = 100,
+ ByteCount = 500,
+ MessageCount = 5,
+ ToolCallCount = 2,
+ UserTurnCount = 3
+ };
+
+ 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..6921bc26ca
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionPipelineResultTests.cs
@@ -0,0 +1,46 @@
+// 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()
+ {
+ CompactionMetric before = new() { MessageCount = 10 };
+ CompactionMetric after = new() { MessageCount = 5 };
+ CompactionResult strategyResult = new("Test", applied: true, before, after);
+ List results = [strategyResult];
+
+ CompactionPipelineResult pipelineResult = new(before, after, results);
+
+ Assert.Same(before, pipelineResult.Before);
+ Assert.Same(after, pipelineResult.After);
+ Assert.Single(pipelineResult.StrategyResults);
+ }
+
+ [Fact]
+ public void AnyApplied_AllFalse_ReturnsFalse()
+ {
+ CompactionMetric metrics = new() { MessageCount = 5 };
+ CompactionResult skipped = CompactionResult.Skipped("Skip", metrics);
+ CompactionPipelineResult result = new(metrics, metrics, [skipped]);
+
+ Assert.False(result.AnyApplied);
+ }
+
+ [Fact]
+ public void AnyApplied_SomeTrue_ReturnsTrue()
+ {
+ CompactionMetric before = new() { MessageCount = 10 };
+ CompactionMetric after = new() { MessageCount = 5 };
+ CompactionResult applied = new("Applied", applied: true, before, after);
+ CompactionPipelineResult result = new(before, after, [applied]);
+
+ 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..1bb476786b
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/CompactionResultTests.cs
@@ -0,0 +1,20 @@
+// 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()
+ {
+ CompactionMetric metrics = new() { MessageCount = 5, TokenCount = 100 };
+ CompactionResult result = CompactionResult.Skipped("Test", metrics);
+
+ 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/DefaultChatHistoryMetricsCalculatorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/DefaultChatHistoryMetricsCalculatorTests.cs
new file mode 100644
index 0000000000..01387f1eed
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/DefaultChatHistoryMetricsCalculatorTests.cs
@@ -0,0 +1,341 @@
+// 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()
+ {
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ CompactionMetric metrics = calculator.Calculate([]);
+
+ 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()
+ {
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ List messages =
+ [
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.Assistant, "Hi there"),
+ ];
+
+ CompactionMetric metrics = calculator.Calculate(messages);
+
+ Assert.Equal(2, metrics.MessageCount);
+ }
+
+ [Fact]
+ public void CountsUserTurns()
+ {
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ List messages =
+ [
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.Assistant, "Hi"),
+ new(ChatRole.User, "How are you?"),
+ new(ChatRole.Assistant, "Good"),
+ ];
+
+ CompactionMetric metrics = calculator.Calculate(messages);
+
+ Assert.Equal(2, metrics.UserTurnCount);
+ }
+
+ [Fact]
+ public void CountsToolCalls()
+ {
+ 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,
+ ];
+
+ CompactionMetric metrics = calculator.Calculate(messages);
+
+ Assert.Equal(2, metrics.ToolCallCount);
+ }
+
+ [Fact]
+ public void ConsecutiveUserMessages_CountAsOneTurn()
+ {
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ List messages =
+ [
+ new(ChatRole.User, "First"),
+ new(ChatRole.User, "Second"),
+ new(ChatRole.Assistant, "Reply"),
+ ];
+
+ CompactionMetric metrics = calculator.Calculate(messages);
+
+ Assert.Equal(1, metrics.UserTurnCount);
+ }
+
+ [Fact]
+ public void TokenCount_IsPositive()
+ {
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ List messages =
+ [
+ new(ChatRole.User, "Hello world"),
+ ];
+
+ CompactionMetric metrics = calculator.Calculate(messages);
+
+ Assert.True(metrics.TokenCount > 0);
+ Assert.True(metrics.ByteCount > 0);
+ }
+
+ [Fact]
+ public void NullInput_ReturnsZeroMetrics()
+ {
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ CompactionMetric metrics = calculator.Calculate(null!);
+
+ Assert.Equal(0, metrics.TokenCount);
+ Assert.Equal(0L, metrics.ByteCount);
+ Assert.Equal(0, metrics.MessageCount);
+ Assert.Empty(metrics.Groups);
+ }
+
+ [Fact]
+ public void InvalidCharsPerToken_UsesDefault()
+ {
+ // A non-positive charsPerToken should fall back to the default (4)
+ DefaultChatHistoryMetricsCalculator calculator = new(charsPerToken: 0);
+ List messages =
+ [
+ new(ChatRole.User, "Hello world"),
+ ];
+
+ CompactionMetric metrics = calculator.Calculate(messages);
+
+ // With default 4 chars/token: "Hello world" = 11 chars → 11/4=2 + 4 overhead = 6 tokens
+ Assert.True(metrics.TokenCount > 0);
+ }
+
+ [Fact]
+ public void NullMessageText_HandledGracefully()
+ {
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ // Message with no text content — Text returns null
+ ChatMessage msg = new() { Role = ChatRole.User };
+ List messages = [msg];
+
+ CompactionMetric metrics = calculator.Calculate(messages);
+
+ Assert.Equal(1, metrics.MessageCount);
+ // Null text → empty string → 0 bytes, only overhead tokens
+ Assert.True(metrics.TokenCount > 0); // per-message overhead
+ Assert.Equal(0L, metrics.ByteCount);
+ }
+
+ [Fact]
+ public void NullContents_SkipsToolCounting()
+ {
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ ChatMessage msg = new(ChatRole.User, "text");
+ msg.Contents = null!;
+ List messages = [msg];
+
+ CompactionMetric metrics = calculator.Calculate(messages);
+
+ Assert.Equal(1, metrics.MessageCount);
+ Assert.Equal(0, metrics.ToolCallCount);
+ }
+
+ [Fact]
+ public void MessageWithOnlyNonTextContent_NullTextHandled()
+ {
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ // FunctionCallContent-only message has null Text
+ ChatMessage msg = new(ChatRole.Assistant,
+ [
+ new FunctionCallContent("c1", "func"),
+ ]);
+ List messages = [msg];
+
+ CompactionMetric metrics = calculator.Calculate(messages);
+
+ Assert.Equal(1, metrics.MessageCount);
+ Assert.Equal(1, metrics.ToolCallCount);
+ }
+
+ [Fact]
+ public void Calculate_PopulatesGroupIndex()
+ {
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ List messages =
+ [
+ new(ChatRole.System, "System prompt"),
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.Assistant, "Hi there"),
+ ];
+
+ CompactionMetric metrics = calculator.Calculate(messages);
+
+ 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()
+ {
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ CompactionMetric metrics = calculator.Calculate([]);
+
+ Assert.Empty(metrics.Groups);
+ }
+
+ [Fact]
+ public void GroupIndex_SystemMessage_IdentifiedCorrectly()
+ {
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ List messages =
+ [
+ new(ChatRole.System, "You are a helpful assistant"),
+ ];
+
+ IReadOnlyList groups = calculator.Calculate(messages).Groups;
+
+ 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()
+ {
+ 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,
+ ];
+
+ IReadOnlyList groups = calculator.Calculate(messages).Groups;
+
+ Assert.Equal(2, groups.Count);
+ Assert.Equal(ChatMessageGroupKind.UserTurn, groups[0].Kind);
+ Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[1].Kind);
+ Assert.Equal(1, groups[1].StartIndex);
+ Assert.Equal(2, groups[1].Count); // assistant + tool result
+ }
+
+ [Fact]
+ public void GroupIndex_MultipleToolResults_GroupedTogether()
+ {
+ 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];
+
+ IReadOnlyList groups = calculator.Calculate(messages).Groups;
+
+ Assert.Single(groups);
+ Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[0].Kind);
+ Assert.Equal(3, groups[0].Count);
+ }
+
+ [Fact]
+ public void GroupIndex_ComplexConversation_CorrectGrouping()
+ {
+ 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!"),
+ ];
+
+ IReadOnlyList groups = calculator.Calculate(messages).Groups;
+
+ Assert.Equal(6, groups.Count);
+ Assert.Equal(ChatMessageGroupKind.System, groups[0].Kind);
+ Assert.Equal(ChatMessageGroupKind.UserTurn, groups[1].Kind);
+ Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[2].Kind);
+ Assert.Equal(ChatMessageGroupKind.UserTurn, groups[3].Kind);
+ Assert.Equal(ChatMessageGroupKind.AssistantToolGroup, groups[4].Kind);
+ Assert.Equal(2, groups[4].Count); // assistant + tool
+ Assert.Equal(ChatMessageGroupKind.AssistantPlain, groups[5].Kind);
+ }
+
+ [Fact]
+ public void GroupIndex_OrphanToolResult_IdentifiedCorrectly()
+ {
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ List messages =
+ [
+ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "orphan result")]),
+ ];
+
+ IReadOnlyList groups = calculator.Calculate(messages).Groups;
+
+ Assert.Single(groups);
+ Assert.Equal(ChatMessageGroupKind.ToolResult, groups[0].Kind);
+ }
+
+ [Fact]
+ public void GroupIndex_UnknownRole_IdentifiedAsOther()
+ {
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ List messages =
+ [
+ new(new ChatRole("custom"), "custom message"),
+ ];
+
+ IReadOnlyList groups = calculator.Calculate(messages).Groups;
+
+ Assert.Single(groups);
+ Assert.Equal(ChatMessageGroupKind.Other, groups[0].Kind);
+ }
+
+ [Fact]
+ public void GroupIndex_AssistantWithNullContents_ClassifiedAsPlain()
+ {
+ DefaultChatHistoryMetricsCalculator calculator = new();
+ ChatMessage msg = new(ChatRole.Assistant, "reply");
+ msg.Contents = null!;
+ List messages = [msg];
+
+ IReadOnlyList groups = calculator.Calculate(messages).Groups;
+
+ 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..9a4429e08a
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NeverCompactStrategy.cs
@@ -0,0 +1,26 @@
+// 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";
+ public override bool ShouldCompact(CompactionMetric 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/NonReadOnlyList.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NonReadOnlyList.cs
new file mode 100644
index 0000000000..101388c921
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/NonReadOnlyList.cs
@@ -0,0 +1,40 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction.Internal;
+
+///
+/// An IList<T> that does NOT implement IReadOnlyList<T>,
+/// used to test the defensive as IReadOnlyList<T> ?? fallback patterns.
+///
+internal sealed class NonReadOnlyList : IList
+{
+ private readonly List _inner;
+
+ public NonReadOnlyList(IEnumerable items)
+ {
+ this._inner = items.ToList();
+ }
+
+ public T this[int index]
+ {
+ get => this._inner[index];
+ set => this._inner[index] = value;
+ }
+
+ public int Count => this._inner.Count;
+ public bool IsReadOnly => false;
+ public void Add(T item) => this._inner.Add(item);
+ public void Clear() => this._inner.Clear();
+ public bool Contains(T item) => this._inner.Contains(item);
+ public void CopyTo(T[] array, int arrayIndex) => this._inner.CopyTo(array, arrayIndex);
+ public IEnumerator GetEnumerator() => this._inner.GetEnumerator();
+ public int IndexOf(T item) => this._inner.IndexOf(item);
+ public void Insert(int index, T item) => this._inner.Insert(index, item);
+ public bool Remove(T item) => this._inner.Remove(item);
+ public void RemoveAt(int index) => this._inner.RemoveAt(index);
+ IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator();
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/RemoveFirstMessageStrategy.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/Internal/RemoveFirstMessageStrategy.cs
new file mode 100644
index 0000000000..f2ca36bc10
--- /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";
+
+ public override bool ShouldCompact(CompactionMetric 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..5449b4bbf9
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupTests.cs
@@ -0,0 +1,55 @@
+// 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()
+ {
+ ChatMessageGroup a = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
+ ChatMessageGroup b = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
+ ChatMessageGroup c = new(1, 2, ChatMessageGroupKind.AssistantToolGroup);
+
+ Assert.Equal(a, b);
+ Assert.True(a == b);
+ Assert.NotEqual(a, c);
+ Assert.True(a != c);
+ }
+
+ [Fact]
+ public void Equals_Object_NullReturnsFalse()
+ {
+ ChatMessageGroup group = new(0, 1, ChatMessageGroupKind.System);
+
+ Assert.False(group.Equals(null));
+ }
+
+ [Fact]
+ public void Equals_Object_BoxedMessageGroupReturnsTrue()
+ {
+ ChatMessageGroup group = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
+ object boxed = new ChatMessageGroup(0, 2, ChatMessageGroupKind.AssistantToolGroup);
+
+ Assert.True(group.Equals(boxed));
+ }
+
+ [Fact]
+ public void Equals_Object_WrongTypeReturnsFalse()
+ {
+ ChatMessageGroup group = new(0, 1, ChatMessageGroupKind.System);
+
+ Assert.False(group.Equals("not a MessageGroup"));
+ }
+
+ [Fact]
+ public void GetHashCode_ConsistentForEqualInstances()
+ {
+ ChatMessageGroup a = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
+ ChatMessageGroup b = new(0, 2, ChatMessageGroupKind.AssistantToolGroup);
+
+ 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..dee020130b
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs
@@ -0,0 +1,158 @@
+// 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
+{
+ [Fact]
+ public void ShouldCompact_UnderLimit_ReturnsFalse()
+ {
+ SlidingWindowCompactionStrategy strategy = new(maxTurns: 10);
+ CompactionMetric metrics = new() { UserTurnCount = 3 };
+
+ Assert.False(strategy.ShouldCompact(metrics));
+ }
+
+ [Fact]
+ public void ShouldCompact_OverLimit_ReturnsTrue()
+ {
+ SlidingWindowCompactionStrategy strategy = new(maxTurns: 2);
+ CompactionMetric metrics = new() { UserTurnCount = 5 };
+
+ Assert.True(strategy.ShouldCompact(metrics));
+ }
+
+ [Fact]
+ public async Task UnderLimit_NoChangeAsync()
+ {
+ SlidingWindowCompactionStrategy strategy = new(maxTurns: 10);
+ List messages =
+ [
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.Assistant, "Hi"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.False(result.Applied);
+ Assert.Equal(2, messages.Count);
+ }
+
+ [Fact]
+ public async Task KeepsLastNTurnsAsync()
+ {
+ SlidingWindowCompactionStrategy strategy = new(maxTurns: 2);
+ 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"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.True(result.Applied);
+ Assert.Equal(4, messages.Count);
+ 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()
+ {
+ SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
+ 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"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.True(result.Applied);
+ Assert.Equal(3, messages.Count);
+ 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()
+ {
+ SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
+ 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!"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.True(result.Applied);
+ // Turn 1 dropped, Turn 2 kept (user + assistant-tool-group + plain assistant)
+ Assert.Equal(4, messages.Count);
+ Assert.Equal("Get weather", messages[0].Text);
+ }
+
+ [Fact]
+ public async Task SingleTurn_AtLimit_NoChangeAsync()
+ {
+ SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
+ List messages =
+ [
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.Assistant, "Hi"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.False(result.Applied);
+ Assert.Equal(2, messages.Count);
+ }
+
+ [Fact]
+ public async Task DropsResponseGroupsFromOldTurnsAsync()
+ {
+ SlidingWindowCompactionStrategy strategy = new(maxTurns: 1);
+ 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"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.True(result.Applied);
+ Assert.Equal(2, messages.Count);
+ 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..02782ad754
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs
@@ -0,0 +1,190 @@
+// 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
+{
+ [Fact]
+ public void ShouldCompact_UnderLimit_ReturnsFalse()
+ {
+ Mock chatClientMock = new();
+ SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100000);
+ CompactionMetric metrics = new() { TokenCount = 500 };
+
+ Assert.False(strategy.ShouldCompact(metrics));
+ }
+
+ [Fact]
+ public void ShouldCompact_OverLimit_ReturnsTrue()
+ {
+ Mock chatClientMock = new();
+ SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100);
+ CompactionMetric metrics = new() { TokenCount = 500 };
+
+ Assert.True(strategy.ShouldCompact(metrics));
+ }
+
+ [Fact]
+ public async Task UnderLimit_NoChangeAsync()
+ {
+ Mock chatClientMock = new();
+ SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 100000);
+ List messages =
+ [
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.Assistant, "Hi"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.False(result.Applied);
+ Assert.Equal(2, messages.Count);
+ chatClientMock.Verify(
+ c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task SummarizesOldGroupsAsync()
+ {
+ Mock chatClientMock = new();
+ chatClientMock
+ .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "User asked about weather. It was sunny.")));
+
+ // preserveRecentGroups=2 means keep last 2 non-system groups
+ SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 2);
+ 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!"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.True(result.Applied);
+ // 6 groups (3 user + 3 assistant), protect last 2 → summarize first 4 groups
+ // Result: summary + 2 protected groups = 3 messages
+ Assert.Equal(3, messages.Count);
+ Assert.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()
+ {
+ 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);
+ 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"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.True(result.Applied);
+ 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()
+ {
+ Mock chatClientMock = new();
+ SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 10);
+ List messages =
+ [
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.Assistant, "Hi"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ // All groups protected → nothing to summarize → no change
+ Assert.False(result.Applied);
+ Assert.Equal(2, messages.Count);
+ chatClientMock.Verify(
+ c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ [Fact]
+ public async Task CustomPrompt_UsedInRequestAsync()
+ {
+ const string CustomPrompt = "Summarize briefly.";
+ List? capturedMessages = null;
+
+ Mock chatClientMock = new();
+ chatClientMock
+ .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny()))
+ .Callback, ChatOptions, CancellationToken>((msgs, _, _) => capturedMessages = [.. msgs])
+ .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Brief summary.")));
+
+ SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1, summarizationPrompt: CustomPrompt);
+ List messages =
+ [
+ new(ChatRole.User, "First"),
+ new(ChatRole.Assistant, "Reply"),
+ new(ChatRole.User, "Second"),
+ new(ChatRole.Assistant, "Reply 2"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ await strategy.CompactAsync(messages, calculator);
+
+ Assert.NotNull(capturedMessages);
+ // First message in request should be the custom system prompt
+ Assert.Equal(ChatRole.System, capturedMessages![0].Role);
+ Assert.Equal(CustomPrompt, capturedMessages[0].Text);
+ }
+
+ [Fact]
+ public async Task NullResponseText_UsesFallbackAsync()
+ {
+ Mock chatClientMock = new();
+ chatClientMock
+ .Setup(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, (string?)null)));
+
+ SummarizationCompactionStrategy strategy = new(chatClientMock.Object, maxTokens: 1, preserveRecentGroups: 1);
+ List messages =
+ [
+ new(ChatRole.User, "First"),
+ new(ChatRole.Assistant, "Reply"),
+ new(ChatRole.User, "Second"),
+ new(ChatRole.Assistant, "Reply 2"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.True(result.Applied);
+ 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..8a6065a940
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs
@@ -0,0 +1,168 @@
+// 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
+{
+ [Fact]
+ public void ShouldCompact_UnderLimit_ReturnsFalse()
+ {
+ ToolResultCompactionStrategy strategy = new(maxTokens: 100000);
+ CompactionMetric metrics = new() { TokenCount = 500, ToolCallCount = 3 };
+
+ Assert.False(strategy.ShouldCompact(metrics));
+ }
+
+ [Fact]
+ public void ShouldCompact_OverLimitNoToolCalls_ReturnsFalse()
+ {
+ ToolResultCompactionStrategy strategy = new(maxTokens: 100);
+ CompactionMetric metrics = new() { TokenCount = 500, ToolCallCount = 0 };
+
+ Assert.False(strategy.ShouldCompact(metrics));
+ }
+
+ [Fact]
+ public void ShouldCompact_OverLimitWithToolCalls_ReturnsTrue()
+ {
+ ToolResultCompactionStrategy strategy = new(maxTokens: 100);
+ CompactionMetric metrics = new() { TokenCount = 500, ToolCallCount = 2 };
+
+ Assert.True(strategy.ShouldCompact(metrics));
+ }
+
+ [Fact]
+ public async Task UnderLimit_NoChangeAsync()
+ {
+ ToolResultCompactionStrategy strategy = new(maxTokens: 100000);
+ List messages =
+ [
+ new(ChatRole.User, "Hello"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
+ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.False(result.Applied);
+ Assert.Equal(3, messages.Count);
+ }
+
+ [Fact]
+ public async Task CollapsesOldToolGroupAsync()
+ {
+ ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
+ 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!"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.True(result.Applied);
+ // The old tool group (assistant+tool = 2 messages) should be collapsed to 1
+ Assert.Equal(4, messages.Count); // user + [collapsed] + user + assistant
+ Assert.Contains("[Tool calls: get_weather]", messages[1].Text);
+ Assert.Equal(ChatRole.Assistant, messages[1].Role);
+ }
+
+ [Fact]
+ public async Task ProtectsRecentGroupsAsync()
+ {
+ // With preserveRecentGroups=4, all groups are protected (5 groups, protect 4 non-system)
+ ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 10);
+ 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!"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ // All groups protected, so no collapse
+ Assert.False(result.Applied);
+ Assert.Equal(5, messages.Count);
+ }
+
+ [Fact]
+ public async Task PreservesSystemMessagesAsync()
+ {
+ ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
+ 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!"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.True(result.Applied);
+ Assert.Equal(ChatRole.System, messages[0].Role);
+ Assert.Equal("You are a helper", messages[0].Text);
+ }
+
+ [Fact]
+ public async Task MultipleToolCalls_ListedInSummaryAsync()
+ {
+ ToolResultCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 1);
+ 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."),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.True(result.Applied);
+ // Old tool group (1 assistant + 2 tools = 3 messages) collapsed to 1
+ Assert.Equal(4, messages.Count);
+ Assert.Contains("search", messages[1].Text);
+ Assert.Contains("fetch_page", messages[1].Text);
+ }
+
+ [Fact]
+ public async Task NoToolGroups_NoChangeAsync()
+ {
+ ToolResultCompactionStrategy strategy = new(maxTokens: 1);
+ List messages =
+ [
+ new(ChatRole.User, "Hello"),
+ new(ChatRole.Assistant, "Hi there"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ // ToolCallCount is 0, so ShouldCompact returns false
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.False(result.Applied);
+ Assert.Equal(2, messages.Count);
+ }
+}
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..47929d23e5
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/TruncationCompactionStrategyTests.cs
@@ -0,0 +1,130 @@
+// 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
+{
+ [Fact]
+ public async Task UnderLimit_NoChangeAsync()
+ {
+ TruncationCompactionStrategy strategy = new(maxTokens: 100000);
+ List messages =
+ [
+ new(ChatRole.User, "Hello"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.False(result.Applied);
+ Assert.Single(messages);
+ }
+
+ [Fact]
+ public async Task OverLimit_RemovesOldestGroupsAsync()
+ {
+ // Use a very low max to trigger compaction
+ TruncationCompactionStrategy strategy = new(maxTokens: 1);
+ List messages =
+ [
+ new(ChatRole.User, "First message"),
+ new(ChatRole.Assistant, "First reply"),
+ new(ChatRole.User, "Second message"),
+ new(ChatRole.Assistant, "Second reply"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.True(result.Applied);
+ // Should have removed old groups, keeping at least the last one
+ Assert.True(messages.Count < 4);
+ Assert.True(messages.Count > 0);
+ }
+
+ [Fact]
+ public void ShouldCompact_ReturnsFalseWhenUnderLimit()
+ {
+ TruncationCompactionStrategy strategy = new(maxTokens: 10000);
+ CompactionMetric metrics = new() { TokenCount = 500 };
+
+ Assert.False(strategy.ShouldCompact(metrics));
+ }
+
+ [Fact]
+ public void ShouldCompact_ReturnsTrueWhenOverLimit()
+ {
+ TruncationCompactionStrategy strategy = new(maxTokens: 100);
+ CompactionMetric metrics = new() { TokenCount = 500 };
+
+ Assert.True(strategy.ShouldCompact(metrics));
+ }
+
+ [Fact]
+ public async Task SystemOnlyMessages_NoChangeAsync()
+ {
+ // Only system messages → removableGroups.Count == 0 → no change
+ TruncationCompactionStrategy strategy = new(maxTokens: 1);
+ List messages =
+ [
+ new(ChatRole.System, "You are a helper"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ // ShouldCompact triggers but reducer finds nothing removable
+ Assert.Single(messages);
+ Assert.Equal(result.After.MessageCount, result.Before.MessageCount);
+ Assert.Equal(result.After.TokenCount, result.Before.TokenCount);
+ }
+
+ [Fact]
+ public async Task SingleNonSystemGroup_NoChangeAsync()
+ {
+ // Only one non-system group → maxRemovable <= 0 → no change
+ TruncationCompactionStrategy strategy = new(maxTokens: 1);
+ List messages =
+ [
+ new(ChatRole.System, "System prompt"),
+ new(ChatRole.User, "Only user message"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.Equal(2, messages.Count);
+ Assert.Equal(result.After.MessageCount, result.Before.MessageCount);
+ Assert.Equal(result.After.TokenCount, result.Before.TokenCount);
+ }
+
+ [Fact]
+ public async Task PreserveRecentGroups_KeepsMultipleGroupsAsync()
+ {
+ // preserveRecentGroups=2 means keep at least the last 2 non-system groups
+ TruncationCompactionStrategy strategy = new(maxTokens: 1, preserveRecentGroups: 2);
+ 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"),
+ ];
+ DefaultChatHistoryMetricsCalculator calculator = new();
+
+ CompactionResult result = await strategy.CompactAsync(messages, calculator);
+
+ Assert.True(result.Applied);
+ // 6 groups (3 user + 3 assistant), protect last 2 → 4 removable
+ // Should keep at least: Turn 3 user + Reply 3 assistant (last 2 groups)
+ Assert.True(messages.Count >= 2);
+ Assert.Equal("Reply 3", messages[^1].Text);
+ }
+}