diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 255d8fe94f..5d4a96ea5d 100644
--- a/dotnet/Directory.Packages.props
+++ b/dotnet/Directory.Packages.props
@@ -108,6 +108,7 @@
+
diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx
index 75888768fa..6aa7337ba8 100644
--- a/dotnet/agent-framework-dotnet.slnx
+++ b/dotnet/agent-framework-dotnet.slnx
@@ -56,6 +56,7 @@
+
diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj
new file mode 100644
index 0000000000..0f9de7c359
--- /dev/null
+++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net10.0
+
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs
new file mode 100644
index 0000000000..472dd4cb0a
--- /dev/null
+++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs
@@ -0,0 +1,112 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample demonstrates how to use a ChatHistoryCompactionPipeline as the ChatReducer for an agent's
+// in-memory chat history. The pipeline chains multiple compaction strategies from gentle to aggressive:
+// 1. ToolResultCompactionStrategy - Collapses old tool-call groups into concise summaries
+// 2. SummarizationCompactionStrategy - LLM-compresses older conversation spans
+// 3. SlidingWindowCompactionStrategy - Keeps only the most recent N user turns
+// 4. TruncationCompactionStrategy - Emergency token-budget backstop
+
+using System.ComponentModel;
+using Azure.AI.OpenAI;
+using Azure.Identity;
+using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Compaction;
+using Microsoft.Extensions.AI;
+
+var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
+var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
+
+// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
+// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
+// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
+AzureOpenAIClient openAIClient = new(new Uri(endpoint), new DefaultAzureCredential());
+
+// Create a chat client for the agent and a separate one for the summarization strategy.
+// Using the same model for simplicity; in production, use a smaller/cheaper model for summarization.
+IChatClient agentChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
+IChatClient summarizerChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
+
+// Define a tool the agent can use, so we can see tool-result compaction in action.
+[Description("Look up the current price of a product by name.")]
+static string LookupPrice([Description("The product name to look up.")] string productName) =>
+ productName.ToUpperInvariant() switch
+ {
+ "LAPTOP" => "The laptop costs $999.99.",
+ "KEYBOARD" => "The keyboard costs $79.99.",
+ "MOUSE" => "The mouse costs $29.99.",
+ _ => $"Sorry, I don't have pricing for '{productName}'."
+ };
+
+// Configure the compaction pipeline with one of each strategy, ordered least to most aggressive.
+PipelineCompactionStrategy compactionPipeline =
+ new(// 1. Gentle: collapse old tool-call groups into short summaries like "[Tool calls: LookupPrice]"
+ new ToolResultCompactionStrategy(CompactionTriggers.TokensExceed(0x200)),
+
+ // 2. Moderate: use an LLM to summarize older conversation spans into a concise message
+ new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(0x500)),
+
+ // 3. Aggressive: keep only the last N user turns and their responses
+ new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(4)),
+
+ // 4. Emergency: drop oldest groups until under the token budget
+ new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(0x8000)));
+
+// Create the agent with an in-memory chat history provider whose reducer is the compaction pipeline.
+AIAgent agent =
+ agentChatClient.AsAIAgent(
+ new ChatClientAgentOptions
+ {
+ Name = "ShoppingAssistant",
+ ChatOptions = new()
+ {
+ Instructions =
+ """
+ You are a helpful, but long winded, shopping assistant.
+ Help the user look up prices and compare products.
+ When responding, Be sure to be extra descriptive and use as
+ many words as possible without sounding ridiculous.
+ """,
+ Tools = [AIFunctionFactory.Create(LookupPrice)],
+ },
+ CompactionStrategy = compactionPipeline,
+ });
+
+AgentSession session = await agent.CreateSessionAsync();
+
+// Helper to print chat history size
+void PrintChatHistory()
+{
+ if (session.TryGetInMemoryChatHistory(out var history))
+ {
+ Console.ForegroundColor = ConsoleColor.Cyan;
+ Console.WriteLine($"\n[Messages: x{history.Count}]\n");
+ Console.ResetColor();
+ }
+}
+
+// Run a multi-turn conversation with tool calls to exercise the pipeline.
+string[] prompts =
+[
+ "What's the price of a laptop?",
+ "How about a keyboard?",
+ "And a mouse?",
+ "Which product is the cheapest?",
+ "Can you compare the laptop and the keyboard for me?",
+ "What was the first product I asked about?",
+ "Thank you!",
+];
+
+foreach (string prompt in prompts)
+{
+ Console.ForegroundColor = ConsoleColor.Cyan;
+ Console.Write("\n[User] ");
+ Console.ResetColor();
+ Console.WriteLine(prompt);
+ Console.ForegroundColor = ConsoleColor.Cyan;
+ Console.Write("\n[Agent] ");
+ Console.ResetColor();
+ Console.WriteLine(await agent.RunAsync(prompt, session));
+
+ PrintChatHistory();
+}
diff --git a/dotnet/samples/02-agents/Agents/README.md b/dotnet/samples/02-agents/Agents/README.md
index 116cbfc06b..4ac53ba246 100644
--- a/dotnet/samples/02-agents/Agents/README.md
+++ b/dotnet/samples/02-agents/Agents/README.md
@@ -44,6 +44,7 @@ Before you begin, ensure you have the following prerequisites:
|[Deep research with an agent](./Agent_Step15_DeepResearch/)|This sample demonstrates how to use the Deep Research Tool to perform comprehensive research on complex topics|
|[Declarative agent](./Agent_Step16_Declarative/)|This sample demonstrates how to declaratively define an agent.|
|[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.|
+|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
## Running the samples from the console
diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs
index 7c7b28b7bd..539a81ff73 100644
--- a/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/InMemoryChatHistoryProvider.cs
@@ -79,20 +79,21 @@ public List GetMessages(AgentSession? session)
/// is .
public void SetMessages(AgentSession? session, List messages)
{
- _ = Throw.IfNull(messages);
+ Throw.IfNull(messages);
- var state = this._sessionState.GetOrInitializeState(session);
+ State state = this._sessionState.GetOrInitializeState(session);
state.Messages = messages;
}
///
protected override async ValueTask> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
- var state = this._sessionState.GetOrInitializeState(context.Session);
+ State state = this._sessionState.GetOrInitializeState(context.Session);
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.BeforeMessagesRetrieval && this.ChatReducer is not null)
{
- state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
+ // Apply pre-invocation compaction strategy if configured
+ await this.CompactMessagesAsync(state, cancellationToken).ConfigureAwait(false);
}
return state.Messages;
@@ -101,18 +102,31 @@ protected override async ValueTask> ProvideChatHistoryA
///
protected override async ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
- var state = this._sessionState.GetOrInitializeState(context.Session);
+ State state = this._sessionState.GetOrInitializeState(context.Session);
// Add request and response messages to the provider
var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []);
state.Messages.AddRange(allNewMessages);
- if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
+ if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded)
{
- state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
+ // Apply pre-write compaction strategy if configured
+ await this.CompactMessagesAsync(state, cancellationToken).ConfigureAwait(false);
}
}
+ private async Task CompactMessagesAsync(State state, CancellationToken cancellationToken = default)
+ {
+ if (this.ChatReducer is not null)
+ {
+ // ChatReducer takes precedence, if configured
+ state.Messages = [.. await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)];
+ return;
+ }
+
+ // %%% TODO: CONSIDER COMPACTION
+ }
+
///
/// Represents the state of a stored in the .
///
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
index 38cad40bbe..affd21398f 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs
@@ -1,7 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
+using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
@@ -45,6 +48,23 @@ 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.
+ ///
+ ///
+ [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+ public CompactionStrategy? CompactionStrategy { get; set; }
+
///
/// Gets or sets a value indicating whether to use the provided instance as is,
/// without applying any default decorators.
@@ -101,6 +121,7 @@ public ChatClientAgentOptions Clone()
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..a8d57ec3d0 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
@@ -4,6 +4,7 @@
using System.Collections.Generic;
using System.Diagnostics;
using Microsoft.Agents.AI;
+using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -53,9 +54,16 @@ internal static IChatClient WithDefaultAgentMiddleware(this IChatClient chatClie
{
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/Compaction/ChatReducerCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs
new file mode 100644
index 0000000000..de1b7481d0
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs
@@ -0,0 +1,91 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// A compaction strategy that delegates to an to reduce the conversation's
+/// included messages.
+///
+///
+///
+/// This strategy bridges the abstraction from Microsoft.Extensions.AI
+/// into the compaction pipeline. It collects the currently included messages from the
+/// , passes them to the reducer, and rebuilds the index from the
+/// reduced message list when the reducer produces fewer messages.
+///
+///
+/// The controls when reduction is attempted.
+/// Use for common trigger conditions such as token or message thresholds.
+///
+///
+/// Use this strategy when you have an existing implementation
+/// (such as MessageCountingChatReducer) and want to apply it as part of a
+/// pipeline or as an in-run compaction strategy.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class ChatReducerCompactionStrategy : CompactionStrategy
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// The that performs the message reduction.
+ ///
+ ///
+ /// The that controls when compaction proceeds.
+ ///
+ ///
+ /// An optional target condition that controls when compaction stops. When ,
+ /// defaults to the inverse of the — compaction stops as soon as the trigger would no longer fire.
+ /// Note that the performs reduction in a single call, so the target is
+ /// not evaluated incrementally; it is available for composition with other strategies via
+ /// .
+ ///
+ public ChatReducerCompactionStrategy(IChatReducer chatReducer, CompactionTrigger trigger, CompactionTrigger? target = null)
+ : base(trigger, target)
+ {
+ this.ChatReducer = Throw.IfNull(chatReducer);
+ }
+
+ ///
+ /// Gets the chat reducer used to reduce messages.
+ ///
+ public IChatReducer ChatReducer { get; }
+
+ ///
+ protected override async Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
+ {
+ List includedMessages = [.. index.GetIncludedMessages()];
+ if (includedMessages.Count == 0)
+ {
+ return false;
+ }
+
+ IEnumerable reduced = await this.ChatReducer.ReduceAsync(includedMessages, cancellationToken).ConfigureAwait(false);
+ IList reducedMessages = reduced as IList ?? [.. reduced];
+
+ if (reducedMessages.Count >= includedMessages.Count)
+ {
+ return false;
+ }
+
+ // Rebuild the index from the reduced messages
+ MessageIndex rebuilt = MessageIndex.Create(reducedMessages, index.Tokenizer);
+ index.Groups.Clear();
+ foreach (MessageGroup group in rebuilt.Groups)
+ {
+ index.Groups.Add(group);
+ }
+
+ return true;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs
new file mode 100644
index 0000000000..29ac2e0994
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactingChatClient.cs
@@ -0,0 +1,141 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
+using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// 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 `FunctionInvokingChatClient` 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.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+internal sealed class CompactingChatClient : DelegatingChatClient
+{
+ private readonly CompactionStrategy _compactionStrategy;
+ private readonly ProviderSessionState _sessionState;
+
+ ///
+ /// 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, CompactionStrategy compactionStrategy)
+ : base(innerClient)
+ {
+ this._compactionStrategy = Throw.IfNull(compactionStrategy);
+ this._sessionState = new ProviderSessionState(
+ _ => new State(),
+ Convert.ToBase64String(BitConverter.GetBytes(compactionStrategy.GetHashCode())),
+ AgentJsonUtilities.DefaultOptions);
+ }
+
+ ///
+ public override async Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ IEnumerable 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)
+ {
+ IEnumerable compactedMessages = await this.ApplyCompactionAsync(messages, cancellationToken).ConfigureAwait(false);
+ await foreach (ChatResponseUpdate update in base.GetStreamingResponseAsync(compactedMessages, options, cancellationToken).ConfigureAwait(false))
+ {
+ yield return update;
+ }
+ }
+
+ ///
+ public override object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ Throw.IfNull(serviceType);
+
+ return
+ serviceKey is null && serviceType.IsInstanceOfType(typeof(CompactionStrategy)) ?
+ this._compactionStrategy :
+ base.GetService(serviceType, serviceKey);
+ }
+
+ private async Task> ApplyCompactionAsync(
+ IEnumerable messages, CancellationToken cancellationToken)
+ {
+ List messageList = messages as List ?? [.. messages]; // %%% TODO - LIST COPY
+
+ AgentRunContext? currentAgentContext = AIAgent.CurrentRunContext;
+ if (currentAgentContext is null ||
+ currentAgentContext.Session is null)
+ {
+ // No session available — no reason to compact
+ return messages;
+ }
+
+ State state = this._sessionState.GetOrInitializeState(currentAgentContext.Session);
+
+ MessageIndex messageIndex;
+ if (state.MessageIndex.Count > 0)
+ {
+ // Update existing index
+ messageIndex = new(state.MessageIndex);
+ messageIndex.Update(messageList);
+ }
+ else
+ {
+ // First pass — initialize message index state
+ messageIndex = MessageIndex.Create(messageList);
+ }
+
+ // Apply compaction
+ Stopwatch stopwatch = Stopwatch.StartNew();
+ bool wasCompacted = await this._compactionStrategy.CompactAsync(messageIndex, cancellationToken).ConfigureAwait(false);
+ stopwatch.Stop();
+
+ Debug.WriteLine($"COMPACTION: {wasCompacted} - {stopwatch.ElapsedMilliseconds}ms");
+
+ if (wasCompacted)
+ {
+ state.MessageIndex = [.. messageIndex.Groups]; // %%% TODO - LIST COPY
+ }
+
+ return wasCompacted ? messageIndex.GetIncludedMessages() : messageList;
+ }
+
+ ///
+ /// Represents the state of a stored in the .
+ ///
+ public sealed class State
+ {
+ ///
+ /// Gets or sets the message index.
+ ///
+ [JsonPropertyName("messages")]
+ public List MessageIndex { get; set; } = [];
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs
new file mode 100644
index 0000000000..489fb44e0a
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs
@@ -0,0 +1,130 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// Base class for strategies that compact 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).
+///
+///
+/// Every strategy requires a that determines whether compaction should
+/// proceed based on current metrics (token count, message count, turn count, etc.).
+/// The base class evaluates this trigger at the start of and skips compaction when
+/// the trigger returns .
+///
+///
+/// An optional target condition controls when compaction stops. Strategies incrementally exclude
+/// groups and re-evaluate the target after each exclusion, stopping as soon as the target returns
+/// . When no target is specified, it defaults to the inverse of the trigger —
+/// meaning compaction stops when the trigger condition would no longer fire.
+///
+///
+/// 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
+/// via .
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public abstract class CompactionStrategy
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// The that determines whether compaction should proceed.
+ ///
+ ///
+ /// An optional target condition that controls when compaction stops. Strategies re-evaluate
+ /// this predicate after each incremental exclusion and stop when it returns .
+ /// When , defaults to the inverse of the — compaction
+ /// stops as soon as the trigger condition would no longer fire.
+ ///
+ protected CompactionStrategy(CompactionTrigger trigger, CompactionTrigger? target = null)
+ {
+ this.Trigger = Throw.IfNull(trigger);
+ this.Target = target ?? (index => !trigger(index));
+ }
+
+ ///
+ /// Gets the trigger predicate that controls when compaction proceeds.
+ ///
+ protected CompactionTrigger Trigger { get; }
+
+ ///
+ /// Gets the target predicate that controls when compaction stops.
+ /// Strategies re-evaluate this after each incremental exclusion and stop when it returns .
+ ///
+ protected CompactionTrigger Target { get; }
+
+ ///
+ /// Evaluates the and, when it fires, delegates to
+ /// and reports compaction metrics.
+ ///
+ /// The message index 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.
+ public async Task CompactAsync(MessageIndex index, CancellationToken cancellationToken = default)
+ {
+ if (!this.Trigger(index))
+ {
+ return false;
+ }
+
+ int beforeTokens = index.IncludedTokenCount;
+ int beforeGroups = index.IncludedGroupCount;
+ int beforeMessages = index.IncludedMessageCount;
+
+ Stopwatch stopwatch = Stopwatch.StartNew();
+
+ bool compacted = await this.ApplyCompactionAsync(index, cancellationToken).ConfigureAwait(false);
+
+ stopwatch.Stop();
+
+ if (compacted)
+ {
+ Debug.WriteLine(
+ $"""
+ COMPACTION: {this.GetType().Name}
+ Duration {stopwatch.ElapsedMilliseconds}ms
+ Messages {beforeMessages} => {index.IncludedMessageCount}
+ Groups {beforeGroups} => {index.IncludedGroupCount}
+ Tokens {beforeTokens} => {index.IncludedTokenCount}
+ """);
+ }
+
+ return compacted;
+ }
+
+ ///
+ /// Applies the strategy-specific compaction logic to the specified message index.
+ ///
+ ///
+ /// This method is called by only when the
+ /// returns . Implementations do not need to evaluate the trigger or
+ /// report metrics — the base class handles both. Implementations should use
+ /// to determine when to stop compacting incrementally.
+ ///
+ /// The message index to compact. The strategy mutates this collection in place.
+ /// The to monitor for cancellation requests.
+ /// A task whose result is if any compaction was performed, otherwise.
+ protected abstract Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs
new file mode 100644
index 0000000000..4803c6a6fa
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs
@@ -0,0 +1,14 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// A predicate that evaluates whether compaction should proceed based on current metrics.
+///
+/// The current message index with group, token, message, and turn metrics.
+/// if compaction should proceed; to skip.
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public delegate bool CompactionTrigger(MessageIndex index);
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs
new file mode 100644
index 0000000000..53d3782a5f
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs
@@ -0,0 +1,117 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// Provides factory methods for common predicates.
+///
+///
+/// These triggers evaluate included (non-excluded) metrics from the .
+/// Combine triggers with or for compound conditions,
+/// or write a custom lambda for full flexibility.
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public static class CompactionTriggers
+{
+ ///
+ /// Always trigger compaction, regardless of the message index state.
+ ///
+ public static readonly CompactionTrigger Always =
+ _ => true;
+
+ ///
+ /// Always trigger compaction, regardless of the message index state.
+ ///
+ public static readonly CompactionTrigger Never =
+ _ => false;
+
+ ///
+ /// Creates a trigger that fires when the included token count is below the specified maximum.
+ ///
+ /// The token threshold. Compaction proceeds when included tokens exceed this value.
+ /// A that evaluates included token count.
+ public static CompactionTrigger TokensBelow(int maxTokens) =>
+ index => index.IncludedTokenCount < maxTokens;
+
+ ///
+ /// Creates a trigger that fires when the included token count exceeds the specified maximum.
+ ///
+ /// The token threshold. Compaction proceeds when included tokens exceed this value.
+ /// A that evaluates included token count.
+ public static CompactionTrigger TokensExceed(int maxTokens) =>
+ index => index.IncludedTokenCount > maxTokens;
+
+ ///
+ /// Creates a trigger that fires when the included message count exceeds the specified maximum.
+ ///
+ /// The message threshold. Compaction proceeds when included messages exceed this value.
+ /// A that evaluates included message count.
+ public static CompactionTrigger MessagesExceed(int maxMessages) =>
+ index => index.IncludedMessageCount > maxMessages;
+
+ ///
+ /// Creates a trigger that fires when the included user turn count exceeds the specified maximum.
+ ///
+ /// The turn threshold. Compaction proceeds when included turns exceed this value.
+ /// A that evaluates included turn count.
+ public static CompactionTrigger TurnsExceed(int maxTurns) =>
+ index => index.IncludedTurnCount > maxTurns;
+
+ ///
+ /// Creates a trigger that fires when the included group count exceeds the specified maximum.
+ ///
+ /// The group threshold. Compaction proceeds when included groups exceed this value.
+ /// A that evaluates included group count.
+ public static CompactionTrigger GroupsExceed(int maxGroups) =>
+ index => index.IncludedGroupCount > maxGroups;
+
+ ///
+ /// Creates a trigger that fires when the included message index contains at least one
+ /// non-excluded group.
+ ///
+ /// A that evaluates included tool call presence.
+ public static CompactionTrigger HasToolCalls() =>
+ index => index.Groups.Any(g => !g.IsExcluded && g.Kind == MessageGroupKind.ToolCall);
+
+ ///
+ /// Creates a compound trigger that fires only when all of the specified triggers fire.
+ ///
+ /// The triggers to combine with logical AND.
+ /// A that requires all conditions to be met.
+ public static CompactionTrigger All(params CompactionTrigger[] triggers) =>
+ index =>
+ {
+ for (int i = 0; i < triggers.Length; i++)
+ {
+ if (!triggers[i](index))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ };
+
+ ///
+ /// Creates a compound trigger that fires when any of the specified triggers fire.
+ ///
+ /// The triggers to combine with logical OR.
+ /// A that requires at least one condition to be met.
+ public static CompactionTrigger Any(params CompactionTrigger[] triggers) =>
+ index =>
+ {
+ for (int i = 0; i < triggers.Length; i++)
+ {
+ if (triggers[i](index))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ };
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs
new file mode 100644
index 0000000000..df1098c7da
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs
@@ -0,0 +1,113 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.DiagnosticIds;
+
+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.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+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).
+ ///
+ [JsonConstructor]
+ 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/Compaction/MessageGroupKind.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs
new file mode 100644
index 0000000000..74018d067b
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroupKind.cs
@@ -0,0 +1,55 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Shared.DiagnosticIds;
+
+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.
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+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,
+
+#pragma warning disable IDE0001 // Simplify Names
+ ///
+ /// 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 .
+ ///
+#pragma warning restore IDE0001 // Simplify Names
+ Summary,
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs
new file mode 100644
index 0000000000..3de2efa390
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs
@@ -0,0 +1,376 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Text;
+using Microsoft.Extensions.AI;
+using Microsoft.ML.Tokenizers;
+using Microsoft.Shared.DiagnosticIds;
+
+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.
+///
+///
+/// Instances created via track internal state that enables efficient incremental
+/// updates via . This allows caching a instance and
+/// appending only new messages without reprocessing the entire history.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class MessageIndex
+{
+ private int _currentTurn;
+
+ ///
+ /// 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; }
+
+ ///
+ /// Gets the number of raw messages that have been processed into groups.
+ ///
+ ///
+ /// This value is set by and updated by .
+ /// It is used by to determine which messages are new and need processing.
+ ///
+ public int ProcessedMessageCount { get; private set; }
+
+ ///
+ /// 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 MessageIndex(IList groups, Tokenizer? tokenizer = null)
+ {
+ this.Groups = groups;
+ this.Tokenizer = tokenizer;
+
+ // Restore turn counter from the last group that has a TurnIndex
+ for (int index = groups.Count - 1; index >= 0; --index)
+ {
+ if (this.Groups[index].TurnIndex.HasValue)
+ {
+ this._currentTurn = this.Groups[index].TurnIndex!.Value;
+ break;
+ }
+ }
+ }
+
+ ///
+ /// 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 MessageIndex Create(IList messages, Tokenizer? tokenizer = null)
+ {
+ Debug.WriteLine("COMPACTION: Creating index x{messages.Count} messages");
+ MessageIndex instance = new([], tokenizer);
+ instance.AppendFromMessages(messages, 0);
+ return instance;
+ }
+
+ ///
+ /// Incrementally updates the groups with new messages from the conversation.
+ ///
+ ///
+ /// The full list of messages for the conversation. This must be the same list (or a replacement with the same
+ /// prefix) that was used to create or last update this instance.
+ ///
+ ///
+ ///
+ /// If the message count exceeds , only the new (delta) messages
+ /// are processed and appended as new groups. Existing groups and their compaction state (exclusions)
+ /// are preserved, allowing compaction strategies to build on previous results.
+ ///
+ ///
+ /// If the message count is less than (e.g., after storage compaction
+ /// replaced messages with summaries), all groups are cleared and rebuilt from scratch.
+ ///
+ ///
+ /// If the message count equals , no work is performed.
+ ///
+ ///
+ public void Update(IList allMessages)
+ {
+ if (allMessages.Count == this.ProcessedMessageCount)
+ {
+ return; // No new messages
+ }
+
+ if (allMessages.Count < this.ProcessedMessageCount)
+ {
+ // Message list shrank (e.g., after storage compaction). Rebuild from scratch.
+ this.ProcessedMessageCount = 0;
+ }
+
+ if (this.ProcessedMessageCount == 0)
+ {
+ // First update on a manually constructed instance — clear any pre-existing groups
+ this.Groups.Clear();
+ this._currentTurn = 0;
+ }
+
+ // Process only the delta messages
+ this.AppendFromMessages(allMessages, this.ProcessedMessageCount);
+ }
+
+ private void AppendFromMessages(IList messages, int startIndex)
+ {
+ int index = startIndex;
+
+ while (index < messages.Count)
+ {
+ ChatMessage message = messages[index];
+
+ if (message.Role == ChatRole.System)
+ {
+ // System messages are not part of any turn
+ this.Groups.Add(CreateGroup(MessageGroupKind.System, [message], this.Tokenizer, turnIndex: null));
+ index++;
+ }
+ else if (message.Role == ChatRole.User)
+ {
+ this._currentTurn++;
+ this.Groups.Add(CreateGroup(MessageGroupKind.User, [message], this.Tokenizer, this._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++;
+ }
+
+ this.Groups.Add(CreateGroup(MessageGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
+ }
+ else if (message.Role == ChatRole.Assistant && IsSummaryMessage(message))
+ {
+ this.Groups.Add(CreateGroup(MessageGroupKind.Summary, [message], this.Tokenizer, this._currentTurn));
+ index++;
+ }
+ else
+ {
+ this.Groups.Add(CreateGroup(MessageGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
+ index++;
+ }
+ }
+
+ this.ProcessedMessageCount = messages.Count;
+ }
+
+ ///
+ /// 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);
+
+ ///
+ /// 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);
+
+ ///
+ /// 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);
+
+ ///
+ /// 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);
+
+ ///
+ /// 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)
+ {
+ 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/Compaction/PipelineCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs
new file mode 100644
index 0000000000..fa23239156
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs
@@ -0,0 +1,62 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Shared.DiagnosticIds;
+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.
+///
+///
+/// The pipeline's own is evaluated first. If it returns
+/// , none of the child strategies are executed. Each child strategy also
+/// evaluates its own trigger independently.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class PipelineCompactionStrategy : CompactionStrategy
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The ordered sequence of strategies to execute.
+ public PipelineCompactionStrategy(params IEnumerable strategies)
+ : base(CompactionTriggers.Always)
+ {
+ this.Strategies = [.. Throw.IfNull(strategies)];
+ }
+
+ ///
+ /// Gets the ordered list of strategies in this pipeline.
+ ///
+ public IReadOnlyList Strategies { get; }
+
+ ///
+ protected override async Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
+ {
+ bool anyCompacted = false;
+
+ foreach (CompactionStrategy strategy in this.Strategies)
+ {
+ bool compacted = await strategy.CompactAsync(index, cancellationToken).ConfigureAwait(false);
+
+ if (compacted)
+ {
+ anyCompacted = true;
+ }
+ }
+
+ return anyCompacted;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs
new file mode 100644
index 0000000000..11effccaa4
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs
@@ -0,0 +1,133 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// A compaction strategy that removes the oldest user turns and their associated response groups
+/// to bound conversation length.
+///
+///
+///
+/// This strategy always preserves system messages. It identifies user turns in the
+/// conversation (via ) and excludes the oldest turns
+/// one at a time until the condition is met.
+///
+///
+/// is a hard floor: even if the
+/// has not been reached, compaction will not touch the last non-system groups.
+///
+///
+/// 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.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class SlidingWindowCompactionStrategy : CompactionStrategy
+{
+ ///
+ /// The default minimum number of most-recent non-system groups to preserve.
+ ///
+ public const int DefaultMinimumPreserved = 1;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// The that controls when compaction proceeds.
+ /// Use for turn-based thresholds.
+ ///
+ ///
+ /// The minimum number of most-recent non-system message groups to preserve.
+ /// This is a hard floor — compaction will not exclude groups beyond this limit,
+ /// regardless of the target condition.
+ ///
+ ///
+ /// An optional target condition that controls when compaction stops. When ,
+ /// defaults to the inverse of the — compaction stops as soon as the trigger would no longer fire.
+ ///
+ public SlidingWindowCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null)
+ : base(trigger, target)
+ {
+ this.MinimumPreserved = minimumPreserved;
+ }
+
+ ///
+ /// Gets the minimum number of most-recent non-system groups that are always preserved.
+ /// This is a hard floor that compaction cannot exceed, regardless of the target condition.
+ ///
+ public int MinimumPreserved { get; }
+
+ ///
+ protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
+ {
+ // Identify protected groups: the N most-recent non-system, non-excluded groups
+ List nonSystemIncludedIndices = [];
+ foreach (MessageGroup group in index.Groups)
+ {
+ if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
+ {
+ nonSystemIncludedIndices.Add(index.Groups.IndexOf(group));
+ }
+ }
+
+ int protectedStart = Math.Max(0, nonSystemIncludedIndices.Count - this.MinimumPreserved);
+ HashSet protectedGroupIndices = [];
+ for (int i = protectedStart; i < nonSystemIncludedIndices.Count; i++)
+ {
+ protectedGroupIndices.Add(nonSystemIncludedIndices[i]);
+ }
+
+ // Collect distinct included turn indices in order (oldest first), excluding protected groups
+ List excludableTurns = [];
+ for (int i = 0; i < index.Groups.Count; i++)
+ {
+ MessageGroup group = index.Groups[i];
+ if (!group.IsExcluded
+ && group.Kind != MessageGroupKind.System
+ && !protectedGroupIndices.Contains(i)
+ && group.TurnIndex is int turnIndex
+ && !excludableTurns.Contains(turnIndex))
+ {
+ excludableTurns.Add(turnIndex);
+ }
+ }
+
+ // Exclude one turn at a time from oldest, re-checking target after each
+ bool compacted = false;
+
+ for (int t = 0; t < excludableTurns.Count; t++)
+ {
+ int turnToExclude = excludableTurns[t];
+
+ for (int i = 0; i < index.Groups.Count; i++)
+ {
+ MessageGroup group = index.Groups[i];
+ if (!group.IsExcluded
+ && group.Kind != MessageGroupKind.System
+ && !protectedGroupIndices.Contains(i)
+ && group.TurnIndex == turnToExclude)
+ {
+ group.IsExcluded = true;
+ group.ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}";
+ }
+ }
+
+ compacted = true;
+
+ // Stop when target condition is met
+ if (this.Target(index))
+ {
+ break;
+ }
+ }
+
+ return Task.FromResult(compacted);
+ }
+}
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..d87a9e63a7
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs
@@ -0,0 +1,185 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.DiagnosticIds;
+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 single summary message that preserves key facts and context.
+///
+///
+///
+/// This strategy protects system messages and the most recent
+/// 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
+/// with .
+///
+///
+/// is a hard floor: even if the
+/// has not been reached, compaction will not touch the last non-system groups.
+///
+///
+/// The predicate controls when compaction proceeds.
+/// When , the strategy compacts whenever there are groups older than the preserve window.
+/// Use for common trigger conditions such as token thresholds.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class SummarizationCompactionStrategy : CompactionStrategy
+{
+ ///
+ /// 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 that controls when compaction proceeds.
+ ///
+ ///
+ /// The minimum number of most-recent non-system message groups to preserve.
+ /// This is a hard floor — compaction will not summarize groups beyond this limit,
+ /// regardless of the target condition. Defaults to 4, preserving the current and recent exchanges.
+ ///
+ ///
+ /// An optional custom system prompt for the summarization LLM call. When ,
+ /// is used.
+ ///
+ ///
+ /// An optional target condition that controls when compaction stops. When ,
+ /// defaults to the inverse of the — compaction stops as soon as the trigger would no longer fire.
+ ///
+ public SummarizationCompactionStrategy(
+ IChatClient chatClient,
+ CompactionTrigger trigger,
+ int minimumPreserved = 4,
+ string? summarizationPrompt = null,
+ CompactionTrigger? target = null)
+ : base(trigger, target)
+ {
+ this.ChatClient = Throw.IfNull(chatClient);
+ this.MinimumPreserved = minimumPreserved;
+ this.SummarizationPrompt = summarizationPrompt ?? DefaultSummarizationPrompt;
+ }
+
+ ///
+ /// Gets the chat client used for generating summaries.
+ ///
+ public IChatClient ChatClient { get; }
+
+ ///
+ /// Gets the minimum number of most-recent non-system groups that are always preserved.
+ /// This is a hard floor that compaction cannot exceed, regardless of the target condition.
+ ///
+ public int MinimumPreserved { get; }
+
+ ///
+ /// Gets the prompt used when requesting summaries from the chat client.
+ ///
+ public string SummarizationPrompt { get; }
+
+ ///
+ protected override async Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
+ {
+ // Count non-system, non-excluded groups to determine which are protected
+ int nonSystemIncludedCount = 0;
+ for (int i = 0; i < index.Groups.Count; i++)
+ {
+ MessageGroup group = index.Groups[i];
+ if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
+ {
+ nonSystemIncludedCount++;
+ }
+ }
+
+ int protectedFromEnd = Math.Min(this.MinimumPreserved, nonSystemIncludedCount);
+ int maxSummarizable = nonSystemIncludedCount - protectedFromEnd;
+
+ if (maxSummarizable <= 0)
+ {
+ return false;
+ }
+
+ // Mark oldest non-system groups for summarization one at a time until the target is met
+ StringBuilder conversationText = new();
+ int summarized = 0;
+ int insertIndex = -1;
+
+ for (int i = 0; i < index.Groups.Count && summarized < maxSummarizable; i++)
+ {
+ MessageGroup group = index.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 {nameof(SummarizationCompactionStrategy)}";
+ summarized++;
+
+ // Stop marking when target condition is met
+ if (this.Target(index))
+ {
+ break;
+ }
+ }
+
+ // Generate summary using the chat client (single LLM call for all marked groups)
+ ChatResponse response = await this.ChatClient.GetResponseAsync(
+ [
+ new ChatMessage(ChatRole.System, this.SummarizationPrompt),
+ .. index.Groups
+ .Where(g => !g.IsExcluded && g.Kind == MessageGroupKind.System)
+ .SelectMany(g => g.Messages),
+ new ChatMessage(ChatRole.User, conversationText.ToString()),
+ new ChatMessage(ChatRole.User, "Summarize the conversation above concisely."),
+ ],
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ string summaryText = string.IsNullOrWhiteSpace(response.Text) ? "[Summary unavailable]" : response.Text;
+
+ // Insert a summary group at the position of the first summarized group
+ ChatMessage summaryMessage = new(ChatRole.Assistant, $"[Summary]\n{summaryText}");
+ (summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true;
+
+ index.InsertGroup(insertIndex, MessageGroupKind.Summary, [summaryMessage]);
+
+ return true;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs
new file mode 100644
index 0000000000..91737394ab
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs
@@ -0,0 +1,153 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// A compaction strategy that collapses old 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
+/// groups 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].
+///
+///
+/// is a hard floor: even if the
+/// has not been reached, compaction will not touch the last non-system groups.
+///
+///
+/// The predicate controls when compaction proceeds.
+/// When , a default compound trigger of
+/// AND
+/// is used.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class ToolResultCompactionStrategy : CompactionStrategy
+{
+ ///
+ /// The default minimum number of most-recent non-system groups to preserve.
+ ///
+ public const int DefaultMinimumPreserved = 2;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// The that controls when compaction proceeds.
+ ///
+ ///
+ /// The minimum number of most-recent non-system message groups to preserve.
+ /// This is a hard floor — compaction will not collapse groups beyond this limit,
+ /// regardless of the target condition.
+ /// Defaults to , ensuring the current turn's tool interactions remain visible.
+ ///
+ ///
+ /// An optional target condition that controls when compaction stops. When ,
+ /// defaults to the inverse of the — compaction stops as soon as the trigger would no longer fire.
+ ///
+ public ToolResultCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null)
+ : base(trigger, target)
+ {
+ this.MinimumPreserved = minimumPreserved;
+ }
+
+ ///
+ /// Gets the minimum number of most-recent non-system groups that are always preserved.
+ /// This is a hard floor that compaction cannot exceed, regardless of the target condition.
+ ///
+ public int MinimumPreserved { get; }
+
+ ///
+ protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
+ {
+ // Identify protected groups: the N most-recent non-system, non-excluded groups
+ List nonSystemIncludedIndices = [];
+ for (int i = 0; i < index.Groups.Count; i++)
+ {
+ MessageGroup group = index.Groups[i];
+ if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
+ {
+ nonSystemIncludedIndices.Add(i);
+ }
+ }
+
+ int protectedStart = Math.Max(0, nonSystemIncludedIndices.Count - this.MinimumPreserved);
+ HashSet protectedGroupIndices = [];
+ for (int i = protectedStart; i < nonSystemIncludedIndices.Count; i++)
+ {
+ protectedGroupIndices.Add(nonSystemIncludedIndices[i]);
+ }
+
+ // Collect eligible tool groups in order (oldest first)
+ List eligibleIndices = [];
+ for (int i = 0; i < index.Groups.Count; i++)
+ {
+ MessageGroup group = index.Groups[i];
+ if (!group.IsExcluded && group.Kind == MessageGroupKind.ToolCall && !protectedGroupIndices.Contains(i))
+ {
+ eligibleIndices.Add(i);
+ }
+ }
+
+ if (eligibleIndices.Count == 0)
+ {
+ return Task.FromResult(false);
+ }
+
+ // Collapse one tool group at a time from oldest, re-checking target after each
+ bool compacted = false;
+ int offset = 0;
+
+ for (int e = 0; e < eligibleIndices.Count; e++)
+ {
+ int idx = eligibleIndices[e] + offset;
+ MessageGroup group = index.Groups[idx];
+
+ // Extract tool names from FunctionCallContent
+ List toolNames = [];
+ foreach (ChatMessage message in group.Messages)
+ {
+ if (message.Contents is not null)
+ {
+ foreach (AIContent content in message.Contents)
+ {
+ if (content is FunctionCallContent fcc)
+ {
+ toolNames.Add(fcc.Name);
+ }
+ }
+ }
+ }
+
+ // Exclude the original group and insert a collapsed replacement
+ group.IsExcluded = true;
+ group.ExcludeReason = $"Collapsed by {nameof(ToolResultCompactionStrategy)}";
+
+ string summary = $"[Tool calls: {string.Join(", ", toolNames)}]";
+ index.InsertGroup(idx + 1, MessageGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, summary)], group.TurnIndex);
+ offset++; // Each insertion shifts subsequent indices by 1
+
+ compacted = true;
+
+ // Stop when target condition is met
+ if (this.Target(index))
+ {
+ break;
+ }
+ }
+
+ return Task.FromResult(compacted);
+ }
+}
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..46960561fc
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs
@@ -0,0 +1,109 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// A compaction strategy that removes the oldest non-system message groups,
+/// keeping at least most-recent groups intact.
+///
+///
+///
+/// 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.
+///
+///
+/// is a hard floor: even if the
+/// has not been reached, compaction will not touch the last non-system groups.
+///
+///
+/// The controls when compaction proceeds.
+/// Use for common trigger conditions such as token or group thresholds.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class TruncationCompactionStrategy : CompactionStrategy
+{
+ ///
+ /// The default minimum number of most-recent non-system groups to preserve.
+ ///
+ public const int DefaultMinimumPreserved = 32;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// The that controls when compaction proceeds.
+ ///
+ ///
+ /// The minimum number of most-recent non-system message groups to preserve.
+ /// This is a hard floor — compaction will not remove groups beyond this limit,
+ /// regardless of the target condition.
+ ///
+ ///
+ /// An optional target condition that controls when compaction stops. When ,
+ /// defaults to the inverse of the — compaction stops as soon as the trigger would no longer fire.
+ ///
+ public TruncationCompactionStrategy(CompactionTrigger trigger, int minimumPreserved = DefaultMinimumPreserved, CompactionTrigger? target = null)
+ : base(trigger, target)
+ {
+ this.MinimumPreserved = minimumPreserved;
+ }
+
+ ///
+ /// Gets the minimum number of most-recent non-system message groups that are always preserved.
+ /// This is a hard floor that compaction cannot exceed, regardless of the target condition.
+ ///
+ public int MinimumPreserved { get; }
+
+ ///
+ protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
+ {
+ // Count removable (non-system, non-excluded) groups
+ int removableCount = 0;
+ for (int i = 0; i < index.Groups.Count; i++)
+ {
+ MessageGroup group = index.Groups[i];
+ if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
+ {
+ removableCount++;
+ }
+ }
+
+ int maxRemovable = removableCount - this.MinimumPreserved;
+ if (maxRemovable <= 0)
+ {
+ return Task.FromResult(false);
+ }
+
+ // Exclude oldest non-system groups one at a time, re-checking target after each
+ bool compacted = false;
+ int removed = 0;
+ for (int i = 0; i < index.Groups.Count && removed < maxRemovable; i++)
+ {
+ MessageGroup group = index.Groups[i];
+ if (group.IsExcluded || group.Kind == MessageGroupKind.System)
+ {
+ continue;
+ }
+
+ group.IsExcluded = true;
+ group.ExcludeReason = $"Truncated by {nameof(TruncationCompactionStrategy)}";
+ removed++;
+ compacted = true;
+
+ // Stop when target condition is met
+ if (this.Target(index))
+ {
+ break;
+ }
+ }
+
+ return Task.FromResult(compacted);
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj
index f036812900..93b228d29e 100644
--- a/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj
+++ b/dotnet/src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj
@@ -18,10 +18,14 @@
+
+
+
+
@@ -36,7 +40,7 @@
-
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs
new file mode 100644
index 0000000000..6d4437d97a
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatReducerCompactionStrategyTests.cs
@@ -0,0 +1,255 @@
+// 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.Compaction;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.UnitTests.Compaction;
+
+///
+/// Contains tests for the class.
+///
+public class ChatReducerCompactionStrategyTests
+{
+ [Fact]
+ public void ConstructorNullReducerThrows()
+ {
+ // Act & Assert
+ Assert.Throws(() => new ChatReducerCompactionStrategy(null!, CompactionTriggers.Always));
+ }
+
+ [Fact]
+ public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
+ {
+ // Arrange — trigger never fires
+ TestChatReducer reducer = new(messages => messages.Take(1));
+ ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Never);
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Hello"),
+ new ChatMessage(ChatRole.Assistant, "Hi!"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert
+ Assert.False(result);
+ Assert.Equal(0, reducer.CallCount);
+ Assert.Equal(2, index.IncludedGroupCount);
+ }
+
+ [Fact]
+ public async Task CompactAsyncReducerReturnsFewerMessagesRebuildsIndexAsync()
+ {
+ // Arrange — reducer keeps only the last message
+ TestChatReducer reducer = new(messages => messages.Skip(messages.Count() - 1));
+ ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "First"),
+ new ChatMessage(ChatRole.Assistant, "Response 1"),
+ new ChatMessage(ChatRole.User, "Second"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert
+ Assert.True(result);
+ Assert.Equal(1, reducer.CallCount);
+ Assert.Equal(1, index.IncludedGroupCount);
+ Assert.Equal("Second", index.Groups[0].Messages[0].Text);
+ }
+
+ [Fact]
+ public async Task CompactAsyncReducerReturnsSameCountReturnsFalseAsync()
+ {
+ // Arrange — reducer returns all messages (no reduction)
+ TestChatReducer reducer = new(messages => messages);
+ ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Hello"),
+ new ChatMessage(ChatRole.Assistant, "Hi!"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert
+ Assert.False(result);
+ Assert.Equal(1, reducer.CallCount);
+ Assert.Equal(2, index.IncludedGroupCount);
+ }
+
+ [Fact]
+ public async Task CompactAsyncEmptyIndexReturnsFalseAsync()
+ {
+ // Arrange — no included messages
+ TestChatReducer reducer = new(messages => messages);
+ ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
+ MessageIndex index = MessageIndex.Create([]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert
+ Assert.False(result);
+ Assert.Equal(0, reducer.CallCount);
+ }
+
+ [Fact]
+ public async Task CompactAsyncPreservesSystemMessagesWhenReducerKeepsThemAsync()
+ {
+ // Arrange — reducer keeps system + last user message
+ TestChatReducer reducer = new(messages =>
+ {
+ var nonSystem = messages.Where(m => m.Role != ChatRole.System).ToList();
+ return messages.Where(m => m.Role == ChatRole.System)
+ .Concat(nonSystem.Skip(nonSystem.Count - 1));
+ });
+
+ ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.System, "You are helpful."),
+ new ChatMessage(ChatRole.User, "First"),
+ new ChatMessage(ChatRole.Assistant, "Response 1"),
+ new ChatMessage(ChatRole.User, "Second"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert
+ Assert.True(result);
+ Assert.Equal(2, index.IncludedGroupCount);
+ Assert.Equal(MessageGroupKind.System, index.Groups[0].Kind);
+ Assert.Equal("You are helpful.", index.Groups[0].Messages[0].Text);
+ Assert.Equal(MessageGroupKind.User, index.Groups[1].Kind);
+ Assert.Equal("Second", index.Groups[1].Messages[0].Text);
+ }
+
+ [Fact]
+ public async Task CompactAsyncRebuildsToolCallGroupsCorrectlyAsync()
+ {
+ // Arrange — reducer keeps last 3 messages (assistant tool call + tool result + user)
+ TestChatReducer reducer = new(messages => messages.Skip(messages.Count() - 3));
+
+ ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
+ ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
+
+ ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Old question"),
+ new ChatMessage(ChatRole.Assistant, "Old answer"),
+ assistantToolCall,
+ toolResult,
+ new ChatMessage(ChatRole.User, "New question"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert
+ Assert.True(result);
+ // Should have 2 groups: ToolCall group (assistant + tool result) + User group
+ Assert.Equal(2, index.IncludedGroupCount);
+ Assert.Equal(MessageGroupKind.ToolCall, index.Groups[0].Kind);
+ Assert.Equal(2, index.Groups[0].Messages.Count);
+ Assert.Equal(MessageGroupKind.User, index.Groups[1].Kind);
+ }
+
+ [Fact]
+ public async Task CompactAsyncSkipsAlreadyExcludedGroupsAsync()
+ {
+ // Arrange — one group is pre-excluded, reducer keeps last message
+ TestChatReducer reducer = new(messages => messages.Skip(messages.Count() - 1));
+ ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Excluded"),
+ new ChatMessage(ChatRole.User, "Included 1"),
+ new ChatMessage(ChatRole.User, "Included 2"),
+ ]);
+ index.Groups[0].IsExcluded = true;
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert — reducer only saw 2 included messages, kept 1
+ Assert.True(result);
+ Assert.Equal(1, index.IncludedGroupCount);
+ Assert.Equal("Included 2", index.Groups[0].Messages[0].Text);
+ }
+
+ [Fact]
+ public async Task CompactAsyncExposesReducerPropertyAsync()
+ {
+ // Arrange
+ TestChatReducer reducer = new(messages => messages);
+ ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
+
+ // Assert
+ Assert.Same(reducer, strategy.ChatReducer);
+ await Task.CompletedTask;
+ }
+
+ [Fact]
+ public async Task CompactAsyncPassesCancellationTokenToReducerAsync()
+ {
+ // Arrange
+ using CancellationTokenSource cts = new();
+ CancellationToken capturedToken = default;
+ TestChatReducer reducer = new((messages, ct) =>
+ {
+ capturedToken = ct;
+ return Task.FromResult>(messages.Skip(messages.Count() - 1).ToList());
+ });
+
+ ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "First"),
+ new ChatMessage(ChatRole.User, "Second"),
+ ]);
+
+ // Act
+ await strategy.CompactAsync(index, cts.Token);
+
+ // Assert
+ Assert.Equal(cts.Token, capturedToken);
+ }
+
+ ///
+ /// A test implementation of that applies a configurable reduction function.
+ ///
+ private sealed class TestChatReducer : IChatReducer
+ {
+ private readonly Func, CancellationToken, Task>> _reduceFunc;
+
+ public TestChatReducer(Func, IEnumerable> reduceFunc)
+ {
+ this._reduceFunc = (messages, _) => Task.FromResult(reduceFunc(messages));
+ }
+
+ public TestChatReducer(Func, CancellationToken, Task>> reduceFunc)
+ {
+ this._reduceFunc = reduceFunc;
+ }
+
+ public int CallCount { get; private set; }
+
+ public async Task> ReduceAsync(IEnumerable messages, CancellationToken cancellationToken = default)
+ {
+ this.CallCount++;
+ return await this._reduceFunc(messages, cancellationToken).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs
new file mode 100644
index 0000000000..ce3bf167e5
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactingChatClientTests.cs
@@ -0,0 +1,399 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Compaction;
+using Microsoft.Extensions.AI;
+using Moq;
+
+namespace Microsoft.Agents.AI.UnitTests.Compaction;
+
+///
+/// Contains tests for the class.
+///
+public sealed class CompactingChatClientTests : IDisposable
+{
+ ///
+ /// Restores the static after each test.
+ ///
+ public void Dispose()
+ {
+ SetCurrentRunContext(null);
+ }
+
+ [Fact]
+ public void ConstructorThrowsOnNullStrategy()
+ {
+ Mock mockInner = new();
+ Assert.Throws(() => new CompactingChatClient(mockInner.Object, null!));
+ }
+
+ [Fact]
+ public async Task GetResponseAsyncNoContextPassesThroughAsync()
+ {
+ // Arrange — no CurrentRunContext set → passthrough
+ ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]);
+ Mock mockInner = new();
+ mockInner.Setup(c => c.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(expectedResponse);
+
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
+ CompactingChatClient client = new(mockInner.Object, strategy);
+
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Hello"),
+ ];
+
+ // Act
+ ChatResponse response = await client.GetResponseAsync(messages);
+
+ // Assert
+ Assert.Same(expectedResponse, response);
+ mockInner.Verify(c => c.GetResponseAsync(
+ messages,
+ It.IsAny(),
+ It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task GetResponseAsyncWithContextAppliesCompactionAsync()
+ {
+ // Arrange — set CurrentRunContext so compaction runs
+ ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Done")]);
+ List? capturedMessages = null;
+ Mock mockInner = new();
+ mockInner.Setup(c => c.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback, ChatOptions?, CancellationToken>((msgs, _, _) =>
+ capturedMessages = [.. msgs])
+ .ReturnsAsync(expectedResponse);
+
+ // Strategy that always triggers and keeps only 1 group
+ TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1);
+ CompactingChatClient client = new(mockInner.Object, strategy);
+
+ TestAgentSession session = new();
+ SetRunContext(session);
+
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ];
+
+ // Act
+ ChatResponse response = await client.GetResponseAsync(messages);
+
+ // Assert — compaction should have removed oldest groups
+ Assert.Same(expectedResponse, response);
+ Assert.NotNull(capturedMessages);
+ Assert.True(capturedMessages!.Count < messages.Count);
+ }
+
+ [Fact]
+ public async Task GetResponseAsyncNoCompactionNeededReturnsOriginalMessagesAsync()
+ {
+ // Arrange — trigger never fires → no compaction
+ ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]);
+ List? capturedMessages = null;
+ Mock mockInner = new();
+ mockInner.Setup(c => c.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback, ChatOptions?, CancellationToken>((msgs, _, _) =>
+ capturedMessages = [.. msgs])
+ .ReturnsAsync(expectedResponse);
+
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
+ CompactingChatClient client = new(mockInner.Object, strategy);
+
+ TestAgentSession session = new();
+ SetRunContext(session);
+
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Hello"),
+ ];
+
+ // Act
+ await client.GetResponseAsync(messages);
+
+ // Assert — original messages passed through
+ Assert.NotNull(capturedMessages);
+ Assert.Single(capturedMessages!);
+ Assert.Equal("Hello", capturedMessages[0].Text);
+ }
+
+ [Fact]
+ public async Task GetResponseAsyncWithExistingIndexUpdatesAsync()
+ {
+ // Arrange — call twice to exercise the "existing index" path (state.MessageIndex.Count > 0)
+ Mock mockInner = new();
+ mockInner.Setup(c => c.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "OK")]));
+
+ // Strategy that always triggers, keeping 1 group
+ TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1);
+ CompactingChatClient client = new(mockInner.Object, strategy);
+
+ TestAgentSession session = new();
+ SetRunContext(session);
+
+ List messages1 =
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ];
+
+ // First call — initializes state
+ await client.GetResponseAsync(messages1);
+
+ List messages2 =
+ [
+ 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 — second call exercises the update path
+ ChatResponse response = await client.GetResponseAsync(messages2);
+
+ // Assert
+ Assert.NotNull(response);
+ }
+
+ [Fact]
+ public async Task GetResponseAsyncNullSessionReturnsOriginalAsync()
+ {
+ // Arrange — CurrentRunContext exists but Session is null
+ ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]);
+ Mock mockInner = new();
+ mockInner.Setup(c => c.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(expectedResponse);
+
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
+ CompactingChatClient client = new(mockInner.Object, strategy);
+
+ // Set context with null session
+ SetRunContext(null);
+
+ List messages = [new ChatMessage(ChatRole.User, "Hello")];
+
+ // Act
+ ChatResponse response = await client.GetResponseAsync(messages);
+
+ // Assert
+ Assert.Same(expectedResponse, response);
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsyncNoContextPassesThroughAsync()
+ {
+ // Arrange — no CurrentRunContext
+ Mock mockInner = new();
+ ChatResponseUpdate[] updates = [new(ChatRole.Assistant, "Hi")];
+ mockInner.Setup(c => c.GetStreamingResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(ToAsyncEnumerableAsync(updates));
+
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
+ CompactingChatClient client = new(mockInner.Object, strategy);
+
+ List messages = [new ChatMessage(ChatRole.User, "Hello")];
+
+ // Act
+ List results = [];
+ await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(messages))
+ {
+ results.Add(update);
+ }
+
+ // Assert
+ Assert.Single(results);
+ Assert.Equal("Hi", results[0].Text);
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsyncWithContextAppliesCompactionAsync()
+ {
+ // Arrange
+ Mock mockInner = new();
+ ChatResponseUpdate[] updates = [new(ChatRole.Assistant, "Done")];
+ mockInner.Setup(c => c.GetStreamingResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(ToAsyncEnumerableAsync(updates));
+
+ TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1);
+ CompactingChatClient client = new(mockInner.Object, strategy);
+
+ TestAgentSession session = new();
+ SetRunContext(session);
+
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ];
+
+ // Act
+ List results = [];
+ await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(messages))
+ {
+ results.Add(update);
+ }
+
+ // Assert
+ Assert.Single(results);
+ Assert.Equal("Done", results[0].Text);
+ }
+
+ [Fact]
+ public void GetServiceReturnsStrategyForMatchingType()
+ {
+ // Arrange
+ Mock mockInner = new();
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
+ CompactingChatClient client = new(mockInner.Object, strategy);
+
+ // Act — typeof(Type).IsInstanceOfType(typeof(CompactionStrategy)) is true
+ object? result = client.GetService(typeof(Type));
+
+ // Assert
+ Assert.Same(strategy, result);
+ }
+
+ [Fact]
+ public void GetServiceDelegatesToBaseForNonMatchingType()
+ {
+ // Arrange
+ Mock mockInner = new();
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
+ CompactingChatClient client = new(mockInner.Object, strategy);
+
+ // Act — typeof(string) doesn't match
+ object? result = client.GetService(typeof(string));
+
+ // Assert — delegates to base (which returns null for unregistered types)
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void GetServiceThrowsOnNullType()
+ {
+ Mock mockInner = new();
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
+ CompactingChatClient client = new(mockInner.Object, strategy);
+
+ Assert.Throws(() => client.GetService(null!));
+ }
+
+ [Fact]
+ public void GetServiceWithServiceKeyDelegatesToBase()
+ {
+ // Arrange — non-null serviceKey always delegates
+ Mock mockInner = new();
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
+ CompactingChatClient client = new(mockInner.Object, strategy);
+
+ // Act
+ object? result = client.GetService(typeof(Type), serviceKey: "mykey");
+
+ // Assert — delegates to base because serviceKey is non-null
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task GetResponseAsyncMessagesNotListCreatesListCopyAsync()
+ {
+ // Arrange — pass IEnumerable (not List) to exercise the list copy branch
+ ChatResponse expectedResponse = new([new ChatMessage(ChatRole.Assistant, "Hi")]);
+ Mock mockInner = new();
+ mockInner.Setup(c => c.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(expectedResponse);
+
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
+ CompactingChatClient client = new(mockInner.Object, strategy);
+
+ TestAgentSession session = new();
+ SetRunContext(session);
+
+ // Use an IEnumerable (not a List) to trigger the copy path
+ IEnumerable messages = [new(ChatRole.User, "Hello")];
+
+ // Act
+ ChatResponse response = await client.GetResponseAsync(messages);
+
+ // Assert
+ Assert.Same(expectedResponse, response);
+ }
+
+ ///
+ /// Sets via reflection.
+ ///
+ private static void SetCurrentRunContext(AgentRunContext? context)
+ {
+ FieldInfo? field = typeof(AIAgent).GetField("s_currentContext", BindingFlags.NonPublic | BindingFlags.Static);
+ Assert.NotNull(field);
+ object? asyncLocal = field!.GetValue(null);
+ Assert.NotNull(asyncLocal);
+ PropertyInfo? valueProp = asyncLocal!.GetType().GetProperty("Value");
+ Assert.NotNull(valueProp);
+ valueProp!.SetValue(asyncLocal, context);
+ }
+
+ ///
+ /// Creates an with the given session and sets it as the current context.
+ ///
+ private static void SetRunContext(AgentSession? session)
+ {
+ Mock mockAgent = new() { CallBase = true };
+ AgentRunContext context = new(
+ mockAgent.Object,
+ session,
+ [new(ChatRole.User, "test")],
+ null);
+ SetCurrentRunContext(context);
+ }
+
+ private static async IAsyncEnumerable ToAsyncEnumerableAsync(
+ ChatResponseUpdate[] updates, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ foreach (ChatResponseUpdate update in updates)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ yield return update;
+ await Task.CompletedTask;
+ }
+ }
+
+ private sealed class TestAgentSession : AgentSession;
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs
new file mode 100644
index 0000000000..8aa1f30637
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs
@@ -0,0 +1,166 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Compaction;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.UnitTests.Compaction;
+
+///
+/// Contains tests for the abstract base class.
+///
+public class CompactionStrategyTests
+{
+ [Fact]
+ public void ConstructorNullTriggerThrows()
+ {
+ // Act & Assert
+ Assert.Throws(() => new TestStrategy(null!));
+ }
+
+ [Fact]
+ public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
+ {
+ // Arrange — trigger never fires
+ TestStrategy strategy = new(_ => false);
+ MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert
+ Assert.False(result);
+ Assert.Equal(0, strategy.ApplyCallCount);
+ }
+
+ [Fact]
+ public async Task CompactAsyncTriggerMetCallsApplyAsync()
+ {
+ // Arrange — trigger always fires
+ TestStrategy strategy = new(_ => true, applyFunc: _ => true);
+ MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert
+ Assert.True(result);
+ Assert.Equal(1, strategy.ApplyCallCount);
+ }
+
+ [Fact]
+ public async Task CompactAsyncReturnsFalseWhenApplyReturnsFalseAsync()
+ {
+ // Arrange — trigger fires but Apply does nothing
+ TestStrategy strategy = new(_ => true, applyFunc: _ => false);
+ MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert
+ Assert.False(result);
+ Assert.Equal(1, strategy.ApplyCallCount);
+ }
+
+ [Fact]
+ public async Task CompactAsyncDefaultTargetIsInverseOfTriggerAsync()
+ {
+ // Arrange — trigger fires when groups > 2
+ // Default target should be: stop when groups <= 2 (i.e., !trigger)
+ CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2);
+ TestStrategy strategy = new(trigger, applyFunc: index =>
+ {
+ // Exclude oldest non-system group one at a time
+ foreach (MessageGroup group in index.Groups)
+ {
+ if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
+ {
+ group.IsExcluded = true;
+ // Target (default = !trigger) returns true when groups <= 2
+ // So the strategy would check Target after this exclusion
+ break;
+ }
+ }
+
+ return true;
+ });
+
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.Assistant, "A2"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert — trigger fires (4 > 2), Apply is called
+ Assert.True(result);
+ Assert.Equal(1, strategy.ApplyCallCount);
+ }
+
+ [Fact]
+ public async Task CompactAsyncCustomTargetIsPassedToStrategyAsync()
+ {
+ // Arrange — custom target that always signals stop
+ bool targetCalled = false;
+ bool CustomTarget(MessageIndex _)
+ {
+ targetCalled = true;
+ return true;
+ }
+
+ TestStrategy strategy = new(_ => true, CustomTarget, _ =>
+ {
+ // Access the target from within the strategy
+ return true;
+ });
+
+ MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
+
+ // Act
+ await strategy.CompactAsync(index);
+
+ // Assert — the custom target is accessible (verified by TestStrategy checking it)
+ Assert.Equal(1, strategy.ApplyCallCount);
+ // The target is accessible to derived classes via the protected property
+ Assert.True(strategy.InvokeTarget(index));
+ Assert.True(targetCalled);
+ }
+
+ ///
+ /// A concrete test implementation of for testing the base class.
+ ///
+ private sealed class TestStrategy : CompactionStrategy
+ {
+ private readonly Func? _applyFunc;
+
+ public TestStrategy(
+ CompactionTrigger trigger,
+ CompactionTrigger? target = null,
+ Func? applyFunc = null)
+ : base(trigger, target)
+ {
+ this._applyFunc = applyFunc;
+ }
+
+ public int ApplyCallCount { get; private set; }
+
+ ///
+ /// Exposes the protected Target property for test verification.
+ ///
+ public bool InvokeTarget(MessageIndex index) => this.Target(index);
+
+ protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
+ {
+ this.ApplyCallCount++;
+ bool result = this._applyFunc?.Invoke(index) ?? false;
+ return Task.FromResult(result);
+ }
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs
new file mode 100644
index 0000000000..be3a874459
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionTriggersTests.cs
@@ -0,0 +1,180 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Agents.AI.Compaction;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.UnitTests.Compaction;
+
+///
+/// Contains tests for and .
+///
+public class CompactionTriggersTests
+{
+ [Fact]
+ public void TokensExceedReturnsTrueWhenAboveThreshold()
+ {
+ // Arrange — use a long message to guarantee tokens > 0
+ CompactionTrigger trigger = CompactionTriggers.TokensExceed(0);
+ MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]);
+
+ // Act & Assert
+ Assert.True(trigger(index));
+ }
+
+ [Fact]
+ public void TokensExceedReturnsFalseWhenBelowThreshold()
+ {
+ CompactionTrigger trigger = CompactionTriggers.TokensExceed(999_999);
+ MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]);
+
+ Assert.False(trigger(index));
+ }
+
+ [Fact]
+ public void MessagesExceedReturnsExpectedResult()
+ {
+ CompactionTrigger trigger = CompactionTriggers.MessagesExceed(2);
+ MessageIndex small = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "A"),
+ new ChatMessage(ChatRole.User, "B"),
+ ]);
+ MessageIndex large = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "A"),
+ new ChatMessage(ChatRole.User, "B"),
+ new ChatMessage(ChatRole.User, "C"),
+ ]);
+
+ Assert.False(trigger(small));
+ Assert.True(trigger(large));
+ }
+
+ [Fact]
+ public void TurnsExceedReturnsExpectedResult()
+ {
+ CompactionTrigger trigger = CompactionTriggers.TurnsExceed(1);
+ MessageIndex oneTurn = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ ]);
+ MessageIndex twoTurns = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ]);
+
+ Assert.False(trigger(oneTurn));
+ Assert.True(trigger(twoTurns));
+ }
+
+ [Fact]
+ public void GroupsExceedReturnsExpectedResult()
+ {
+ CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2);
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "A"),
+ new ChatMessage(ChatRole.Assistant, "B"),
+ new ChatMessage(ChatRole.User, "C"),
+ ]);
+
+ Assert.True(trigger(index));
+ }
+
+ [Fact]
+ public void HasToolCallsReturnsTrueWhenToolCallGroupExists()
+ {
+ CompactionTrigger trigger = CompactionTriggers.HasToolCalls();
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
+ new ChatMessage(ChatRole.Tool, "result"),
+ ]);
+
+ Assert.True(trigger(index));
+ }
+
+ [Fact]
+ public void HasToolCallsReturnsFalseWhenNoToolCallGroup()
+ {
+ CompactionTrigger trigger = CompactionTriggers.HasToolCalls();
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Hello"),
+ new ChatMessage(ChatRole.Assistant, "Hi!"),
+ ]);
+
+ Assert.False(trigger(index));
+ }
+
+ [Fact]
+ public void AllRequiresAllConditions()
+ {
+ CompactionTrigger trigger = CompactionTriggers.All(
+ CompactionTriggers.TokensExceed(0),
+ CompactionTriggers.MessagesExceed(5));
+
+ MessageIndex small = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
+
+ // Tokens > 0 is true, but messages > 5 is false
+ Assert.False(trigger(small));
+ }
+
+ [Fact]
+ public void AnyRequiresAtLeastOneCondition()
+ {
+ CompactionTrigger trigger = CompactionTriggers.Any(
+ CompactionTriggers.TokensExceed(999_999),
+ CompactionTriggers.MessagesExceed(0));
+
+ MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
+
+ // Tokens not exceeded, but messages > 0 is true
+ Assert.True(trigger(index));
+ }
+
+ [Fact]
+ public void AllEmptyTriggersReturnsTrue()
+ {
+ CompactionTrigger trigger = CompactionTriggers.All();
+ MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
+ Assert.True(trigger(index));
+ }
+
+ [Fact]
+ public void AnyEmptyTriggersReturnsFalse()
+ {
+ CompactionTrigger trigger = CompactionTriggers.Any();
+ MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
+ Assert.False(trigger(index));
+ }
+
+ [Fact]
+ public void TokensBelowReturnsTrueWhenBelowThreshold()
+ {
+ CompactionTrigger trigger = CompactionTriggers.TokensBelow(999_999);
+ MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]);
+
+ Assert.True(trigger(index));
+ }
+
+ [Fact]
+ public void TokensBelowReturnsFalseWhenAboveThreshold()
+ {
+ CompactionTrigger trigger = CompactionTriggers.TokensBelow(0);
+ MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]);
+
+ Assert.False(trigger(index));
+ }
+
+ [Fact]
+ public void AlwaysReturnsTrue()
+ {
+ MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
+ Assert.True(CompactionTriggers.Always(index));
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs
new file mode 100644
index 0000000000..395e6ff2bf
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs
@@ -0,0 +1,912 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Buffers;
+using System.Collections.Generic;
+using Microsoft.Agents.AI.Compaction;
+using Microsoft.Extensions.AI;
+using Microsoft.ML.Tokenizers;
+
+namespace Microsoft.Agents.AI.UnitTests.Compaction;
+
+///
+/// Contains tests for the class.
+///
+public class MessageIndexTests
+{
+ [Fact]
+ public void CreateEmptyListReturnsEmptyGroups()
+ {
+ // Arrange
+ List messages = [];
+
+ // Act
+ MessageIndex groups = MessageIndex.Create(messages);
+
+ // Assert
+ Assert.Empty(groups.Groups);
+ }
+
+ [Fact]
+ public void CreateSystemMessageCreatesSystemGroup()
+ {
+ // Arrange
+ List messages =
+ [
+ new ChatMessage(ChatRole.System, "You are helpful."),
+ ];
+
+ // Act
+ MessageIndex groups = MessageIndex.Create(messages);
+
+ // Assert
+ Assert.Single(groups.Groups);
+ Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
+ Assert.Single(groups.Groups[0].Messages);
+ }
+
+ [Fact]
+ public void CreateUserMessageCreatesUserGroup()
+ {
+ // Arrange
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Hello"),
+ ];
+
+ // Act
+ MessageIndex groups = MessageIndex.Create(messages);
+
+ // Assert
+ Assert.Single(groups.Groups);
+ Assert.Equal(MessageGroupKind.User, groups.Groups[0].Kind);
+ }
+
+ [Fact]
+ public void CreateAssistantTextMessageCreatesAssistantTextGroup()
+ {
+ // Arrange
+ List messages =
+ [
+ new ChatMessage(ChatRole.Assistant, "Hi there!"),
+ ];
+
+ // Act
+ MessageIndex groups = MessageIndex.Create(messages);
+
+ // Assert
+ Assert.Single(groups.Groups);
+ Assert.Equal(MessageGroupKind.AssistantText, groups.Groups[0].Kind);
+ }
+
+ [Fact]
+ public void CreateToolCallWithResultsCreatesAtomicToolCallGroup()
+ {
+ // 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
+ MessageIndex groups = MessageIndex.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 CreateMixedConversationGroupsCorrectly()
+ {
+ // 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
+ MessageIndex groups = MessageIndex.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 CreateMultipleToolResultsGroupsAllWithAssistant()
+ {
+ // 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
+ MessageIndex groups = MessageIndex.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 GetIncludedMessagesExcludesMarkedGroups()
+ {
+ // Arrange
+ ChatMessage msg1 = new(ChatRole.User, "First");
+ ChatMessage msg2 = new(ChatRole.Assistant, "Response");
+ ChatMessage msg3 = new(ChatRole.User, "Second");
+
+ MessageIndex groups = MessageIndex.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 GetAllMessagesIncludesExcludedGroups()
+ {
+ // Arrange
+ ChatMessage msg1 = new(ChatRole.User, "First");
+ ChatMessage msg2 = new(ChatRole.Assistant, "Response");
+
+ MessageIndex groups = MessageIndex.Create([msg1, msg2]);
+ groups.Groups[0].IsExcluded = true;
+
+ // Act
+ List all = [.. groups.GetAllMessages()];
+
+ // Assert
+ Assert.Equal(2, all.Count);
+ }
+
+ [Fact]
+ public void IncludedGroupCountReflectsExclusions()
+ {
+ // Arrange
+ MessageIndex groups = MessageIndex.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 CreateSummaryMessageCreatesSummaryGroup()
+ {
+ // Arrange
+ ChatMessage summaryMessage = new(ChatRole.Assistant, "[Summary of earlier conversation]: key facts...");
+ (summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true;
+
+ List messages = [summaryMessage];
+
+ // Act
+ MessageIndex groups = MessageIndex.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 CreateSummaryAmongOtherMessagesGroupsCorrectly()
+ {
+ // 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
+ MessageIndex groups = MessageIndex.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 MessageGroupStoresPassedCounts()
+ {
+ // 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 MessageGroupMessagesAreImmutable()
+ {
+ // 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 CreateComputesByteCountUtf8()
+ {
+ // Arrange — "Hello" is 5 UTF-8 bytes
+ MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
+
+ // Assert
+ Assert.Equal(5, groups.Groups[0].ByteCount);
+ }
+
+ [Fact]
+ public void CreateComputesByteCountMultiByteChars()
+ {
+ // Arrange — "café" has a multi-byte 'é' (2 bytes in UTF-8) → 5 bytes total
+ MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "café")]);
+
+ // Assert
+ Assert.Equal(5, groups.Groups[0].ByteCount);
+ }
+
+ [Fact]
+ public void CreateComputesByteCountMultipleMessagesInGroup()
+ {
+ // 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");
+ MessageIndex groups = MessageIndex.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 CreateDefaultTokenCountIsHeuristic()
+ {
+ // Arrange — "Hello world test data!" = 22 UTF-8 bytes → 22 / 4 = 5 estimated tokens
+ MessageIndex groups = MessageIndex.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 CreateNullTextHasZeroCounts()
+ {
+ // 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);
+ MessageIndex groups = MessageIndex.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 TotalAggregatesSumAllGroups()
+ {
+ // Arrange
+ MessageIndex groups = MessageIndex.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 IncludedAggregatesExcludeMarkedGroups()
+ {
+ // Arrange
+ MessageIndex groups = MessageIndex.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 ToolCallGroupAggregatesAcrossMessages()
+ {
+ // 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");
+
+ MessageIndex groups = MessageIndex.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 CreateAssignsTurnIndicesSingleTurn()
+ {
+ // Arrange — System (no turn), User + Assistant = turn 1
+ MessageIndex groups = MessageIndex.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 CreateAssignsTurnIndicesMultiTurn()
+ {
+ // Arrange — 3 user turns
+ MessageIndex groups = MessageIndex.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 CreateTurnSpansToolCallGroups()
+ {
+ // Arrange — turn 1 includes User, ToolCall, AssistantText
+ ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
+ ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
+
+ MessageIndex groups = MessageIndex.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 GetTurnGroupsReturnsGroupsForSpecificTurn()
+ {
+ // Arrange
+ MessageIndex groups = MessageIndex.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 IncludedTurnCountReflectsExclusions()
+ {
+ // Arrange — 2 turns, exclude all groups in turn 1
+ MessageIndex groups = MessageIndex.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 TotalTurnCountZeroWhenNoUserMessages()
+ {
+ // Arrange — only system messages
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.System, "System."),
+ ]);
+
+ // Assert
+ Assert.Equal(0, groups.TotalTurnCount);
+ Assert.Equal(0, groups.IncludedTurnCount);
+ }
+
+ [Fact]
+ public void IncludedTurnCountPartialExclusionStillCountsTurn()
+ {
+ // Arrange — turn 1 has 2 groups, only one excluded
+ MessageIndex groups = MessageIndex.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);
+ }
+
+ [Fact]
+ public void UpdateAppendsNewMessagesIncrementally()
+ {
+ // Arrange — create with 2 messages
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ ];
+ MessageIndex index = MessageIndex.Create(messages);
+ Assert.Equal(2, index.Groups.Count);
+ Assert.Equal(2, index.ProcessedMessageCount);
+
+ // Act — add 2 more messages and update
+ messages.Add(new ChatMessage(ChatRole.User, "Q2"));
+ messages.Add(new ChatMessage(ChatRole.Assistant, "A2"));
+ index.Update(messages);
+
+ // Assert — should have 4 groups total, processed count updated
+ Assert.Equal(4, index.Groups.Count);
+ Assert.Equal(4, index.ProcessedMessageCount);
+ Assert.Equal(MessageGroupKind.User, index.Groups[2].Kind);
+ Assert.Equal(MessageGroupKind.AssistantText, index.Groups[3].Kind);
+ }
+
+ [Fact]
+ public void UpdateNoOpWhenNoNewMessages()
+ {
+ // Arrange
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ ];
+ MessageIndex index = MessageIndex.Create(messages);
+ int originalCount = index.Groups.Count;
+
+ // Act — update with same count
+ index.Update(messages);
+
+ // Assert — nothing changed
+ Assert.Equal(originalCount, index.Groups.Count);
+ }
+
+ [Fact]
+ public void UpdateRebuildsWhenMessagesShrink()
+ {
+ // Arrange — create with 3 messages
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ];
+ MessageIndex index = MessageIndex.Create(messages);
+ Assert.Equal(3, index.Groups.Count);
+
+ // Exclude a group to verify rebuild clears state
+ index.Groups[0].IsExcluded = true;
+
+ // Act — update with fewer messages (simulates storage compaction)
+ List shortened =
+ [
+ new ChatMessage(ChatRole.User, "Q2"),
+ ];
+ index.Update(shortened);
+
+ // Assert — rebuilt from scratch
+ Assert.Single(index.Groups);
+ Assert.False(index.Groups[0].IsExcluded);
+ Assert.Equal(1, index.ProcessedMessageCount);
+ }
+
+ [Fact]
+ public void UpdatePreservesExistingGroupExclusionState()
+ {
+ // Arrange
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ ];
+ MessageIndex index = MessageIndex.Create(messages);
+ index.Groups[0].IsExcluded = true;
+ index.Groups[0].ExcludeReason = "Test exclusion";
+
+ // Act — append new messages
+ messages.Add(new ChatMessage(ChatRole.User, "Q2"));
+ index.Update(messages);
+
+ // Assert — original exclusion state preserved
+ Assert.True(index.Groups[0].IsExcluded);
+ Assert.Equal("Test exclusion", index.Groups[0].ExcludeReason);
+ Assert.Equal(3, index.Groups.Count);
+ }
+
+ [Fact]
+ public void InsertGroupInsertsAtSpecifiedIndex()
+ {
+ // Arrange
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ]);
+
+ // Act — insert between Q1 and Q2
+ ChatMessage summaryMsg = new(ChatRole.Assistant, "[Summary]");
+ MessageGroup inserted = index.InsertGroup(1, MessageGroupKind.Summary, [summaryMsg], turnIndex: 1);
+
+ // Assert
+ Assert.Equal(3, index.Groups.Count);
+ Assert.Same(inserted, index.Groups[1]);
+ Assert.Equal(MessageGroupKind.Summary, index.Groups[1].Kind);
+ Assert.Equal("[Summary]", index.Groups[1].Messages[0].Text);
+ Assert.Equal(1, inserted.TurnIndex);
+ }
+
+ [Fact]
+ public void AddGroupAppendsToEnd()
+ {
+ // Arrange
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ ]);
+
+ // Act
+ ChatMessage msg = new(ChatRole.Assistant, "Appended");
+ MessageGroup added = index.AddGroup(MessageGroupKind.AssistantText, [msg], turnIndex: 1);
+
+ // Assert
+ Assert.Equal(2, index.Groups.Count);
+ Assert.Same(added, index.Groups[1]);
+ Assert.Equal("Appended", index.Groups[1].Messages[0].Text);
+ }
+
+ [Fact]
+ public void InsertGroupComputesByteAndTokenCounts()
+ {
+ // Arrange
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ ]);
+
+ // Act — insert a group with known text
+ ChatMessage msg = new(ChatRole.Assistant, "Hello"); // 5 bytes, ~1 token (5/4)
+ MessageGroup inserted = index.InsertGroup(0, MessageGroupKind.AssistantText, [msg]);
+
+ // Assert
+ Assert.Equal(5, inserted.ByteCount);
+ Assert.Equal(1, inserted.TokenCount); // 5 / 4 = 1 (integer division)
+ }
+
+ [Fact]
+ public void ConstructorWithGroupsRestoresTurnIndex()
+ {
+ // Arrange — pre-existing groups with turn indices
+ MessageGroup group1 = new(MessageGroupKind.User, [new ChatMessage(ChatRole.User, "Q1")], 2, 1, turnIndex: 1);
+ MessageGroup group2 = new(MessageGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, "A1")], 2, 1, turnIndex: 1);
+ MessageGroup group3 = new(MessageGroupKind.User, [new ChatMessage(ChatRole.User, "Q2")], 2, 1, turnIndex: 2);
+ List groups = [group1, group2, group3];
+
+ // Act — constructor should restore _currentTurn from the last group's TurnIndex
+ MessageIndex index = new(groups);
+
+ // Assert — adding a new user message should get turn 3 (restored 2 + 1)
+ index.Update(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.User, "Q3"),
+ ]);
+
+ // The new user group should have TurnIndex 3
+ MessageGroup lastGroup = index.Groups[index.Groups.Count - 1];
+ Assert.Equal(MessageGroupKind.User, lastGroup.Kind);
+ Assert.NotNull(lastGroup.TurnIndex);
+ }
+
+ [Fact]
+ public void ConstructorWithEmptyGroupsHandlesGracefully()
+ {
+ // Arrange & Act — constructor with empty list
+ MessageIndex index = new([]);
+
+ // Assert
+ Assert.Empty(index.Groups);
+ }
+
+ [Fact]
+ public void ConstructorWithGroupsWithoutTurnIndexSkipsRestore()
+ {
+ // Arrange — groups without turn indices (system messages)
+ MessageGroup systemGroup = new(MessageGroupKind.System, [new ChatMessage(ChatRole.System, "Be helpful")], 10, 3, turnIndex: null);
+ List groups = [systemGroup];
+
+ // Act — constructor won't find a TurnIndex to restore
+ MessageIndex index = new(groups);
+
+ // Assert
+ Assert.Single(index.Groups);
+ }
+
+ [Fact]
+ public void ComputeTokenCountReturnsTokenCount()
+ {
+ // Arrange — call the public static method directly
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Hello world"),
+ new ChatMessage(ChatRole.Assistant, "Greetings"),
+ ];
+
+ // Act — use a simple tokenizer that counts words (each word = 1 token)
+ SimpleWordTokenizer tokenizer = new();
+ int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer);
+
+ // Assert — "Hello world" = 2, "Greetings" = 1 → 3 total
+ Assert.Equal(3, tokenCount);
+ }
+
+ [Fact]
+ public void ComputeTokenCountEmptyTextReturnsZero()
+ {
+ // Arrange — message with no text content
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, [new FunctionCallContent("c1", "fn")]),
+ ];
+
+ SimpleWordTokenizer tokenizer = new();
+ int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer);
+
+ // Assert — no text content → 0 tokens
+ Assert.Equal(0, tokenCount);
+ }
+
+ [Fact]
+ public void CreateWithTokenizerUsesTokenizerForCounts()
+ {
+ // Arrange
+ SimpleWordTokenizer tokenizer = new();
+
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Hello world test"),
+ ];
+
+ // Act
+ MessageIndex index = MessageIndex.Create(messages, tokenizer);
+
+ // Assert — tokenizer counts words: "Hello world test" = 3 tokens
+ Assert.Single(index.Groups);
+ Assert.Equal(3, index.Groups[0].TokenCount);
+ Assert.NotNull(index.Tokenizer);
+ }
+
+ [Fact]
+ public void InsertGroupWithTokenizerUsesTokenizer()
+ {
+ // Arrange
+ SimpleWordTokenizer tokenizer = new();
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Hello"),
+ ], tokenizer);
+
+ // Act
+ ChatMessage msg = new(ChatRole.Assistant, "Hello world test message");
+ MessageGroup inserted = index.InsertGroup(0, MessageGroupKind.AssistantText, [msg]);
+
+ // Assert — tokenizer counts words: "Hello world test message" = 4 tokens
+ Assert.Equal(4, inserted.TokenCount);
+ }
+
+ [Fact]
+ public void CreateWithStandaloneToolMessageGroupsAsAssistantText()
+ {
+ // A Tool message not preceded by an assistant tool-call falls through to the else branch
+ List messages =
+ [
+ new ChatMessage(ChatRole.Tool, "Orphaned tool result"),
+ ];
+
+ MessageIndex index = MessageIndex.Create(messages);
+
+ // The Tool message should be grouped as AssistantText (the default fallback)
+ Assert.Single(index.Groups);
+ Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind);
+ }
+
+ [Fact]
+ public void CreateWithAssistantNonSummaryWithPropertiesFallsToAssistantText()
+ {
+ // Assistant message with AdditionalProperties but NOT a summary
+ ChatMessage assistant = new(ChatRole.Assistant, "Regular response");
+ (assistant.AdditionalProperties ??= [])["someOtherKey"] = "value";
+
+ MessageIndex index = MessageIndex.Create([assistant]);
+
+ Assert.Single(index.Groups);
+ Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind);
+ }
+
+ [Fact]
+ public void ComputeByteCountHandlesNullAndNonNullText()
+ {
+ // Mix of messages: one with text (non-null), one without (null Text)
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Hello"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
+ ];
+
+ int byteCount = MessageIndex.ComputeByteCount(messages);
+
+ // Only "Hello" contributes bytes (5 bytes UTF-8)
+ Assert.Equal(5, byteCount);
+ }
+
+ [Fact]
+ public void ComputeTokenCountHandlesNullAndNonNullText()
+ {
+ // Mix: one with text, one without
+ SimpleWordTokenizer tokenizer = new();
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Hello world"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
+ ];
+
+ int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer);
+
+ // Only "Hello world" contributes tokens (2 words)
+ Assert.Equal(2, tokenCount);
+ }
+
+ ///
+ /// A simple tokenizer that counts whitespace-separated words as tokens.
+ ///
+ private sealed class SimpleWordTokenizer : Tokenizer
+ {
+ public override PreTokenizer? PreTokenizer => null;
+ public override Normalizer? Normalizer => null;
+
+ protected override EncodeResults EncodeToTokens(string? text, System.ReadOnlySpan textSpan, EncodeSettings settings)
+ {
+ // Simple word-based encoding
+ string input = text ?? textSpan.ToString();
+ if (string.IsNullOrWhiteSpace(input))
+ {
+ return new EncodeResults
+ {
+ Tokens = System.Array.Empty(),
+ CharsConsumed = 0,
+ NormalizedText = null,
+ };
+ }
+
+ string[] words = input.Split(' ');
+ List tokens = [];
+ int offset = 0;
+ for (int i = 0; i < words.Length; i++)
+ {
+ tokens.Add(new EncodedToken(i, words[i], new System.Range(offset, offset + words[i].Length)));
+ offset += words[i].Length + 1;
+ }
+
+ return new EncodeResults
+ {
+ Tokens = tokens,
+ CharsConsumed = input.Length,
+ NormalizedText = null,
+ };
+ }
+
+ public override OperationStatus Decode(IEnumerable ids, System.Span destination, out int idsConsumed, out int charsWritten)
+ {
+ idsConsumed = 0;
+ charsWritten = 0;
+ return OperationStatus.Done;
+ }
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs
new file mode 100644
index 0000000000..0bb7b022dc
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs
@@ -0,0 +1,191 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+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 PipelineCompactionStrategyTests
+{
+ [Fact]
+ public async Task CompactAsyncExecutesAllStrategiesInOrderAsync()
+ {
+ // Arrange
+ List executionOrder = [];
+ TestCompactionStrategy strategy1 = new(
+ _ =>
+ {
+ executionOrder.Add("first");
+ return false;
+ });
+
+ TestCompactionStrategy strategy2 = new(
+ _ =>
+ {
+ executionOrder.Add("second");
+ return false;
+ });
+
+ PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
+ MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
+
+ // Act
+ await pipeline.CompactAsync(groups);
+
+ // Assert
+ Assert.Equal(["first", "second"], executionOrder);
+ }
+
+ [Fact]
+ public async Task CompactAsyncReturnsFalseWhenNoStrategyCompactsAsync()
+ {
+ // Arrange
+ TestCompactionStrategy strategy1 = new(_ => false);
+
+ PipelineCompactionStrategy pipeline = new(strategy1);
+ MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
+
+ // Act
+ bool result = await pipeline.CompactAsync(groups);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task CompactAsyncReturnsTrueWhenAnyStrategyCompactsAsync()
+ {
+ // Arrange
+ TestCompactionStrategy strategy1 = new(_ => false);
+ TestCompactionStrategy strategy2 = new(_ => true);
+
+ PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
+ MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
+
+ // Act
+ bool result = await pipeline.CompactAsync(groups);
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Fact]
+ public async Task CompactAsyncContinuesAfterFirstCompactionAsync()
+ {
+ // Arrange
+ TestCompactionStrategy strategy1 = new(_ => true);
+ TestCompactionStrategy strategy2 = new(_ => false);
+
+ PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
+ MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
+
+ // Act
+ await pipeline.CompactAsync(groups);
+
+ // Assert — both strategies were called
+ Assert.Equal(1, strategy1.ApplyCallCount);
+ Assert.Equal(1, strategy2.ApplyCallCount);
+ }
+
+ [Fact]
+ public async Task CompactAsyncComposesStrategiesEndToEndAsync()
+ {
+ // Arrange — pipeline: first exclude oldest 2 non-system groups, then exclude 2 more
+ static void ExcludeOldest2(MessageIndex index)
+ {
+ int excluded = 0;
+ foreach (MessageGroup group in index.Groups)
+ {
+ if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2)
+ {
+ group.IsExcluded = true;
+ excluded++;
+ }
+ }
+ }
+
+ TestCompactionStrategy phase1 = new(
+ index =>
+ {
+ ExcludeOldest2(index);
+ return true;
+ });
+
+ TestCompactionStrategy phase2 = new(
+ index =>
+ {
+ ExcludeOldest2(index);
+ return true;
+ });
+
+ PipelineCompactionStrategy pipeline = new(phase1, phase2);
+
+ MessageIndex groups = MessageIndex.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);
+
+ Assert.Equal(1, phase1.ApplyCallCount);
+ Assert.Equal(1, phase2.ApplyCallCount);
+ }
+
+ [Fact]
+ public async Task CompactAsyncEmptyPipelineReturnsFalseAsync()
+ {
+ // Arrange
+ PipelineCompactionStrategy pipeline = new(new List());
+ MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
+
+ // Act
+ bool result = await pipeline.CompactAsync(groups);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ ///
+ /// A simple test implementation of that delegates to a synchronous callback.
+ ///
+ private sealed class TestCompactionStrategy : CompactionStrategy
+ {
+ private readonly Func _applyFunc;
+
+ public TestCompactionStrategy(Func applyFunc)
+ : base(CompactionTriggers.Always)
+ {
+ this._applyFunc = applyFunc;
+ }
+
+ public int ApplyCallCount { get; private set; }
+
+ protected override Task ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
+ {
+ this.ApplyCallCount++;
+ return Task.FromResult(this._applyFunc(index));
+ }
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs
new file mode 100644
index 0000000000..709a44e0a4
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SlidingWindowCompactionStrategyTests.cs
@@ -0,0 +1,250 @@
+// 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.UnitTests.Compaction;
+
+///
+/// Contains tests for the class.
+///
+public class SlidingWindowCompactionStrategyTests
+{
+ [Fact]
+ public async Task CompactAsyncBelowMaxTurnsReturnsFalseAsync()
+ {
+ // Arrange — trigger requires > 3 turns, conversation has 2
+ SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(3));
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.Assistant, "A2"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task CompactAsyncExceedsMaxTurnsExcludesOldestTurnsAsync()
+ {
+ // Arrange — trigger on > 2 turns, conversation has 3
+ SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(2));
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.Assistant, "A2"),
+ new ChatMessage(ChatRole.User, "Q3"),
+ new ChatMessage(ChatRole.Assistant, "A3"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert
+ Assert.True(result);
+ // Turn 1 (Q1 + A1) should be excluded
+ Assert.True(groups.Groups[0].IsExcluded);
+ Assert.True(groups.Groups[1].IsExcluded);
+ // Turn 2 and 3 should remain
+ Assert.False(groups.Groups[2].IsExcluded);
+ Assert.False(groups.Groups[3].IsExcluded);
+ Assert.False(groups.Groups[4].IsExcluded);
+ Assert.False(groups.Groups[5].IsExcluded);
+ }
+
+ [Fact]
+ public async Task CompactAsyncPreservesSystemMessagesAsync()
+ {
+ // Arrange — trigger on > 1 turn
+ SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.System, "You are helpful."),
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert
+ Assert.True(result);
+ Assert.False(groups.Groups[0].IsExcluded); // System preserved
+ Assert.True(groups.Groups[1].IsExcluded); // Turn 1 excluded
+ Assert.True(groups.Groups[2].IsExcluded); // Turn 1 response excluded
+ Assert.False(groups.Groups[3].IsExcluded); // Turn 2 kept
+ }
+
+ [Fact]
+ public async Task CompactAsyncPreservesToolCallGroupsInKeptTurnsAsync()
+ {
+ // Arrange — trigger on > 1 turn
+ SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]),
+ new ChatMessage(ChatRole.Tool, "Results"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert
+ Assert.True(result);
+ // Turn 1 excluded
+ Assert.True(groups.Groups[0].IsExcluded);
+ Assert.True(groups.Groups[1].IsExcluded);
+ // Turn 2 kept (user + tool call group)
+ Assert.False(groups.Groups[2].IsExcluded);
+ Assert.False(groups.Groups[3].IsExcluded);
+ }
+
+ [Fact]
+ public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
+ {
+ // Arrange — trigger requires > 99 turns
+ SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(99));
+
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.User, "Q3"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task CompactAsyncIncludedMessagesContainOnlyKeptTurnsAsync()
+ {
+ // Arrange — trigger on > 1 turn
+ SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(1));
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.System, "System"),
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.Assistant, "A2"),
+ ]);
+
+ // Act
+ await strategy.CompactAsync(groups);
+
+ // Assert
+ List included = [.. groups.GetIncludedMessages()];
+ Assert.Equal(3, included.Count);
+ Assert.Equal("System", included[0].Text);
+ Assert.Equal("Q2", included[1].Text);
+ Assert.Equal("A2", included[2].Text);
+ }
+
+ [Fact]
+ public async Task CompactAsyncCustomTargetStopsExcludingEarlyAsync()
+ {
+ // Arrange — trigger on > 1 turn, custom target stops after removing 1 turn
+ int removeCount = 0;
+ bool TargetAfterOne(MessageIndex _) => ++removeCount >= 1;
+
+ SlidingWindowCompactionStrategy strategy = new(
+ CompactionTriggers.TurnsExceed(1),
+ minimumPreserved: 0,
+ target: TargetAfterOne);
+
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.Assistant, "A2"),
+ new ChatMessage(ChatRole.User, "Q3"),
+ new ChatMessage(ChatRole.Assistant, "A3"),
+ new ChatMessage(ChatRole.User, "Q4"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert — only turn 1 excluded (target stopped after 1 removal)
+ Assert.True(result);
+ Assert.True(index.Groups[0].IsExcluded); // Q1 (turn 1)
+ Assert.True(index.Groups[1].IsExcluded); // A1 (turn 1)
+ Assert.False(index.Groups[2].IsExcluded); // Q2 (turn 2) — kept
+ Assert.False(index.Groups[3].IsExcluded); // A2 (turn 2)
+ }
+
+ [Fact]
+ public async Task CompactAsyncMinimumPreservedStopsCompactionAsync()
+ {
+ // Arrange — always trigger with never-satisfied target, but MinimumPreserved = 2 is hard floor
+ SlidingWindowCompactionStrategy strategy = new(
+ CompactionTriggers.TurnsExceed(1),
+ minimumPreserved: 2,
+ target: _ => false);
+
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.Assistant, "A2"),
+ new ChatMessage(ChatRole.User, "Q3"),
+ new ChatMessage(ChatRole.Assistant, "A3"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert — target never says stop, but MinimumPreserved=2 prevents removing the last 2 groups
+ Assert.True(result);
+ Assert.Equal(2, index.IncludedGroupCount);
+ // Last 2 non-system groups must be preserved
+ Assert.False(index.Groups[4].IsExcluded); // Q3
+ Assert.False(index.Groups[5].IsExcluded); // A3
+ }
+
+ [Fact]
+ public async Task CompactAsyncSkipsExcludedAndSystemGroupsInEnumerationAsync()
+ {
+ // Arrange — includes system and pre-excluded groups that must be skipped
+ SlidingWindowCompactionStrategy strategy = new(
+ CompactionTriggers.TurnsExceed(1),
+ minimumPreserved: 0);
+
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.System, "System prompt"),
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ]);
+ // Pre-exclude one group
+ index.Groups[1].IsExcluded = true;
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert — system preserved, pre-excluded skipped
+ Assert.True(result);
+ Assert.False(index.Groups[0].IsExcluded); // System preserved
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs
new file mode 100644
index 0000000000..0bf225ae34
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs
@@ -0,0 +1,410 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Compaction;
+using Microsoft.Extensions.AI;
+using Moq;
+
+namespace Microsoft.Agents.AI.UnitTests.Compaction;
+
+///
+/// Contains tests for the class.
+///
+public class SummarizationCompactionStrategyTests
+{
+ ///
+ /// Creates a mock that returns the specified summary text.
+ ///
+ private static IChatClient CreateMockChatClient(string summaryText = "Summary of conversation.")
+ {
+ Mock mock = new();
+ mock.Setup(c => c.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, summaryText)]));
+ return mock.Object;
+ }
+
+ [Fact]
+ public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
+ {
+ // Arrange — trigger requires > 100000 tokens
+ SummarizationCompactionStrategy strategy = new(
+ CreateMockChatClient(),
+ CompactionTriggers.TokensExceed(100000),
+ minimumPreserved: 1);
+
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Hello"),
+ new ChatMessage(ChatRole.Assistant, "Hi!"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert
+ Assert.False(result);
+ Assert.Equal(2, index.IncludedGroupCount);
+ }
+
+ [Fact]
+ public async Task CompactAsyncSummarizesOldGroupsAsync()
+ {
+ // Arrange — always trigger, preserve 1 recent group
+ SummarizationCompactionStrategy strategy = new(
+ CreateMockChatClient("Key facts from earlier."),
+ CompactionTriggers.Always,
+ minimumPreserved: 1);
+
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "First question"),
+ new ChatMessage(ChatRole.Assistant, "First answer"),
+ new ChatMessage(ChatRole.User, "Second question"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert
+ Assert.True(result);
+
+ List included = [.. index.GetIncludedMessages()];
+
+ // Should have: summary + preserved recent group (Second question)
+ Assert.Equal(2, included.Count);
+ Assert.Contains("[Summary]", included[0].Text);
+ Assert.Contains("Key facts from earlier.", included[0].Text);
+ Assert.Equal("Second question", included[1].Text);
+ }
+
+ [Fact]
+ public async Task CompactAsyncPreservesSystemMessagesAsync()
+ {
+ // Arrange
+ SummarizationCompactionStrategy strategy = new(
+ CreateMockChatClient(),
+ CompactionTriggers.Always,
+ minimumPreserved: 1);
+
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.System, "You are helpful."),
+ new ChatMessage(ChatRole.User, "Old question"),
+ new ChatMessage(ChatRole.Assistant, "Old answer"),
+ new ChatMessage(ChatRole.User, "Recent question"),
+ ]);
+
+ // Act
+ await strategy.CompactAsync(index);
+
+ // Assert
+ List included = [.. index.GetIncludedMessages()];
+
+ Assert.Equal("You are helpful.", included[0].Text);
+ Assert.Equal(ChatRole.System, included[0].Role);
+ }
+
+ [Fact]
+ public async Task CompactAsyncInsertsSummaryGroupAtCorrectPositionAsync()
+ {
+ // Arrange
+ SummarizationCompactionStrategy strategy = new(
+ CreateMockChatClient("Summary text."),
+ CompactionTriggers.Always,
+ minimumPreserved: 1);
+
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.System, "System prompt."),
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ]);
+
+ // Act
+ await strategy.CompactAsync(index);
+
+ // Assert — summary should be inserted after system, before preserved group
+ MessageGroup summaryGroup = index.Groups.First(g => g.Kind == MessageGroupKind.Summary);
+ Assert.NotNull(summaryGroup);
+ Assert.Contains("[Summary]", summaryGroup.Messages[0].Text);
+ Assert.True(summaryGroup.Messages[0].AdditionalProperties!.ContainsKey(MessageGroup.SummaryPropertyKey));
+ }
+
+ [Fact]
+ public async Task CompactAsyncHandlesEmptyLlmResponseAsync()
+ {
+ // Arrange — LLM returns whitespace
+ SummarizationCompactionStrategy strategy = new(
+ CreateMockChatClient(" "),
+ CompactionTriggers.Always,
+ minimumPreserved: 1);
+
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ]);
+
+ // Act
+ await strategy.CompactAsync(index);
+
+ // Assert — should use fallback text
+ List included = [.. index.GetIncludedMessages()];
+ Assert.Contains("[Summary unavailable]", included[0].Text);
+ }
+
+ [Fact]
+ public async Task CompactAsyncNothingToSummarizeReturnsFalseAsync()
+ {
+ // Arrange — preserve 5 but only 2 non-system groups
+ SummarizationCompactionStrategy strategy = new(
+ CreateMockChatClient(),
+ CompactionTriggers.Always,
+ minimumPreserved: 5);
+
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Hello"),
+ new ChatMessage(ChatRole.Assistant, "Hi!"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task CompactAsyncUsesCustomPromptAsync()
+ {
+ // Arrange — capture the messages sent to the chat client
+ List? capturedMessages = null;
+ Mock mockClient = new();
+ mockClient.Setup(c => c.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback, ChatOptions?, CancellationToken>((msgs, _, _) =>
+ capturedMessages = [.. msgs])
+ .ReturnsAsync(new ChatResponse([new ChatMessage(ChatRole.Assistant, "Custom summary.")]));
+
+ const string CustomPrompt = "Summarize in bullet points only.";
+ SummarizationCompactionStrategy strategy = new(
+ mockClient.Object,
+ CompactionTriggers.Always,
+ minimumPreserved: 1,
+ summarizationPrompt: CustomPrompt);
+
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ]);
+
+ // Act
+ await strategy.CompactAsync(index);
+
+ // Assert — the custom prompt should be the first message sent to the LLM
+ Assert.NotNull(capturedMessages);
+ Assert.Equal(CustomPrompt, capturedMessages![0].Text);
+ }
+
+ [Fact]
+ public async Task CompactAsyncSetsExcludeReasonAsync()
+ {
+ // Arrange
+ SummarizationCompactionStrategy strategy = new(
+ CreateMockChatClient(),
+ CompactionTriggers.Always,
+ minimumPreserved: 1);
+
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Old"),
+ new ChatMessage(ChatRole.User, "New"),
+ ]);
+
+ // Act
+ await strategy.CompactAsync(index);
+
+ // Assert
+ MessageGroup excluded = index.Groups.First(g => g.IsExcluded);
+ Assert.NotNull(excluded.ExcludeReason);
+ Assert.Contains("SummarizationCompactionStrategy", excluded.ExcludeReason);
+ }
+
+ [Fact]
+ public async Task CompactAsyncTargetStopsMarkingEarlyAsync()
+ {
+ // Arrange — 4 non-system groups, preserve 1, target met after 1 exclusion
+ int exclusionCount = 0;
+ bool TargetAfterOne(MessageIndex _) => ++exclusionCount >= 1;
+
+ SummarizationCompactionStrategy strategy = new(
+ CreateMockChatClient("Partial summary."),
+ CompactionTriggers.Always,
+ minimumPreserved: 1,
+ target: TargetAfterOne);
+
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.User, "Q3"),
+ ]);
+
+ // Act
+ await strategy.CompactAsync(index);
+
+ // Assert — only 1 group should have been summarized (target met after first exclusion)
+ int excludedCount = index.Groups.Count(g => g.IsExcluded);
+ Assert.Equal(1, excludedCount);
+ }
+
+ [Fact]
+ public async Task CompactAsyncPreservesMultipleRecentGroupsAsync()
+ {
+ // Arrange — preserve 2
+ SummarizationCompactionStrategy strategy = new(
+ CreateMockChatClient("Summary."),
+ CompactionTriggers.Always,
+ minimumPreserved: 2);
+
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.Assistant, "A2"),
+ ]);
+
+ // Act
+ await strategy.CompactAsync(index);
+
+ // Assert — 2 oldest excluded, 2 newest preserved + 1 summary inserted
+ List included = [.. index.GetIncludedMessages()];
+ Assert.Equal(3, included.Count); // summary + Q2 + A2
+ Assert.Contains("[Summary]", included[0].Text);
+ Assert.Equal("Q2", included[1].Text);
+ Assert.Equal("A2", included[2].Text);
+ }
+
+ [Fact]
+ public async Task CompactAsyncWithSystemBetweenSummarizableGroupsAsync()
+ {
+ // Arrange — system group between user/assistant groups to exercise skip logic in loop
+ IChatClient mockClient = CreateMockChatClient("[Summary]");
+ SummarizationCompactionStrategy strategy = new(
+ mockClient,
+ CompactionTriggers.Always,
+ minimumPreserved: 1);
+
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.System, "System note"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert — summary inserted at 0, system group shifted to index 2
+ Assert.True(result);
+ Assert.Equal(MessageGroupKind.Summary, index.Groups[0].Kind);
+ Assert.Equal(MessageGroupKind.System, index.Groups[2].Kind);
+ Assert.False(index.Groups[2].IsExcluded); // System never excluded
+ }
+
+ [Fact]
+ public async Task CompactAsyncMaxSummarizableBoundsLoopExitAsync()
+ {
+ // Arrange — large MinimumPreserved so maxSummarizable is small, target never stops
+ IChatClient mockClient = CreateMockChatClient("[Summary]");
+ SummarizationCompactionStrategy strategy = new(
+ mockClient,
+ CompactionTriggers.Always,
+ minimumPreserved: 3,
+ target: _ => false);
+
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.Assistant, "A2"),
+ new ChatMessage(ChatRole.User, "Q3"),
+ new ChatMessage(ChatRole.Assistant, "A3"),
+ ]);
+
+ // Act — should only summarize 6-3 = 3 groups (not all 6)
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert — 3 preserved + 1 summary = 4 included
+ Assert.True(result);
+ Assert.Equal(4, index.IncludedGroupCount);
+ }
+
+ [Fact]
+ public async Task CompactAsyncWithPreExcludedGroupAsync()
+ {
+ // Arrange — pre-exclude a group so the count and loop both must skip it
+ IChatClient mockClient = CreateMockChatClient("[Summary]");
+ SummarizationCompactionStrategy strategy = new(
+ mockClient,
+ CompactionTriggers.Always,
+ minimumPreserved: 1);
+
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.Assistant, "A2"),
+ ]);
+ index.Groups[0].IsExcluded = true; // Pre-exclude Q1
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert
+ Assert.True(result);
+ Assert.True(index.Groups[0].IsExcluded); // Still excluded
+ }
+
+ [Fact]
+ public async Task CompactAsyncWithEmptyTextMessageInGroupAsync()
+ {
+ // Arrange — a message with null text (FunctionCallContent) in a summarized group
+ IChatClient mockClient = CreateMockChatClient("[Summary]");
+ SummarizationCompactionStrategy strategy = new(
+ mockClient,
+ CompactionTriggers.Always,
+ minimumPreserved: 1);
+
+ List messages =
+ [
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ ];
+
+ MessageIndex index = MessageIndex.Create(messages);
+
+ // Act — the tool-call group's message has null text
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert — compaction succeeded despite null text
+ Assert.True(result);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs
new file mode 100644
index 0000000000..3d4a34ac54
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs
@@ -0,0 +1,262 @@
+// 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.UnitTests.Compaction;
+
+///
+/// Contains tests for the class.
+///
+public class ToolResultCompactionStrategyTests
+{
+ [Fact]
+ public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
+ {
+ // Arrange — trigger requires > 1000 tokens
+ ToolResultCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
+
+ ChatMessage toolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
+ ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
+
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "What's the weather?"),
+ toolCall,
+ toolResult,
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task CompactAsyncCollapsesOldToolGroupsAsync()
+ {
+ // Arrange — always trigger
+ ToolResultCompactionStrategy strategy = new(
+ trigger: _ => true,
+ minimumPreserved: 1);
+
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]),
+ new ChatMessage(ChatRole.Tool, "Sunny and 72°F"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert
+ Assert.True(result);
+
+ List included = [.. groups.GetIncludedMessages()];
+ // Q1 + collapsed tool summary + Q2
+ Assert.Equal(3, included.Count);
+ Assert.Equal("Q1", included[0].Text);
+ Assert.Contains("[Tool calls: get_weather]", included[1].Text);
+ Assert.Equal("Q2", included[2].Text);
+ }
+
+ [Fact]
+ public async Task CompactAsyncPreservesRecentToolGroupsAsync()
+ {
+ // Arrange — protect 2 recent non-system groups (the tool group + Q2)
+ ToolResultCompactionStrategy strategy = new(
+ trigger: _ => true,
+ minimumPreserved: 3);
+
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]),
+ new ChatMessage(ChatRole.Tool, "Results"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert — all groups are in the protected window, nothing to collapse
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task CompactAsyncPreservesSystemMessagesAsync()
+ {
+ // Arrange
+ ToolResultCompactionStrategy strategy = new(
+ trigger: _ => true,
+ minimumPreserved: 1);
+
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.System, "You are helpful."),
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]),
+ new ChatMessage(ChatRole.Tool, "result"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ]);
+
+ // Act
+ await strategy.CompactAsync(groups);
+
+ // Assert
+ List included = [.. groups.GetIncludedMessages()];
+ Assert.Equal("You are helpful.", included[0].Text);
+ }
+
+ [Fact]
+ public async Task CompactAsyncExtractsMultipleToolNamesAsync()
+ {
+ // Arrange — assistant calls two tools
+ ToolResultCompactionStrategy strategy = new(
+ trigger: _ => true,
+ minimumPreserved: 1);
+
+ ChatMessage multiToolCall = new(ChatRole.Assistant,
+ [
+ new FunctionCallContent("c1", "get_weather"),
+ new FunctionCallContent("c2", "search_docs"),
+ ]);
+
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ multiToolCall,
+ new ChatMessage(ChatRole.Tool, "Sunny"),
+ new ChatMessage(ChatRole.Tool, "Found 3 docs"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ]);
+
+ // Act
+ await strategy.CompactAsync(groups);
+
+ // Assert
+ List included = [.. groups.GetIncludedMessages()];
+ string collapsed = included[1].Text!;
+ Assert.Contains("get_weather", collapsed);
+ Assert.Contains("search_docs", collapsed);
+ }
+
+ [Fact]
+ public async Task CompactAsyncNoToolGroupsReturnsFalseAsync()
+ {
+ // Arrange — trigger fires but no tool groups to collapse
+ ToolResultCompactionStrategy strategy = new(
+ trigger: _ => true,
+ minimumPreserved: 0);
+
+ MessageIndex groups = MessageIndex.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 CompactAsyncCompoundTriggerRequiresTokensAndToolCallsAsync()
+ {
+ // Arrange — compound: tokens > 0 AND has tool calls
+ ToolResultCompactionStrategy strategy = new(
+ CompactionTriggers.All(
+ CompactionTriggers.TokensExceed(0),
+ CompactionTriggers.HasToolCalls()),
+ minimumPreserved: 1);
+
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
+ new ChatMessage(ChatRole.Tool, "result"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Fact]
+ public async Task CompactAsyncTargetStopsCollapsingEarlyAsync()
+ {
+ // Arrange — 2 tool groups, target met after first collapse
+ int collapseCount = 0;
+ bool TargetAfterOne(MessageIndex _) => ++collapseCount >= 1;
+
+ ToolResultCompactionStrategy strategy = new(
+ trigger: _ => true,
+ minimumPreserved: 1,
+ target: TargetAfterOne);
+
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn1")]),
+ new ChatMessage(ChatRole.Tool, "result1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c2", "fn2")]),
+ new ChatMessage(ChatRole.Tool, "result2"),
+ new ChatMessage(ChatRole.User, "Q3"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert — only first tool group collapsed, second left intact
+ Assert.True(result);
+
+ // Count collapsed tool groups (excluded with ToolCall kind)
+ int collapsedToolGroups = 0;
+ foreach (MessageGroup group in index.Groups)
+ {
+ if (group.IsExcluded && group.Kind == MessageGroupKind.ToolCall)
+ {
+ collapsedToolGroups++;
+ }
+ }
+
+ Assert.Equal(1, collapsedToolGroups);
+ }
+
+ [Fact]
+ public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync()
+ {
+ // Arrange — pre-excluded and system groups in the enumeration
+ ToolResultCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 0);
+
+ List messages =
+ [
+ new ChatMessage(ChatRole.System, "System prompt"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
+ new ChatMessage(ChatRole.Tool, "Result 1"),
+ new ChatMessage(ChatRole.User, "Q1"),
+ ];
+
+ MessageIndex index = MessageIndex.Create(messages);
+ // Pre-exclude the user group
+ index.Groups[index.Groups.Count - 1].IsExcluded = true;
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert — system never excluded, pre-excluded skipped
+ Assert.True(result);
+ Assert.False(index.Groups[0].IsExcluded); // System stays
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs
new file mode 100644
index 0000000000..2783fa029c
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/TruncationCompactionStrategyTests.cs
@@ -0,0 +1,328 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Linq;
+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 CompactAsyncAlwaysTriggerCompactsToPreserveRecentAsync()
+ {
+ // Arrange — always-trigger means always compact
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "First"),
+ new ChatMessage(ChatRole.Assistant, "Response 1"),
+ new ChatMessage(ChatRole.User, "Second"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert
+ Assert.True(result);
+ Assert.Equal(1, groups.Groups.Count(g => !g.IsExcluded));
+ }
+
+ [Fact]
+ public async Task CompactAsyncTriggerNotMetReturnsFalseAsync()
+ {
+ // Arrange — trigger requires > 1000 tokens, conversation is tiny
+ TruncationCompactionStrategy strategy = new(
+ minimumPreserved: 1,
+ trigger: CompactionTriggers.TokensExceed(1000));
+
+ MessageIndex groups = MessageIndex.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 CompactAsyncTriggerMetExcludesOldestGroupsAsync()
+ {
+ // Arrange — trigger on groups > 2
+ TruncationCompactionStrategy strategy = new(
+ minimumPreserved: 1,
+ trigger: CompactionTriggers.GroupsExceed(2));
+
+ MessageIndex groups = MessageIndex.Create(
+ [
+ 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 strategy.CompactAsync(groups);
+
+ // Assert — incremental: excludes until GroupsExceed(2) is no longer met → 2 groups remain
+ Assert.True(result);
+ Assert.Equal(2, groups.IncludedGroupCount);
+ // Oldest 2 excluded, newest 2 kept
+ 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 CompactAsyncPreservesSystemMessagesAsync()
+ {
+ // Arrange
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.System, "You are helpful."),
+ new ChatMessage(ChatRole.User, "First"),
+ new ChatMessage(ChatRole.Assistant, "Response 1"),
+ new ChatMessage(ChatRole.User, "Second"),
+ ]);
+
+ // 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 excluded
+ Assert.True(groups.Groups[1].IsExcluded);
+ Assert.True(groups.Groups[2].IsExcluded);
+ // Most recent kept
+ Assert.False(groups.Groups[3].IsExcluded);
+ }
+
+ [Fact]
+ public async Task CompactAsyncPreservesToolCallGroupAtomicityAsync()
+ {
+ // Arrange
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
+
+ ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
+ ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
+ ChatMessage finalResponse = new(ChatRole.User, "Thanks!");
+
+ MessageIndex groups = MessageIndex.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 CompactAsyncSetsExcludeReasonAsync()
+ {
+ // Arrange
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
+ MessageIndex groups = MessageIndex.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 CompactAsyncSkipsAlreadyExcludedGroupsAsync()
+ {
+ // Arrange
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
+ MessageIndex groups = MessageIndex.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
+ }
+
+ [Fact]
+ public async Task CompactAsyncMinimumPreservedKeepsMultipleAsync()
+ {
+ // Arrange — keep 2 most recent
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 2);
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.Assistant, "A2"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert
+ Assert.True(result);
+ 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 CompactAsyncNothingToRemoveReturnsFalseAsync()
+ {
+ // Arrange — preserve 5 but only 2 groups
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 5);
+ MessageIndex groups = MessageIndex.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 CompactAsyncCustomTargetStopsEarlyAsync()
+ {
+ // Arrange — always trigger, custom target stops after 1 exclusion
+ int targetChecks = 0;
+ bool TargetAfterOne(MessageIndex _) => ++targetChecks >= 1;
+
+ TruncationCompactionStrategy strategy = new(
+ CompactionTriggers.Always,
+ minimumPreserved: 1,
+ target: TargetAfterOne);
+
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.User, "Q3"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert — only 1 group excluded (target met after first)
+ Assert.True(result);
+ Assert.True(groups.Groups[0].IsExcluded);
+ Assert.False(groups.Groups[1].IsExcluded);
+ Assert.False(groups.Groups[2].IsExcluded);
+ Assert.False(groups.Groups[3].IsExcluded);
+ }
+
+ [Fact]
+ public async Task CompactAsyncIncrementalStopsAtTargetAsync()
+ {
+ // Arrange — trigger on groups > 2, target is default (inverse of trigger: groups <= 2)
+ TruncationCompactionStrategy strategy = new(
+ CompactionTriggers.GroupsExceed(2),
+ minimumPreserved: 1);
+
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.Assistant, "A2"),
+ new ChatMessage(ChatRole.User, "Q3"),
+ ]);
+
+ // Act — 5 groups, trigger fires (5 > 2), compacts until groups <= 2
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert — should stop at 2 included groups (not go all the way to 1)
+ Assert.True(result);
+ Assert.Equal(2, groups.IncludedGroupCount);
+ }
+
+ [Fact]
+ public async Task CompactAsyncLoopExitsWhenMaxRemovableReachedAsync()
+ {
+ // Arrange — target never stops (always false), so the loop must exit via removed >= maxRemovable
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 2, target: CompactionTriggers.Never);
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ new ChatMessage(ChatRole.Assistant, "A2"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert — only 2 removed (maxRemovable = 4 - 2 = 2), 2 preserved
+ Assert.True(result);
+ Assert.Equal(2, groups.IncludedGroupCount);
+ Assert.True(groups.Groups[0].IsExcluded);
+ Assert.True(groups.Groups[1].IsExcluded);
+ Assert.False(groups.Groups[2].IsExcluded);
+ Assert.False(groups.Groups[3].IsExcluded);
+ }
+
+ [Fact]
+ public async Task CompactAsyncSkipsPreExcludedAndSystemGroupsAsync()
+ {
+ // Arrange — has excluded + system groups that the loop must skip
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
+ MessageIndex groups = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.System, "System"),
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ]);
+ // Pre-exclude one group
+ groups.Groups[1].IsExcluded = true;
+
+ // Act
+ bool result = await strategy.CompactAsync(groups);
+
+ // Assert — system preserved, pre-excluded skipped, A1 removed, Q2 preserved
+ Assert.True(result);
+ Assert.False(groups.Groups[0].IsExcluded); // System
+ Assert.True(groups.Groups[1].IsExcluded); // Pre-excluded Q1
+ Assert.True(groups.Groups[2].IsExcluded); // Newly excluded A1
+ Assert.False(groups.Groups[3].IsExcluded); // Preserved Q2
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj
index 7fa417b184..ffa4417f34 100644
--- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj
@@ -16,6 +16,7 @@
+