diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 1b1e0daa08..516176ca76 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -108,6 +108,7 @@
+
diff --git a/dotnet/agent-working-dotnet.slnx b/dotnet/agent-working-dotnet.slnx
new file mode 100644
index 0000000000..651b185a23
--- /dev/null
+++ b/dotnet/agent-working-dotnet.slnx
@@ -0,0 +1,447 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs
index ad3f3aacfb..cdd21e9e1c 100644
--- a/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/ChatHistoryProvider.cs
@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
@@ -269,6 +270,35 @@ public abstract class ChatHistoryProvider
protected virtual ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default) =>
default;
+ ///
+ /// Compacts the messages in place using the specified compaction strategy before they are stored.
+ ///
+ /// The messages to compact. This list is mutated in place.
+ /// The compaction strategy to apply.
+ /// The to monitor for cancellation requests.
+ /// A task representing the asynchronous operation. The task result is if compaction occurred.
+ ///
+ ///
+ /// This method organizes the messages into atomic units,
+ /// applies the compaction strategy, and replaces the contents of the list with the compacted result.
+ /// Tool call groups (assistant message + tool results) are treated as atomic units.
+ ///
+ ///
+ protected static async Task CompactMessagesAsync(List messages, ICompactionStrategy compactionStrategy, CancellationToken cancellationToken = default)
+ {
+ MessageGroups groups = MessageGroups.Create(messages);
+
+ bool compacted = await compactionStrategy.CompactAsync(groups, cancellationToken).ConfigureAwait(false);
+
+ if (compacted)
+ {
+ messages.Clear();
+ messages.AddRange(groups.GetIncludedMessages());
+ }
+
+ return compacted;
+ }
+
/// Asks the for an object of the specified type .
/// The type of object being requested.
/// An optional key that can be used to help identify the target service.
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ICompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ICompactionStrategy.cs
new file mode 100644
index 0000000000..615317062d
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/ICompactionStrategy.cs
@@ -0,0 +1,38 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// Defines a strategy for compacting a to reduce context size.
+///
+///
+///
+/// Compaction strategies operate on instances, which organize messages
+/// into atomic groups that respect the tool-call/result pairing constraint. Strategies mutate the collection
+/// in place by marking groups as excluded, removing groups, or replacing message content (e.g., with summaries).
+///
+///
+/// Strategies can be applied at three lifecycle points:
+///
+/// - In-run: During the tool loop, before each LLM call, to keep context within token limits.
+/// - Pre-write: Before persisting messages to storage via .
+/// - On existing storage: As a maintenance operation to compact stored history.
+///
+///
+///
+/// Multiple strategies can be composed by applying them sequentially to the same .
+///
+///
+public interface ICompactionStrategy
+{
+ ///
+ /// Compacts the specified message groups in place.
+ ///
+ /// The message group collection to compact. The strategy mutates this collection in place.
+ /// The to monitor for cancellation requests.
+ /// A task representing the asynchronous operation. The task result is if compaction occurred, otherwise.
+ Task CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroup.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroup.cs
new file mode 100644
index 0000000000..d443b15d87
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroup.cs
@@ -0,0 +1,108 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// Represents a logical group of instances that must be kept or removed together during compaction.
+///
+///
+///
+/// Message groups ensure atomic preservation of related messages. For example, an assistant message
+/// containing tool calls and its corresponding tool result messages form a
+/// group — removing one without the other would cause LLM API errors.
+///
+///
+/// Groups also support exclusion semantics: a group can be marked as excluded (with an optional reason)
+/// to indicate it should not be included in the messages sent to the model, while still being preserved
+/// for diagnostics, storage, or later re-inclusion.
+///
+///
+/// Each group tracks its , , and
+/// so that can efficiently aggregate totals across all or only included groups.
+/// These values are computed by and passed into the constructor.
+///
+///
+public sealed class MessageGroup
+{
+ ///
+ /// The key used to identify a message as a compaction summary.
+ ///
+ ///
+ /// When this key is present with a value of , the message is classified as
+ /// by .
+ ///
+ public static readonly string SummaryPropertyKey = "_is_summary";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The kind of message group.
+ /// The messages in this group. The list is captured as a read-only snapshot.
+ /// The total UTF-8 byte count of the text content in the messages.
+ /// The token count for the messages, computed by a tokenizer or estimated.
+ ///
+ /// The zero-based user turn this group belongs to, or for groups that precede
+ /// the first user message (e.g., system messages).
+ ///
+ public MessageGroup(MessageGroupKind kind, IReadOnlyList messages, int byteCount, int tokenCount, int? turnIndex = null)
+ {
+ this.Kind = kind;
+ this.Messages = messages;
+ this.MessageCount = messages.Count;
+ this.ByteCount = byteCount;
+ this.TokenCount = tokenCount;
+ this.TurnIndex = turnIndex;
+ }
+
+ ///
+ /// Gets the kind of this message group.
+ ///
+ public MessageGroupKind Kind { get; }
+
+ ///
+ /// Gets the messages in this group.
+ ///
+ public IReadOnlyList Messages { get; }
+
+ ///
+ /// Gets the number of messages in this group.
+ ///
+ public int MessageCount { get; }
+
+ ///
+ /// Gets the total UTF-8 byte count of the text content in this group's messages.
+ ///
+ public int ByteCount { get; }
+
+ ///
+ /// Gets the estimated or actual token count for this group's messages.
+ ///
+ public int TokenCount { get; }
+
+ ///
+ /// Gets the zero-based user turn index this group belongs to, or
+ /// for groups that precede the first user message (e.g., system messages).
+ ///
+ ///
+ /// A turn starts with a group and includes all subsequent
+ /// non-user, non-system groups until the next user group or end of conversation.
+ ///
+ public int? TurnIndex { get; }
+
+ ///
+ /// Gets or sets a value indicating whether this group is excluded from the projected message list.
+ ///
+ ///
+ /// Excluded groups are preserved in the collection for diagnostics or storage purposes
+ /// but are not included when calling .
+ ///
+ public bool IsExcluded { get; set; }
+
+ ///
+ /// Gets or sets an optional reason explaining why this group was excluded.
+ ///
+ public string? ExcludeReason { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroupKind.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroupKind.cs
new file mode 100644
index 0000000000..f3ee4c072f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroupKind.cs
@@ -0,0 +1,49 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// Identifies the kind of a .
+///
+///
+/// Message groups are used to classify logically related messages that must be kept together
+/// during compaction operations. For example, an assistant message containing tool calls
+/// and its corresponding tool result messages form an atomic group.
+///
+public enum MessageGroupKind
+{
+ ///
+ /// A system message group containing one or more system messages.
+ ///
+ System,
+
+ ///
+ /// A user message group containing a single user message.
+ ///
+ User,
+
+ ///
+ /// An assistant message group containing a single assistant text response (no tool calls).
+ ///
+ AssistantText,
+
+ ///
+ /// An atomic tool call group containing an assistant message with tool calls
+ /// followed by the corresponding tool result messages.
+ ///
+ ///
+ /// This group must be treated as an atomic unit during compaction. Removing the assistant
+ /// message without its tool results (or vice versa) will cause LLM API errors.
+ ///
+ ToolCall,
+
+ ///
+ /// A summary message group produced by a compaction strategy (e.g., SummarizationCompactionStrategy).
+ ///
+ ///
+ /// Summary groups replace previously compacted messages with a condensed representation.
+ /// They are identified by the metadata entry
+ /// on the underlying .
+ ///
+ Summary,
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroups.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroups.cs
new file mode 100644
index 0000000000..5661c1516a
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/MessageGroups.cs
@@ -0,0 +1,312 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using Microsoft.Extensions.AI;
+using Microsoft.ML.Tokenizers;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// Represents a collection of instances derived from a flat list of objects.
+///
+///
+///
+/// provides structural grouping of messages into logical units that
+/// respect the atomic group preservation constraint: tool call assistant messages and their corresponding
+/// tool result messages are always grouped together.
+///
+///
+/// This collection supports exclusion-based projection, where groups can be marked as excluded
+/// without being removed, allowing compaction strategies to toggle visibility while preserving
+/// the full history for diagnostics or storage.
+///
+///
+/// Each group tracks its own , ,
+/// and . The collection provides aggregate properties for both
+/// the total (all groups) and included (non-excluded groups only) counts.
+///
+///
+public sealed class MessageGroups
+{
+ ///
+ /// Gets the list of message groups in this collection.
+ ///
+ public IList Groups { get; }
+
+ ///
+ /// Gets the tokenizer used for computing token counts, or if token counts are estimated.
+ ///
+ public Tokenizer? Tokenizer { get; }
+
+ ///
+ /// Initializes a new instance of the class with the specified groups.
+ ///
+ /// The message groups.
+ /// An optional tokenizer retained for computing token counts when adding new groups.
+ public MessageGroups(IList groups, Tokenizer? tokenizer = null)
+ {
+ this.Groups = groups;
+ this.Tokenizer = tokenizer;
+ }
+
+ ///
+ /// Creates a from a flat list of instances.
+ ///
+ /// The messages to group.
+ ///
+ /// An optional for computing token counts on each group.
+ /// When , token counts are estimated as ByteCount / 4.
+ ///
+ /// A new with messages organized into logical groups.
+ ///
+ /// The grouping algorithm:
+ ///
+ /// - System messages become groups.
+ /// - User messages become groups.
+ /// - Assistant messages with tool calls, followed by their corresponding tool result messages, become groups.
+ /// - Assistant messages marked with become groups.
+ /// - Assistant messages without tool calls become groups.
+ ///
+ ///
+ public static MessageGroups Create(IList messages, Tokenizer? tokenizer = null)
+ {
+ List groups = [];
+ int index = 0;
+ int currentTurn = 0;
+
+ while (index < messages.Count)
+ {
+ ChatMessage message = messages[index];
+
+ if (message.Role == ChatRole.System)
+ {
+ // System messages are not part of any turn
+ groups.Add(CreateGroup(MessageGroupKind.System, [message], tokenizer, turnIndex: null));
+ index++;
+ }
+ else if (message.Role == ChatRole.User)
+ {
+ currentTurn++;
+ groups.Add(CreateGroup(MessageGroupKind.User, [message], tokenizer, currentTurn));
+ index++;
+ }
+ else if (message.Role == ChatRole.Assistant && HasToolCalls(message))
+ {
+ List groupMessages = [message];
+ index++;
+
+ // Collect all subsequent tool result messages
+ while (index < messages.Count && messages[index].Role == ChatRole.Tool)
+ {
+ groupMessages.Add(messages[index]);
+ index++;
+ }
+
+ groups.Add(CreateGroup(MessageGroupKind.ToolCall, groupMessages, tokenizer, currentTurn));
+ }
+ else if (message.Role == ChatRole.Assistant && IsSummaryMessage(message))
+ {
+ groups.Add(CreateGroup(MessageGroupKind.Summary, [message], tokenizer, currentTurn));
+ index++;
+ }
+ else
+ {
+ groups.Add(CreateGroup(MessageGroupKind.AssistantText, [message], tokenizer, currentTurn));
+ index++;
+ }
+ }
+
+ return new MessageGroups(groups, tokenizer);
+ }
+
+ ///
+ /// Creates a new with byte and token counts computed using this collection's
+ /// , and adds it to the list at the specified index.
+ ///
+ /// The zero-based index at which the group should be inserted.
+ /// The kind of message group.
+ /// The messages in the group.
+ /// The optional turn index to assign to the new group.
+ /// The newly created .
+ public MessageGroup InsertGroup(int index, MessageGroupKind kind, IReadOnlyList messages, int? turnIndex = null)
+ {
+ MessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
+ this.Groups.Insert(index, group);
+ return group;
+ }
+
+ ///
+ /// Creates a new with byte and token counts computed using this collection's
+ /// , and appends it to the end of the list.
+ ///
+ /// The kind of message group.
+ /// The messages in the group.
+ /// The optional turn index to assign to the new group.
+ /// The newly created .
+ public MessageGroup AddGroup(MessageGroupKind kind, IReadOnlyList messages, int? turnIndex = null)
+ {
+ MessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
+ this.Groups.Add(group);
+ return group;
+ }
+
+ ///
+ /// Returns only the messages from groups that are not excluded.
+ ///
+ /// A list of instances from included groups, in order.
+ public IEnumerable GetIncludedMessages() =>
+ this.Groups.Where(group => !group.IsExcluded).SelectMany(group => group.Messages);
+
+ ///
+ /// Returns all messages from all groups, including excluded ones.
+ ///
+ /// A list of all instances, in order.
+ public IEnumerable GetAllMessages() => this.Groups.SelectMany(group => group.Messages);
+
+ #region Total aggregates (all groups, including excluded)
+
+ ///
+ /// Gets the total number of groups, including excluded ones.
+ ///
+ public int TotalGroupCount => this.Groups.Count;
+
+ ///
+ /// Gets the total number of messages across all groups, including excluded ones.
+ ///
+ public int TotalMessageCount => this.Groups.Sum(g => g.MessageCount);
+
+ ///
+ /// Gets the total UTF-8 byte count across all groups, including excluded ones.
+ ///
+ public int TotalByteCount => this.Groups.Sum(g => g.ByteCount);
+
+ ///
+ /// Gets the total token count across all groups, including excluded ones.
+ ///
+ public int TotalTokenCount => this.Groups.Sum(g => g.TokenCount);
+
+ #endregion
+
+ #region Included aggregates (non-excluded groups only)
+
+ ///
+ /// Gets the total number of groups that are not excluded.
+ ///
+ public int IncludedGroupCount => this.Groups.Count(g => !g.IsExcluded);
+
+ ///
+ /// Gets the total number of messages across all included (non-excluded) groups.
+ ///
+ public int IncludedMessageCount => this.Groups.Where(g => !g.IsExcluded).Sum(g => g.MessageCount);
+
+ ///
+ /// Gets the total UTF-8 byte count across all included (non-excluded) groups.
+ ///
+ public int IncludedByteCount => this.Groups.Where(g => !g.IsExcluded).Sum(g => g.ByteCount);
+
+ ///
+ /// Gets the total token count across all included (non-excluded) groups.
+ ///
+ public int IncludedTokenCount => this.Groups.Where(g => !g.IsExcluded).Sum(g => g.TokenCount);
+
+ #endregion
+
+ #region Turn aggregates
+
+ ///
+ /// Gets the total number of user turns across all groups (including those with excluded groups).
+ ///
+ public int TotalTurnCount => this.Groups.Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null);
+
+ ///
+ /// Gets the number of user turns that have at least one non-excluded group.
+ ///
+ public int IncludedTurnCount => this.Groups.Where(group => !group.IsExcluded).Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null);
+
+ ///
+ /// Returns all groups that belong to the specified user turn.
+ ///
+ /// The zero-based turn index.
+ /// The groups belonging to the turn, in order.
+ public IEnumerable GetTurnGroups(int turnIndex) =>
+ this.Groups.Where(g => g.TurnIndex == turnIndex);
+
+ #endregion
+
+ ///
+ /// Computes the UTF-8 byte count for a set of messages.
+ ///
+ /// The messages to compute byte count for.
+ /// The total UTF-8 byte count of all message text content.
+ public static int ComputeByteCount(IReadOnlyList messages)
+ {
+ int total = 0;
+ for (int i = 0; i < messages.Count; i++)
+ {
+ string text = messages[i].Text ?? string.Empty;
+ if (text.Length > 0)
+ {
+ total += Encoding.UTF8.GetByteCount(text);
+ }
+ }
+
+ return total;
+ }
+
+ ///
+ /// Computes the token count for a set of messages using the specified tokenizer.
+ ///
+ /// The messages to compute token count for.
+ /// The tokenizer to use for counting tokens.
+ /// The total token count across all message text content.
+ public static int ComputeTokenCount(IReadOnlyList messages, Tokenizer tokenizer)
+ {
+ int total = 0;
+ for (int i = 0; i < messages.Count; i++)
+ {
+ string text = messages[i].Text ?? string.Empty;
+ if (text.Length > 0)
+ {
+ total += tokenizer.CountTokens(text);
+ }
+ }
+
+ return total;
+ }
+
+ private static MessageGroup CreateGroup(MessageGroupKind kind, IReadOnlyList messages, Tokenizer? tokenizer, int? turnIndex)
+ {
+ int byteCount = ComputeByteCount(messages);
+ int tokenCount = tokenizer is not null
+ ? ComputeTokenCount(messages, tokenizer)
+ : byteCount / 4;
+
+ return new MessageGroup(kind, messages, byteCount, tokenCount, turnIndex);
+ }
+
+ private static bool HasToolCalls(ChatMessage message)
+ {
+ if (message.Contents is null)
+ {
+ return false;
+ }
+
+ foreach (AIContent content in message.Contents)
+ {
+ if (content is FunctionCallContent)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool IsSummaryMessage(ChatMessage message)
+ {
+ return message.AdditionalProperties?.TryGetValue(MessageGroup.SummaryPropertyKey, out object? value) is true
+ && value is true;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/PipelineCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/PipelineCompactionStrategy.cs
new file mode 100644
index 0000000000..0c4af67700
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Compaction/PipelineCompactionStrategy.cs
@@ -0,0 +1,82 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// A compaction strategy that executes a sequential pipeline of instances
+/// against the same .
+///
+///
+///
+/// Each strategy in the pipeline operates on the result of the previous one, enabling composed behaviors
+/// such as summarizing older messages first and then truncating to fit a token budget.
+///
+///
+/// When is and a is configured,
+/// the pipeline stops executing after a strategy reduces the included group count to or below the target.
+/// This avoids unnecessary work when an earlier strategy is sufficient.
+///
+///
+public sealed class PipelineCompactionStrategy : ICompactionStrategy
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The ordered sequence of strategies to execute. Must not be empty.
+ public PipelineCompactionStrategy(params IEnumerable strategies)
+ {
+ this.Strategies = [.. Throw.IfNull(strategies)];
+ }
+
+ ///
+ /// Gets the ordered list of strategies in this pipeline.
+ ///
+ public IReadOnlyList Strategies { get; }
+
+ ///
+ /// Gets or sets a value indicating whether the pipeline should stop executing after a strategy
+ /// brings the included group count to or below .
+ ///
+ ///
+ /// Defaults to , meaning all strategies are always executed.
+ ///
+ public bool EarlyStop { get; set; }
+
+ ///
+ /// Gets or sets the target number of included groups at which the pipeline stops
+ /// when is .
+ ///
+ ///
+ /// Defaults to , meaning early stop checks are not performed
+ /// even when is .
+ ///
+ public int? TargetIncludedGroupCount { get; set; }
+
+ ///
+ public async Task CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default)
+ {
+ bool anyCompacted = false;
+
+ foreach (ICompactionStrategy strategy in this.Strategies)
+ {
+ bool compacted = await strategy.CompactAsync(groups, cancellationToken).ConfigureAwait(false);
+
+ if (compacted)
+ {
+ anyCompacted = true;
+ }
+
+ if (this.EarlyStop && this.TargetIncludedGroupCount is int targetIncludedGroupCount && groups.IncludedGroupCount <= targetIncludedGroupCount)
+ {
+ break;
+ }
+ }
+
+ return anyCompacted;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs
index 12e935b23e..4356c1ac3e 100644
--- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs
@@ -6,6 +6,7 @@ using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
+using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
@@ -46,6 +47,7 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
options?.JsonSerializerOptions);
this.ChatReducer = options?.ChatReducer;
this.ReducerTriggerEvent = options?.ReducerTriggerEvent ?? InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval;
+ this.CompactionStrategy = options?.CompactionStrategy;
}
///
@@ -61,6 +63,11 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
///
public InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent ReducerTriggerEvent { get; }
+ ///
+ /// Gets the compaction strategy used to compact stored messages. If , no compaction is applied.
+ ///
+ public ICompactionStrategy? CompactionStrategy { get; }
+
///
/// Gets the chat messages stored for the specified session.
///
@@ -109,6 +116,36 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
{
state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
}
+
+ // Apply compaction strategy if configured (pre-write compaction)
+ if (this.CompactionStrategy is not null)
+ {
+ await CompactMessagesAsync(state.Messages, this.CompactionStrategy, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Compacts the stored messages for the specified session using the given or configured compaction strategy.
+ ///
+ /// The agent session whose stored messages should be compacted.
+ ///
+ /// An optional compaction strategy to use. If , the provider's configured
+ /// is used. If neither is available, an is thrown.
+ ///
+ /// The to monitor for cancellation requests.
+ /// A task representing the asynchronous operation. The task result is if compaction occurred.
+ /// No compaction strategy is configured or provided.
+ ///
+ /// This method enables on-demand compaction of stored history, for example as a maintenance operation.
+ /// It reads the full stored history, applies the compaction strategy, and writes the compacted result back.
+ ///
+ public async Task CompactStorageAsync(AgentSession? session, ICompactionStrategy? compactionStrategy = null, CancellationToken cancellationToken = default)
+ {
+ ICompactionStrategy strategy = compactionStrategy ?? this.CompactionStrategy
+ ?? throw new InvalidOperationException("No compaction strategy is configured or provided.");
+
+ var state = this._sessionState.GetOrInitializeState(session);
+ return await CompactMessagesAsync(state.Messages, strategy, cancellationToken).ConfigureAwait(false);
}
///
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProviderOptions.cs
index ba24f55ded..b30968aa61 100644
--- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProviderOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProviderOptions.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
+using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
@@ -73,6 +74,21 @@ public sealed class InMemoryChatHistoryProviderOptions
///
public Func, IEnumerable>? ProvideOutputMessageFilter { get; set; }
+ ///
+ /// Gets or sets an optional to apply to stored messages after new messages are added.
+ ///
+ ///
+ ///
+ /// When set, this strategy is applied to the full stored message list after new messages have been appended.
+ /// This enables pre-write compaction to limit storage size.
+ ///
+ ///
+ /// The compaction strategy organizes messages into atomic groups (preserving tool-call/result pairings)
+ /// before applying the strategy logic. See for details.
+ ///
+ ///
+ public ICompactionStrategy? CompactionStrategy { get; set; }
+
///
/// Defines the events that can trigger a reducer in the .
///
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..b097386b14 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
@@ -29,6 +29,7 @@
+
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
index 38cad40bbe..4e1fe61f12 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
+using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
@@ -45,6 +46,26 @@ public sealed class ChatClientAgentOptions
///
public IEnumerable? AIContextProviders { get; set; }
+ ///
+ /// Gets or sets the to use for in-run context compaction.
+ ///
+ ///
+ ///
+ /// When set, this strategy is applied to the message list before each call to the underlying
+ /// during agent execution. This keeps the context within token limits
+ /// as tool calls accumulate during long-running agent invocations.
+ ///
+ ///
+ /// The strategy organizes messages into atomic groups (preserving tool-call/result pairings)
+ /// before applying compaction logic. See for details.
+ ///
+ ///
+ /// This is separate from the compaction strategy on ,
+ /// which applies pre-write compaction before storing messages. Both can be used together.
+ ///
+ ///
+ public ICompactionStrategy? CompactionStrategy { get; set; }
+
///
/// Gets or sets a value indicating whether to use the provided instance as is,
/// without applying any default decorators.
@@ -101,6 +122,7 @@ public sealed class ChatClientAgentOptions
ChatOptions = this.ChatOptions?.Clone(),
ChatHistoryProvider = this.ChatHistoryProvider,
AIContextProviders = this.AIContextProviders is null ? null : new List(this.AIContextProviders),
+ CompactionStrategy = this.CompactionStrategy,
UseProvidedChatClientAsIs = this.UseProvidedChatClientAsIs,
ClearOnChatHistoryProviderConflict = this.ClearOnChatHistoryProviderConflict,
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
index 653f198402..8b83830afc 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
@@ -53,9 +53,16 @@ public static class ChatClientExtensions
{
var chatBuilder = chatClient.AsBuilder();
+ // Add compaction as the innermost middleware so it runs before every LLM call,
+ // including those triggered by tool call iterations within FunctionInvokingChatClient.
+ if (options?.CompactionStrategy is { } compactionStrategy)
+ {
+ chatBuilder.Use(innerClient => new CompactingChatClient(innerClient, compactionStrategy));
+ }
+
if (chatClient.GetService() is null)
{
- _ = chatBuilder.Use((innerClient, services) =>
+ chatBuilder.Use((innerClient, services) =>
{
var loggerFactory = services.GetService();
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/CompactingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/CompactingChatClient.cs
new file mode 100644
index 0000000000..8e94b840ea
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/CompactingChatClient.cs
@@ -0,0 +1,75 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Compaction;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI;
+
+///
+/// A delegating that applies an to the message list
+/// before each call to the inner chat client.
+///
+///
+///
+/// This client is used for in-run compaction during the tool loop. It is inserted into the
+/// pipeline before the so that
+/// compaction is applied before every LLM call, including those triggered by tool call iterations.
+///
+///
+/// The compaction strategy organizes messages into atomic groups (preserving tool-call/result pairings)
+/// before applying compaction logic. Only included messages are forwarded to the inner client.
+///
+///
+internal sealed class CompactingChatClient : DelegatingChatClient
+{
+ private readonly ICompactionStrategy _compactionStrategy;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The inner chat client to delegate to.
+ /// The compaction strategy to apply before each call.
+ public CompactingChatClient(IChatClient innerClient, ICompactionStrategy compactionStrategy)
+ : base(innerClient)
+ {
+ this._compactionStrategy = Throw.IfNull(compactionStrategy);
+ }
+
+ ///
+ public override async Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ List compactedMessages = await this.ApplyCompactionAsync(messages, cancellationToken).ConfigureAwait(false);
+ return await base.GetResponseAsync(compactedMessages, options, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public override async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ List compactedMessages = await this.ApplyCompactionAsync(messages, cancellationToken).ConfigureAwait(false);
+ await foreach (var update in base.GetStreamingResponseAsync(compactedMessages, options, cancellationToken).ConfigureAwait(false))
+ {
+ yield return update;
+ }
+ }
+
+ private async Task> ApplyCompactionAsync(IEnumerable messages, CancellationToken cancellationToken)
+ {
+ List messageList = messages as List ?? [.. messages];
+ MessageGroups groups = MessageGroups.Create(messageList);
+
+ bool compacted = await this._compactionStrategy.CompactAsync(groups, cancellationToken).ConfigureAwait(false);
+
+ return compacted ? [.. groups.GetIncludedMessages()] : messageList;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs
new file mode 100644
index 0000000000..3255d3f998
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs
@@ -0,0 +1,135 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// A compaction strategy that summarizes older message groups using an ,
+/// replacing them with a single summary message.
+///
+///
+///
+/// When the number of included message groups exceeds ,
+/// this strategy extracts the oldest non-system groups (up to the threshold), sends them
+/// to an for summarization, and replaces those groups with a single
+/// assistant message containing the summary.
+///
+///
+/// System message groups are always preserved and never included in summarization.
+///
+///
+public sealed class SummarizationCompactionStrategy : ICompactionStrategy
+{
+ private const string DefaultSummarizationPrompt =
+ "Summarize the following conversation concisely, preserving key facts, decisions, and context. " +
+ "Focus on information that would be needed to continue the conversation effectively.";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The chat client to use for generating summaries.
+ /// The maximum number of included groups allowed before summarization is triggered.
+ /// Optional custom prompt for the summarization request. If , a default prompt is used.
+ public SummarizationCompactionStrategy(IChatClient chatClient, int maxGroupsBeforeSummary, string? summarizationPrompt = null)
+ {
+ this.ChatClient = Throw.IfNull(chatClient);
+ this.MaxGroupsBeforeSummary = maxGroupsBeforeSummary;
+ this.SummarizationPrompt = summarizationPrompt ?? DefaultSummarizationPrompt;
+ }
+
+ ///
+ /// Gets the chat client used for generating summaries.
+ ///
+ public IChatClient ChatClient { get; }
+
+ ///
+ /// Gets the maximum number of included groups allowed before summarization is triggered.
+ ///
+ public int MaxGroupsBeforeSummary { get; }
+
+ ///
+ /// Gets the prompt used when requesting summaries from the chat client.
+ ///
+ public string SummarizationPrompt { get; }
+
+ ///
+ public async Task CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default)
+ {
+ int includedCount = groups.IncludedGroupCount;
+ if (includedCount <= this.MaxGroupsBeforeSummary)
+ {
+ return false;
+ }
+
+ // Determine how many groups to summarize (keep the most recent MaxGroupsBeforeSummary groups)
+ int groupsToSummarize = includedCount - this.MaxGroupsBeforeSummary;
+
+ // Collect the oldest non-system included groups for summarization
+ StringBuilder conversationText = new();
+ int summarized = 0;
+ int insertIndex = -1;
+
+ for (int i = 0; i < groups.Groups.Count && summarized < groupsToSummarize; i++)
+ {
+ MessageGroup group = groups.Groups[i];
+ if (group.IsExcluded || group.Kind == MessageGroupKind.System)
+ {
+ continue;
+ }
+
+ if (insertIndex < 0)
+ {
+ insertIndex = i;
+ }
+
+ // Build text representation of the group for summarization
+ foreach (ChatMessage message in group.Messages)
+ {
+ string text = message.Text ?? string.Empty;
+ if (!string.IsNullOrEmpty(text))
+ {
+ conversationText.AppendLine($"{message.Role}: {text}");
+ }
+ }
+
+ group.IsExcluded = true;
+ group.ExcludeReason = "Summarized by SummarizationCompactionStrategy";
+ summarized++;
+ }
+
+ if (summarized == 0)
+ {
+ return false;
+ }
+
+ // Generate summary using the chat client
+ ChatResponse response = await this.ChatClient.GetResponseAsync(
+ [
+ new ChatMessage(ChatRole.System, this.SummarizationPrompt),
+ new ChatMessage(ChatRole.User, conversationText.ToString()),
+ ],
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ string summaryText = response.Text ?? string.Empty;
+
+ // Insert a summary group at the position of the first summarized group
+ ChatMessage summaryMessage = new(ChatRole.Assistant, $"[Summary of earlier conversation]: {summaryText}");
+ (summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true;
+
+ if (insertIndex >= 0)
+ {
+ groups.InsertGroup(insertIndex, MessageGroupKind.Summary, [summaryMessage]);
+ }
+ else
+ {
+ groups.AddGroup(MessageGroupKind.Summary, [summaryMessage]);
+ }
+
+ return true;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs
new file mode 100644
index 0000000000..dbeef544cd
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs
@@ -0,0 +1,80 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// A compaction strategy that keeps the most recent message groups up to a specified limit,
+/// optionally preserving system message groups.
+///
+///
+///
+/// This strategy implements a sliding window approach: it marks older groups as excluded
+/// while keeping the most recent groups within the configured limit.
+/// System message groups can optionally be preserved regardless of their position.
+///
+///
+/// This strategy respects atomic group preservation — tool call groups (assistant message + tool results)
+/// are always kept or excluded together.
+///
+///
+public sealed class TruncationCompactionStrategy : ICompactionStrategy
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The maximum number of message groups to keep. Must be greater than zero.
+ /// Whether to preserve system message groups regardless of position. Defaults to .
+ public TruncationCompactionStrategy(int maxGroups, bool preserveSystemMessages = true)
+ {
+ this.MaxGroups = maxGroups;
+ this.PreserveSystemMessages = preserveSystemMessages;
+ }
+
+ ///
+ /// Gets the maximum number of message groups to retain after compaction.
+ ///
+ public int MaxGroups { get; }
+
+ ///
+ /// Gets a value indicating whether system message groups are preserved regardless of their position in the conversation.
+ ///
+ public bool PreserveSystemMessages { get; }
+
+ ///
+ public Task CompactAsync(MessageGroups groups, CancellationToken cancellationToken = default)
+ {
+ int includedCount = groups.IncludedGroupCount;
+ if (includedCount <= this.MaxGroups)
+ {
+ return Task.FromResult(false);
+ }
+
+ int excessCount = includedCount - this.MaxGroups;
+ bool compacted = false;
+
+ // Exclude oldest non-system groups first (iterate from the beginning)
+ for (int i = 0; i < groups.Groups.Count && excessCount > 0; i++)
+ {
+ MessageGroup group = groups.Groups[i];
+ if (group.IsExcluded)
+ {
+ continue;
+ }
+
+ if (this.PreserveSystemMessages && group.Kind == MessageGroupKind.System)
+ {
+ continue;
+ }
+
+ group.IsExcluded = true;
+ group.ExcludeReason = "Truncated by TruncationCompactionStrategy";
+ excessCount--;
+ compacted = true;
+ }
+
+ return Task.FromResult(compacted);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs
new file mode 100644
index 0000000000..ae02b85779
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/InMemoryChatHistoryProviderCompactionTests.cs
@@ -0,0 +1,268 @@
+// 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;
+
+///
+/// Contains tests for the compaction integration with .
+///
+public class InMemoryChatHistoryProviderCompactionTests
+{
+ private static readonly AIAgent s_mockAgent = new Mock().Object;
+
+ private static AgentSession CreateMockSession() => new Mock().Object;
+
+ [Fact]
+ public void Constructor_SetsCompactionStrategy_FromOptions()
+ {
+ // Arrange
+ Mock strategy = new();
+
+ // Act
+ InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions
+ {
+ CompactionStrategy = strategy.Object,
+ });
+
+ // Assert
+ Assert.Same(strategy.Object, provider.CompactionStrategy);
+ }
+
+ [Fact]
+ public void Constructor_CompactionStrategyIsNull_ByDefault()
+ {
+ // Arrange & Act
+ InMemoryChatHistoryProvider provider = new();
+
+ // Assert
+ Assert.Null(provider.CompactionStrategy);
+ }
+
+ [Fact]
+ public async Task StoreChatHistoryAsync_AppliesCompaction_WhenStrategyConfiguredAsync()
+ {
+ // Arrange — mock strategy that excludes the first included non-system group
+ Mock mockStrategy = new();
+ mockStrategy.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
+ .Callback((groups, _) =>
+ {
+ foreach (MessageGroup group in groups.Groups)
+ {
+ if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
+ {
+ group.IsExcluded = true;
+ group.ExcludeReason = "Mock compaction";
+ break;
+ }
+ }
+ })
+ .ReturnsAsync(true);
+
+ InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions
+ {
+ CompactionStrategy = mockStrategy.Object,
+ });
+
+ AgentSession session = CreateMockSession();
+
+ // Pre-populate with some messages
+ List existingMessages =
+ [
+ new ChatMessage(ChatRole.User, "First"),
+ new ChatMessage(ChatRole.Assistant, "Response 1"),
+ ];
+ provider.SetMessages(session, existingMessages);
+
+ // Invoke the store flow with additional messages
+ List requestMessages =
+ [
+ new ChatMessage(ChatRole.User, "Second"),
+ ];
+ List responseMessages =
+ [
+ new ChatMessage(ChatRole.Assistant, "Response 2"),
+ ];
+
+ ChatHistoryProvider.InvokedContext context = new(s_mockAgent, session, requestMessages, responseMessages);
+
+ // Act
+ await provider.InvokedAsync(context);
+
+ // Assert - compaction should have removed one group
+ List storedMessages = provider.GetMessages(session);
+ Assert.Equal(3, storedMessages.Count);
+ mockStrategy.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task StoreChatHistoryAsync_DoesNotCompact_WhenNoStrategyAsync()
+ {
+ // Arrange
+ InMemoryChatHistoryProvider provider = new();
+ AgentSession session = CreateMockSession();
+
+ List requestMessages =
+ [
+ new ChatMessage(ChatRole.User, "Hello"),
+ ];
+ List responseMessages =
+ [
+ new ChatMessage(ChatRole.Assistant, "Hi!"),
+ ];
+
+ ChatHistoryProvider.InvokedContext context = new(s_mockAgent, session, requestMessages, responseMessages);
+
+ // Act
+ await provider.InvokedAsync(context);
+
+ // Assert - all messages should be stored
+ List storedMessages = provider.GetMessages(session);
+ Assert.Equal(2, storedMessages.Count);
+ }
+
+ [Fact]
+ public async Task CompactStorageAsync_CompactsStoredMessagesAsync()
+ {
+ // Arrange — mock strategy that excludes the two oldest non-system groups
+ Mock mockStrategy = new();
+ mockStrategy.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
+ .Callback((groups, _) =>
+ {
+ int excluded = 0;
+ foreach (MessageGroup group in groups.Groups)
+ {
+ if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2)
+ {
+ group.IsExcluded = true;
+ excluded++;
+ }
+ }
+ })
+ .ReturnsAsync(true);
+
+ InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions
+ {
+ CompactionStrategy = mockStrategy.Object,
+ });
+
+ AgentSession session = CreateMockSession();
+ provider.SetMessages(session,
+ [
+ new ChatMessage(ChatRole.User, "First"),
+ new ChatMessage(ChatRole.Assistant, "Response 1"),
+ new ChatMessage(ChatRole.User, "Second"),
+ new ChatMessage(ChatRole.Assistant, "Response 2"),
+ ]);
+
+ // Act
+ bool result = await provider.CompactStorageAsync(session);
+
+ // Assert
+ Assert.True(result);
+ List messages = provider.GetMessages(session);
+ Assert.Equal(2, messages.Count);
+ mockStrategy.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task CompactStorageAsync_UsesProvidedStrategy_OverDefaultAsync()
+ {
+ // Arrange
+ Mock defaultStrategy = new();
+ Mock overrideStrategy = new();
+
+ overrideStrategy.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
+ .Callback((groups, _) =>
+ {
+ // Exclude all but the last group
+ for (int i = 0; i < groups.Groups.Count - 1; i++)
+ {
+ groups.Groups[i].IsExcluded = true;
+ }
+ })
+ .ReturnsAsync(true);
+
+ InMemoryChatHistoryProvider provider = new(new InMemoryChatHistoryProviderOptions
+ {
+ CompactionStrategy = defaultStrategy.Object,
+ });
+
+ AgentSession session = CreateMockSession();
+ provider.SetMessages(session,
+ [
+ new ChatMessage(ChatRole.User, "First"),
+ new ChatMessage(ChatRole.User, "Second"),
+ new ChatMessage(ChatRole.User, "Third"),
+ ]);
+
+ // Act
+ bool result = await provider.CompactStorageAsync(session, overrideStrategy.Object);
+
+ // Assert
+ Assert.True(result);
+ List messages = provider.GetMessages(session);
+ Assert.Single(messages);
+ Assert.Equal("Third", messages[0].Text);
+
+ // Verify the override was used, not the default
+ overrideStrategy.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once);
+ defaultStrategy.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task CompactStorageAsync_Throws_WhenNoStrategyAvailableAsync()
+ {
+ // Arrange
+ InMemoryChatHistoryProvider provider = new();
+ AgentSession session = CreateMockSession();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(
+ () => provider.CompactStorageAsync(session));
+ }
+
+ [Fact]
+ public async Task CompactStorageAsync_WithCustomStrategy_AppliesCustomLogicAsync()
+ {
+ // Arrange
+ Mock mockStrategy = new();
+ mockStrategy.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
+ .Callback((groups, _) =>
+ {
+ // Exclude all user groups
+ foreach (MessageGroup group in groups.Groups)
+ {
+ if (group.Kind == MessageGroupKind.User)
+ {
+ group.IsExcluded = true;
+ }
+ }
+ })
+ .ReturnsAsync(true);
+
+ InMemoryChatHistoryProvider provider = new();
+ AgentSession session = CreateMockSession();
+ provider.SetMessages(session,
+ [
+ new ChatMessage(ChatRole.System, "System"),
+ new ChatMessage(ChatRole.User, "User message"),
+ new ChatMessage(ChatRole.Assistant, "Response"),
+ ]);
+
+ // Act
+ bool result = await provider.CompactStorageAsync(session, mockStrategy.Object);
+
+ // Assert
+ Assert.True(result);
+ List messages = provider.GetMessages(session);
+ Assert.Equal(2, messages.Count);
+ Assert.Equal(ChatRole.System, messages[0].Role);
+ Assert.Equal(ChatRole.Assistant, messages[1].Role);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupsTests.cs
new file mode 100644
index 0000000000..c3dfedd208
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/MessageGroupsTests.cs
@@ -0,0 +1,524 @@
+// 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;
+
+///
+/// Contains tests for the class.
+///
+public class MessageGroupsTests
+{
+ [Fact]
+ public void Create_EmptyList_ReturnsEmptyGroups()
+ {
+ // Arrange
+ List messages = [];
+
+ // Act
+ MessageGroups groups = MessageGroups.Create(messages);
+
+ // Assert
+ Assert.Empty(groups.Groups);
+ }
+
+ [Fact]
+ public void Create_SystemMessage_CreatesSystemGroup()
+ {
+ // Arrange
+ List messages =
+ [
+ new ChatMessage(ChatRole.System, "You are helpful."),
+ ];
+
+ // Act
+ MessageGroups groups = MessageGroups.Create(messages);
+
+ // Assert
+ Assert.Single(groups.Groups);
+ Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
+ Assert.Single(groups.Groups[0].Messages);
+ }
+
+ [Fact]
+ public void Create_UserMessage_CreatesUserGroup()
+ {
+ // Arrange
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Hello"),
+ ];
+
+ // Act
+ MessageGroups groups = MessageGroups.Create(messages);
+
+ // Assert
+ Assert.Single(groups.Groups);
+ Assert.Equal(MessageGroupKind.User, groups.Groups[0].Kind);
+ }
+
+ [Fact]
+ public void Create_AssistantTextMessage_CreatesAssistantTextGroup()
+ {
+ // Arrange
+ List messages =
+ [
+ new ChatMessage(ChatRole.Assistant, "Hi there!"),
+ ];
+
+ // Act
+ MessageGroups groups = MessageGroups.Create(messages);
+
+ // Assert
+ Assert.Single(groups.Groups);
+ Assert.Equal(MessageGroupKind.AssistantText, groups.Groups[0].Kind);
+ }
+
+ [Fact]
+ public void Create_ToolCallWithResults_CreatesAtomicToolCallGroup()
+ {
+ // Arrange
+ ChatMessage assistantMessage = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "Seattle" })]);
+ ChatMessage toolResult = new(ChatRole.Tool, "Sunny, 72°F");
+
+ List messages = [assistantMessage, toolResult];
+
+ // Act
+ MessageGroups groups = MessageGroups.Create(messages);
+
+ // Assert
+ Assert.Single(groups.Groups);
+ Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[0].Kind);
+ Assert.Equal(2, groups.Groups[0].Messages.Count);
+ Assert.Same(assistantMessage, groups.Groups[0].Messages[0]);
+ Assert.Same(toolResult, groups.Groups[0].Messages[1]);
+ }
+
+ [Fact]
+ public void Create_MixedConversation_GroupsCorrectly()
+ {
+ // Arrange
+ ChatMessage systemMsg = new(ChatRole.System, "You are helpful.");
+ ChatMessage userMsg = new(ChatRole.User, "What's the weather?");
+ ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
+ ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
+ ChatMessage assistantText = new(ChatRole.Assistant, "The weather is sunny!");
+
+ List messages = [systemMsg, userMsg, assistantToolCall, toolResult, assistantText];
+
+ // Act
+ MessageGroups groups = MessageGroups.Create(messages);
+
+ // Assert
+ Assert.Equal(4, groups.Groups.Count);
+ Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
+ Assert.Equal(MessageGroupKind.User, groups.Groups[1].Kind);
+ Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[2].Kind);
+ Assert.Equal(2, groups.Groups[2].Messages.Count);
+ Assert.Equal(MessageGroupKind.AssistantText, groups.Groups[3].Kind);
+ }
+
+ [Fact]
+ public void Create_MultipleToolResults_GroupsAllWithAssistant()
+ {
+ // Arrange
+ ChatMessage assistantToolCall = new(ChatRole.Assistant, [
+ new FunctionCallContent("call1", "get_weather"),
+ new FunctionCallContent("call2", "get_time"),
+ ]);
+ ChatMessage toolResult1 = new(ChatRole.Tool, "Sunny");
+ ChatMessage toolResult2 = new(ChatRole.Tool, "3:00 PM");
+
+ List messages = [assistantToolCall, toolResult1, toolResult2];
+
+ // Act
+ MessageGroups groups = MessageGroups.Create(messages);
+
+ // Assert
+ Assert.Single(groups.Groups);
+ Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[0].Kind);
+ Assert.Equal(3, groups.Groups[0].Messages.Count);
+ }
+
+ [Fact]
+ public void GetIncludedMessages_ExcludesMarkedGroups()
+ {
+ // Arrange
+ ChatMessage msg1 = new(ChatRole.User, "First");
+ ChatMessage msg2 = new(ChatRole.Assistant, "Response");
+ ChatMessage msg3 = new(ChatRole.User, "Second");
+
+ MessageGroups groups = MessageGroups.Create([msg1, msg2, msg3]);
+ groups.Groups[1].IsExcluded = true;
+
+ // Act
+ List included = [.. groups.GetIncludedMessages()];
+
+ // Assert
+ Assert.Equal(2, included.Count);
+ Assert.Same(msg1, included[0]);
+ Assert.Same(msg3, included[1]);
+ }
+
+ [Fact]
+ public void GetAllMessages_IncludesExcludedGroups()
+ {
+ // Arrange
+ ChatMessage msg1 = new(ChatRole.User, "First");
+ ChatMessage msg2 = new(ChatRole.Assistant, "Response");
+
+ MessageGroups groups = MessageGroups.Create([msg1, msg2]);
+ groups.Groups[0].IsExcluded = true;
+
+ // Act
+ List all = [.. groups.GetAllMessages()];
+
+ // Assert
+ Assert.Equal(2, all.Count);
+ }
+
+ [Fact]
+ public void IncludedGroupCount_ReflectsExclusions()
+ {
+ // Arrange
+ MessageGroups groups = MessageGroups.Create(
+ [
+ new ChatMessage(ChatRole.User, "A"),
+ new ChatMessage(ChatRole.Assistant, "B"),
+ new ChatMessage(ChatRole.User, "C"),
+ ]);
+
+ groups.Groups[1].IsExcluded = true;
+
+ // Act & Assert
+ Assert.Equal(2, groups.IncludedGroupCount);
+ Assert.Equal(2, groups.IncludedMessageCount);
+ }
+
+ [Fact]
+ public void Create_SummaryMessage_CreatesSummaryGroup()
+ {
+ // Arrange
+ ChatMessage summaryMessage = new(ChatRole.Assistant, "[Summary of earlier conversation]: key facts...");
+ (summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true;
+
+ List messages = [summaryMessage];
+
+ // Act
+ MessageGroups groups = MessageGroups.Create(messages);
+
+ // Assert
+ Assert.Single(groups.Groups);
+ Assert.Equal(MessageGroupKind.Summary, groups.Groups[0].Kind);
+ Assert.Same(summaryMessage, groups.Groups[0].Messages[0]);
+ }
+
+ [Fact]
+ public void Create_SummaryAmongOtherMessages_GroupsCorrectly()
+ {
+ // Arrange
+ ChatMessage systemMsg = new(ChatRole.System, "You are helpful.");
+ ChatMessage summaryMsg = new(ChatRole.Assistant, "[Summary]: previous context");
+ (summaryMsg.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true;
+ ChatMessage userMsg = new(ChatRole.User, "Continue...");
+
+ List messages = [systemMsg, summaryMsg, userMsg];
+
+ // Act
+ MessageGroups groups = MessageGroups.Create(messages);
+
+ // Assert
+ Assert.Equal(3, groups.Groups.Count);
+ Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
+ Assert.Equal(MessageGroupKind.Summary, groups.Groups[1].Kind);
+ Assert.Equal(MessageGroupKind.User, groups.Groups[2].Kind);
+ }
+
+ [Fact]
+ public void MessageGroup_StoresPassedCounts()
+ {
+ // Arrange & Act
+ MessageGroup group = new(MessageGroupKind.User, [new ChatMessage(ChatRole.User, "Hello")], byteCount: 5, tokenCount: 2);
+
+ // Assert
+ Assert.Equal(1, group.MessageCount);
+ Assert.Equal(5, group.ByteCount);
+ Assert.Equal(2, group.TokenCount);
+ }
+
+ [Fact]
+ public void MessageGroup_MessagesAreImmutable()
+ {
+ // Arrange
+ IReadOnlyList messages = [new ChatMessage(ChatRole.User, "Hello")];
+ MessageGroup group = new(MessageGroupKind.User, messages, byteCount: 5, tokenCount: 1);
+
+ // Assert — Messages is IReadOnlyList, not IList
+ Assert.IsAssignableFrom>(group.Messages);
+ Assert.Same(messages, group.Messages);
+ }
+
+ [Fact]
+ public void Create_ComputesByteCount_Utf8()
+ {
+ // Arrange — "Hello" is 5 UTF-8 bytes
+ MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
+
+ // Assert
+ Assert.Equal(5, groups.Groups[0].ByteCount);
+ }
+
+ [Fact]
+ public void Create_ComputesByteCount_MultiByteChars()
+ {
+ // Arrange — "café" has a multi-byte 'é' (2 bytes in UTF-8) → 5 bytes total
+ MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "café")]);
+
+ // Assert
+ Assert.Equal(5, groups.Groups[0].ByteCount);
+ }
+
+ [Fact]
+ public void Create_ComputesByteCount_MultipleMessagesInGroup()
+ {
+ // Arrange — ToolCall group: assistant (tool call, null text) + tool result "OK" (2 bytes)
+ ChatMessage assistantMsg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]);
+ ChatMessage toolResult = new(ChatRole.Tool, "OK");
+ MessageGroups groups = MessageGroups.Create([assistantMsg, toolResult]);
+
+ // Assert — single ToolCall group with 2 messages
+ Assert.Single(groups.Groups);
+ Assert.Equal(2, groups.Groups[0].MessageCount);
+ Assert.Equal(2, groups.Groups[0].ByteCount); // "OK" = 2 bytes, assistant text is null
+ }
+
+ [Fact]
+ public void Create_DefaultTokenCount_IsHeuristic()
+ {
+ // Arrange — "Hello world test data!" = 22 UTF-8 bytes → 22 / 4 = 5 estimated tokens
+ MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello world test data!")]);
+
+ // Assert
+ Assert.Equal(22, groups.Groups[0].ByteCount);
+ Assert.Equal(22 / 4, groups.Groups[0].TokenCount);
+ }
+
+ [Fact]
+ public void Create_NullText_HasZeroCounts()
+ {
+ // Arrange — message with no text (e.g., pure function call)
+ ChatMessage msg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
+ ChatMessage tool = new(ChatRole.Tool, string.Empty);
+ MessageGroups groups = MessageGroups.Create([msg, tool]);
+
+ // Assert
+ Assert.Equal(2, groups.Groups[0].MessageCount);
+ Assert.Equal(0, groups.Groups[0].ByteCount);
+ Assert.Equal(0, groups.Groups[0].TokenCount);
+ }
+
+ [Fact]
+ public void TotalAggregates_SumAllGroups()
+ {
+ // Arrange
+ MessageGroups groups = MessageGroups.Create(
+ [
+ new ChatMessage(ChatRole.User, "AAAA"), // 4 bytes
+ new ChatMessage(ChatRole.Assistant, "BBBB"), // 4 bytes
+ ]);
+
+ groups.Groups[0].IsExcluded = true;
+
+ // Act & Assert — totals include excluded groups
+ Assert.Equal(2, groups.TotalGroupCount);
+ Assert.Equal(2, groups.TotalMessageCount);
+ Assert.Equal(8, groups.TotalByteCount);
+ Assert.Equal(2, groups.TotalTokenCount); // Each group: 4 bytes / 4 = 1 token, 2 groups = 2
+ }
+
+ [Fact]
+ public void IncludedAggregates_ExcludeMarkedGroups()
+ {
+ // Arrange
+ MessageGroups groups = MessageGroups.Create(
+ [
+ new ChatMessage(ChatRole.User, "AAAA"), // 4 bytes
+ new ChatMessage(ChatRole.Assistant, "BBBB"), // 4 bytes
+ new ChatMessage(ChatRole.User, "CCCC"), // 4 bytes
+ ]);
+
+ groups.Groups[0].IsExcluded = true;
+
+ // Act & Assert
+ Assert.Equal(3, groups.TotalGroupCount);
+ Assert.Equal(2, groups.IncludedGroupCount);
+ Assert.Equal(3, groups.TotalMessageCount);
+ Assert.Equal(2, groups.IncludedMessageCount);
+ Assert.Equal(12, groups.TotalByteCount);
+ Assert.Equal(8, groups.IncludedByteCount);
+ Assert.Equal(3, groups.TotalTokenCount); // 12 / 4 = 3 (across 3 groups of 4 bytes each = 1+1+1)
+ Assert.Equal(2, groups.IncludedTokenCount); // 8 / 4 = 2 (2 included groups of 4 bytes = 1+1)
+ }
+
+ [Fact]
+ public void ToolCallGroup_AggregatesAcrossMessages()
+ {
+ // Arrange — tool call group with assistant "Ask" (3 bytes) + tool result "OK" (2 bytes)
+ ChatMessage assistantMsg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]);
+ ChatMessage toolResult = new(ChatRole.Tool, "OK");
+
+ MessageGroups groups = MessageGroups.Create([assistantMsg, toolResult]);
+
+ // Assert — single group with 2 messages
+ Assert.Single(groups.Groups);
+ Assert.Equal(2, groups.Groups[0].MessageCount);
+ Assert.Equal(2, groups.Groups[0].ByteCount); // assistant text is null (function call), tool result is "OK" = 2 bytes
+ Assert.Equal(1, groups.TotalGroupCount);
+ Assert.Equal(2, groups.TotalMessageCount);
+ }
+
+ [Fact]
+ public void Create_AssignsTurnIndices_SingleTurn()
+ {
+ // Arrange — System (no turn), User + Assistant = turn 1
+ MessageGroups groups = MessageGroups.Create(
+ [
+ new ChatMessage(ChatRole.System, "You are helpful."),
+ new ChatMessage(ChatRole.User, "Hello"),
+ new ChatMessage(ChatRole.Assistant, "Hi!"),
+ ]);
+
+ // Assert
+ Assert.Null(groups.Groups[0].TurnIndex); // System
+ Assert.Equal(1, groups.Groups[1].TurnIndex); // User
+ Assert.Equal(1, groups.Groups[2].TurnIndex); // Assistant
+ Assert.Equal(1, groups.TotalTurnCount);
+ Assert.Equal(1, groups.IncludedTurnCount);
+ }
+
+ [Fact]
+ public void Create_AssignsTurnIndices_MultiTurn()
+ {
+ // Arrange — 3 user turns
+ MessageGroups groups = MessageGroups.Create(
+ [
+ new ChatMessage(ChatRole.System, "System prompt."),
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.Assistant, "A2"),
+ new ChatMessage(ChatRole.User, "Q3"),
+ ]);
+
+ // Assert — 6 groups: System(null), User(1), Assistant(1), User(2), Assistant(2), User(3)
+ Assert.Null(groups.Groups[0].TurnIndex);
+ Assert.Equal(1, groups.Groups[1].TurnIndex);
+ Assert.Equal(1, groups.Groups[2].TurnIndex);
+ Assert.Equal(2, groups.Groups[3].TurnIndex);
+ Assert.Equal(2, groups.Groups[4].TurnIndex);
+ Assert.Equal(3, groups.Groups[5].TurnIndex);
+ Assert.Equal(3, groups.TotalTurnCount);
+ }
+
+ [Fact]
+ public void Create_TurnSpansToolCallGroups()
+ {
+ // Arrange — turn 1 includes User, ToolCall, AssistantText
+ ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
+ ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
+
+ MessageGroups groups = MessageGroups.Create(
+ [
+ new ChatMessage(ChatRole.User, "What's the weather?"),
+ assistantToolCall,
+ toolResult,
+ new ChatMessage(ChatRole.Assistant, "The weather is sunny!"),
+ ]);
+
+ // Assert — all 3 groups belong to turn 1
+ Assert.Equal(3, groups.Groups.Count);
+ Assert.Equal(1, groups.Groups[0].TurnIndex); // User
+ Assert.Equal(1, groups.Groups[1].TurnIndex); // ToolCall
+ Assert.Equal(1, groups.Groups[2].TurnIndex); // AssistantText
+ Assert.Equal(1, groups.TotalTurnCount);
+ }
+
+ [Fact]
+ public void GetTurnGroups_ReturnsGroupsForSpecificTurn()
+ {
+ // Arrange
+ MessageGroups groups = MessageGroups.Create(
+ [
+ new ChatMessage(ChatRole.System, "System."),
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.Assistant, "A2"),
+ ]);
+
+ // Act
+ List turn1 = [.. groups.GetTurnGroups(1)];
+ List turn2 = [.. groups.GetTurnGroups(2)];
+
+ // Assert
+ Assert.Equal(2, turn1.Count);
+ Assert.Equal(MessageGroupKind.User, turn1[0].Kind);
+ Assert.Equal(MessageGroupKind.AssistantText, turn1[1].Kind);
+ Assert.Equal(2, turn2.Count);
+ Assert.Equal(MessageGroupKind.User, turn2[0].Kind);
+ Assert.Equal(MessageGroupKind.AssistantText, turn2[1].Kind);
+ }
+
+ [Fact]
+ public void IncludedTurnCount_ReflectsExclusions()
+ {
+ // Arrange — 2 turns, exclude all groups in turn 1
+ MessageGroups groups = MessageGroups.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.Assistant, "A2"),
+ ]);
+
+ groups.Groups[0].IsExcluded = true; // User Q1 (turn 1)
+ groups.Groups[1].IsExcluded = true; // Assistant A1 (turn 1)
+
+ // Assert
+ Assert.Equal(2, groups.TotalTurnCount);
+ Assert.Equal(1, groups.IncludedTurnCount); // Only turn 2 has included groups
+ }
+
+ [Fact]
+ public void TotalTurnCount_ZeroWhenNoUserMessages()
+ {
+ // Arrange — only system messages
+ MessageGroups groups = MessageGroups.Create(
+ [
+ new ChatMessage(ChatRole.System, "System."),
+ ]);
+
+ // Assert
+ Assert.Equal(0, groups.TotalTurnCount);
+ Assert.Equal(0, groups.IncludedTurnCount);
+ }
+
+ [Fact]
+ public void IncludedTurnCount_PartialExclusion_StillCountsTurn()
+ {
+ // Arrange — turn 1 has 2 groups, only one excluded
+ MessageGroups groups = MessageGroups.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ ]);
+
+ groups.Groups[1].IsExcluded = true; // Exclude assistant but user is still included
+
+ // Assert — turn 1 still has one included group
+ Assert.Equal(1, groups.TotalTurnCount);
+ Assert.Equal(1, groups.IncludedTurnCount);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/PipelineCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/PipelineCompactionStrategyTests.cs
new file mode 100644
index 0000000000..eb5cda0997
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/Compaction/PipelineCompactionStrategyTests.cs
@@ -0,0 +1,282 @@
+// 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;
+
+///
+/// Contains tests for the class.
+///
+public class PipelineCompactionStrategyTests
+{
+ [Fact]
+ public async Task CompactAsync_ExecutesAllStrategiesInOrder()
+ {
+ // Arrange
+ List executionOrder = [];
+ Mock strategy1 = new();
+ strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
+ .Callback(() => executionOrder.Add("first"))
+ .ReturnsAsync(false);
+
+ Mock strategy2 = new();
+ strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
+ .Callback(() => executionOrder.Add("second"))
+ .ReturnsAsync(false);
+
+ PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object);
+ MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
+
+ // Act
+ await pipeline.CompactAsync(groups);
+
+ // Assert
+ Assert.Equal(["first", "second"], executionOrder);
+ }
+
+ [Fact]
+ public async Task CompactAsync_ReturnsFalse_WhenNoStrategyCompacts()
+ {
+ // Arrange
+ Mock strategy1 = new();
+ strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(false);
+
+ PipelineCompactionStrategy pipeline = new(strategy1.Object);
+ MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
+
+ // Act
+ bool result = await pipeline.CompactAsync(groups);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task CompactAsync_ReturnsTrue_WhenAnyStrategyCompacts()
+ {
+ // Arrange
+ Mock strategy1 = new();
+ strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(false);
+
+ Mock strategy2 = new();
+ strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(true);
+
+ PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object);
+ MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
+
+ // Act
+ bool result = await pipeline.CompactAsync(groups);
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Fact]
+ public async Task CompactAsync_ContinuesAfterFirstCompaction_WhenEarlyStopDisabled()
+ {
+ // Arrange
+ Mock strategy1 = new();
+ strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(true);
+
+ Mock strategy2 = new();
+ strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(false);
+
+ PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object);
+ MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
+
+ // Act
+ await pipeline.CompactAsync(groups);
+
+ // Assert — both strategies were called
+ strategy1.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once);
+ strategy2.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task CompactAsync_StopsEarly_WhenTargetReached()
+ {
+ // Arrange — first strategy reduces to target
+ Mock strategy1 = new();
+ strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
+ .Callback((groups, _) =>
+ {
+ // Exclude the first group to bring count down
+ groups.Groups[0].IsExcluded = true;
+ })
+ .ReturnsAsync(true);
+
+ Mock strategy2 = new();
+ strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(false);
+
+ PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object)
+ {
+ EarlyStop = true,
+ TargetIncludedGroupCount = 2,
+ };
+
+ MessageGroups groups = MessageGroups.Create(
+ [
+ new ChatMessage(ChatRole.User, "First"),
+ new ChatMessage(ChatRole.Assistant, "Response"),
+ new ChatMessage(ChatRole.User, "Second"),
+ ]);
+
+ // Act
+ bool result = await pipeline.CompactAsync(groups);
+
+ // Assert — strategy2 should not have been called
+ Assert.True(result);
+ strategy1.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once);
+ strategy2.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task CompactAsync_DoesNotStopEarly_WhenTargetNotReached()
+ {
+ // Arrange — first strategy does NOT bring count to target
+ Mock strategy1 = new();
+ strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(false);
+
+ Mock strategy2 = new();
+ strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(false);
+
+ PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object)
+ {
+ EarlyStop = true,
+ TargetIncludedGroupCount = 1,
+ };
+
+ MessageGroups groups = MessageGroups.Create(
+ [
+ new ChatMessage(ChatRole.User, "First"),
+ new ChatMessage(ChatRole.User, "Second"),
+ new ChatMessage(ChatRole.User, "Third"),
+ ]);
+
+ // Act
+ await pipeline.CompactAsync(groups);
+
+ // Assert — both strategies were called since target was never reached
+ strategy1.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once);
+ strategy2.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task CompactAsync_EarlyStopIgnored_WhenNoTargetSet()
+ {
+ // Arrange
+ Mock strategy1 = new();
+ strategy1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(true);
+
+ Mock strategy2 = new();
+ strategy2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(false);
+
+ PipelineCompactionStrategy pipeline = new(strategy1.Object, strategy2.Object)
+ {
+ EarlyStop = true,
+ // TargetIncludedGroupCount is null
+ };
+
+ MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
+
+ // Act
+ await pipeline.CompactAsync(groups);
+
+ // Assert — both strategies called because no target to check against
+ strategy1.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once);
+ strategy2.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task CompactAsync_ComposesStrategies_EndToEnd()
+ {
+ // Arrange — pipeline: first exclude oldest 2 non-system groups, then exclude 2 more
+ Mock phase1 = new();
+ phase1.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
+ .Callback((groups, _) =>
+ {
+ int excluded = 0;
+ foreach (MessageGroup group in groups.Groups)
+ {
+ if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2)
+ {
+ group.IsExcluded = true;
+ excluded++;
+ }
+ }
+ })
+ .ReturnsAsync(true);
+
+ Mock phase2 = new();
+ phase2.Setup(s => s.CompactAsync(It.IsAny(), It.IsAny()))
+ .Callback((groups, _) =>
+ {
+ int excluded = 0;
+ foreach (MessageGroup group in groups.Groups)
+ {
+ if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2)
+ {
+ group.IsExcluded = true;
+ excluded++;
+ }
+ }
+ })
+ .ReturnsAsync(true);
+
+ PipelineCompactionStrategy pipeline = new(phase1.Object, phase2.Object);
+
+ MessageGroups groups = MessageGroups.Create(
+ [
+ new ChatMessage(ChatRole.System, "You are helpful."),
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.Assistant, "A2"),
+ new ChatMessage(ChatRole.User, "Q3"),
+ ]);
+
+ // Act
+ bool result = await pipeline.CompactAsync(groups);
+
+ // Assert — system is preserved, phase1 excluded Q1+A1, phase2 excluded Q2+A2 → System + Q3
+ Assert.True(result);
+ Assert.Equal(2, groups.IncludedGroupCount);
+
+ List included = [.. groups.GetIncludedMessages()];
+ Assert.Equal(2, included.Count);
+ Assert.Equal("You are helpful.", included[0].Text);
+ Assert.Equal("Q3", included[1].Text);
+
+ phase1.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once);
+ phase2.Verify(s => s.CompactAsync(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task CompactAsync_EmptyPipeline_ReturnsFalseAsync()
+ {
+ // Arrange
+ PipelineCompactionStrategy pipeline = new(new List());
+ MessageGroups groups = MessageGroups.Create([new ChatMessage(ChatRole.User, "Hello")]);
+
+ // Act
+ bool result = await pipeline.CompactAsync(groups);
+
+ // Assert
+ Assert.False(result);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs
new file mode 100644
index 0000000000..41855884a9
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs
@@ -0,0 +1,191 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Compaction;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.UnitTests.Compaction;
+
+///
+/// Contains tests for the class.
+///
+public class TruncationCompactionStrategyTests
+{
+ [Fact]
+ public async Task CompactAsync_BelowLimit_ReturnsFalseAsync()
+ {
+ // Arrange
+ TruncationCompactionStrategy strategy = new(maxGroups: 5);
+ MessageGroups groups = MessageGroups.Create(
+ [
+ new ChatMessage(ChatRole.User, "Hello"),
+ new ChatMessage(ChatRole.Assistant, "Hi!"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert
+ Assert.False(result);
+ Assert.Equal(2, groups.IncludedGroupCount);
+ }
+
+ [Fact]
+ public async Task CompactAsync_AtLimit_ReturnsFalseAsync()
+ {
+ // Arrange
+ TruncationCompactionStrategy strategy = new(maxGroups: 2);
+ MessageGroups groups = MessageGroups.Create(
+ [
+ new ChatMessage(ChatRole.User, "Hello"),
+ new ChatMessage(ChatRole.Assistant, "Hi!"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task CompactAsync_ExceedsLimit_ExcludesOldestGroupsAsync()
+ {
+ // Arrange
+ TruncationCompactionStrategy strategy = new(maxGroups: 2);
+ ChatMessage msg1 = new(ChatRole.User, "First");
+ ChatMessage msg2 = new(ChatRole.Assistant, "Response 1");
+ ChatMessage msg3 = new(ChatRole.User, "Second");
+ ChatMessage msg4 = new(ChatRole.Assistant, "Response 2");
+
+ MessageGroups groups = MessageGroups.Create([msg1, msg2, msg3, msg4]);
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert
+ Assert.True(result);
+ Assert.Equal(2, groups.IncludedGroupCount);
+ Assert.True(groups.Groups[0].IsExcluded);
+ Assert.True(groups.Groups[1].IsExcluded);
+ Assert.False(groups.Groups[2].IsExcluded);
+ Assert.False(groups.Groups[3].IsExcluded);
+ }
+
+ [Fact]
+ public async Task CompactAsync_PreservesSystemMessages_WhenEnabledAsync()
+ {
+ // Arrange
+ TruncationCompactionStrategy strategy = new(maxGroups: 2, preserveSystemMessages: true);
+ ChatMessage systemMsg = new(ChatRole.System, "You are helpful.");
+ ChatMessage msg1 = new(ChatRole.User, "First");
+ ChatMessage msg2 = new(ChatRole.Assistant, "Response 1");
+ ChatMessage msg3 = new(ChatRole.User, "Second");
+
+ MessageGroups groups = MessageGroups.Create([systemMsg, msg1, msg2, msg3]);
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert
+ Assert.True(result);
+ // System message should be preserved
+ Assert.False(groups.Groups[0].IsExcluded);
+ Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
+ // Oldest non-system groups should be excluded
+ Assert.True(groups.Groups[1].IsExcluded);
+ Assert.True(groups.Groups[2].IsExcluded);
+ // Most recent should remain
+ Assert.False(groups.Groups[3].IsExcluded);
+ }
+
+ [Fact]
+ public async Task CompactAsync_DoesNotPreserveSystemMessages_WhenDisabledAsync()
+ {
+ // Arrange
+ TruncationCompactionStrategy strategy = new(maxGroups: 2, preserveSystemMessages: false);
+ ChatMessage systemMsg = new(ChatRole.System, "You are helpful.");
+ ChatMessage msg1 = new(ChatRole.User, "First");
+ ChatMessage msg2 = new(ChatRole.Assistant, "Response");
+ ChatMessage msg3 = new(ChatRole.User, "Second");
+
+ MessageGroups groups = MessageGroups.Create([systemMsg, msg1, msg2, msg3]);
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert
+ Assert.True(result);
+ // System message should be excluded (oldest)
+ Assert.True(groups.Groups[0].IsExcluded);
+ Assert.True(groups.Groups[1].IsExcluded);
+ Assert.False(groups.Groups[2].IsExcluded);
+ Assert.False(groups.Groups[3].IsExcluded);
+ }
+
+ [Fact]
+ public async Task CompactAsync_PreservesToolCallGroupAtomicityAsync()
+ {
+ // Arrange
+ TruncationCompactionStrategy strategy = new(maxGroups: 1);
+
+ ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
+ ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
+ ChatMessage finalResponse = new(ChatRole.User, "Thanks!");
+
+ MessageGroups groups = MessageGroups.Create([assistantToolCall, toolResult, finalResponse]);
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert
+ Assert.True(result);
+ // Tool call group should be excluded as one atomic unit
+ Assert.True(groups.Groups[0].IsExcluded);
+ Assert.Equal(MessageGroupKind.ToolCall, groups.Groups[0].Kind);
+ Assert.Equal(2, groups.Groups[0].Messages.Count);
+ Assert.False(groups.Groups[1].IsExcluded);
+ }
+
+ [Fact]
+ public async Task CompactAsync_SetsExcludeReasonAsync()
+ {
+ // Arrange
+ TruncationCompactionStrategy strategy = new(maxGroups: 1);
+ MessageGroups groups = MessageGroups.Create(
+ [
+ new ChatMessage(ChatRole.User, "Old"),
+ new ChatMessage(ChatRole.User, "New"),
+ ]);
+
+ // Act
+ await strategy.CompactAsync(groups);
+
+ // Assert
+ Assert.NotNull(groups.Groups[0].ExcludeReason);
+ Assert.Contains("TruncationCompactionStrategy", groups.Groups[0].ExcludeReason);
+ }
+
+ [Fact]
+ public async Task CompactAsync_SkipsAlreadyExcludedGroupsAsync()
+ {
+ // Arrange
+ TruncationCompactionStrategy strategy = new(maxGroups: 1);
+ MessageGroups groups = MessageGroups.Create(
+ [
+ new ChatMessage(ChatRole.User, "Already excluded"),
+ new ChatMessage(ChatRole.User, "Included 1"),
+ new ChatMessage(ChatRole.User, "Included 2"),
+ ]);
+ groups.Groups[0].IsExcluded = true;
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert
+ Assert.True(result);
+ Assert.True(groups.Groups[0].IsExcluded); // was already excluded
+ Assert.True(groups.Groups[1].IsExcluded); // newly excluded
+ Assert.False(groups.Groups[2].IsExcluded); // kept
+ }
+}