diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props
index 81ab56efd3..5e83e0d577 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 0e1f678003..04fbb6cd87 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..09e948d31c
--- /dev/null
+++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Program.cs
@@ -0,0 +1,120 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+// This sample demonstrates how to use a CompactionProvider with a compaction pipeline
+// as an AIContextProvider for an agent's in-run context management. 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.MessagesExceed(7)),
+
+ // 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 a CompactionProvider that uses the compaction pipeline.
+AIAgent agent =
+ agentChatClient
+ .AsBuilder()
+ // Note: Adding the CompactionProvider at the builder level means it will be applied to all agents
+ // built from this builder and will manage context for both agent messages and tool calls.
+ .UseAIContextProviders(new CompactionProvider(compactionPipeline))
+ .BuildAIAgent(
+ 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)]
+ },
+ // Note: AIContextProviders may be specified here instead of ChatClientBuilder.UseAIContextProviders.
+ // Specifying compaction at the agent level skips compaction in the function calling loop.
+ //AIContextProviders = [new CompactionProvider(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/Agent_Step18_CompactionPipeline/README.md b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md
new file mode 100644
index 0000000000..db3969a825
--- /dev/null
+++ b/dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline/README.md
@@ -0,0 +1,126 @@
+# Compaction Pipeline
+
+This sample demonstrates how to use a `CompactionProvider` with a `PipelineCompactionStrategy` to manage long conversation histories in a token-efficient way. The pipeline chains four compaction strategies, ordered from gentle to aggressive, so that the least disruptive strategy runs first and more aggressive strategies only activate when necessary.
+
+## What This Sample Shows
+
+- **`CompactionProvider`** — an `AIContextProvider` that applies a compaction strategy before each agent invocation, keeping only the most relevant messages within the model's context window
+- **`PipelineCompactionStrategy`** — chains multiple compaction strategies into an ordered pipeline; each strategy evaluates its own trigger independently and operates on the output of the previous one
+- **`ToolResultCompactionStrategy`** — collapses older tool-call groups into concise inline summaries (e.g., `[Tool calls: LookupPrice]`), activated by a message-count trigger
+- **`SummarizationCompactionStrategy`** — uses an LLM to compress older conversation spans into a single summary message, activated by a token-count trigger
+- **`SlidingWindowCompactionStrategy`** — retains only the most recent N user turns and their responses, activated by a turn-count trigger
+- **`TruncationCompactionStrategy`** — emergency backstop that drops the oldest groups until the conversation fits within a hard token budget
+- **`CompactionTriggers`** — factory methods (`MessagesExceed`, `TokensExceed`, `TurnsExceed`, `GroupsExceed`, `HasToolCalls`, `All`, `Any`) that control when each strategy activates
+
+## Concepts
+
+### Message groups
+
+The compaction engine organizes messages into atomic *groups* that are treated as indivisible units during compaction. A group is either:
+
+| Group kind | Contents |
+|---|---|
+| `System` | System prompt message(s) |
+| `User` | A single user message |
+| `ToolCall` | One assistant message with tool calls + the matching tool result messages |
+| `AssistantText` | A single assistant text-only message |
+
+Strategies exclude entire groups rather than individual messages, preserving the tool-call/result pairing required by most model APIs.
+
+### Compaction triggers
+
+A `CompactionTrigger` is a predicate evaluated against the current `MessageIndex`. When the trigger fires, the strategy performs compaction; when it does not fire, the strategy is skipped. Available triggers are:
+
+| Trigger | Activates when… |
+|---|---|
+| `CompactionTriggers.Always` | Always (unconditional) |
+| `CompactionTriggers.Never` | Never (disabled) |
+| `CompactionTriggers.MessagesExceed(n)` | Included message count > n |
+| `CompactionTriggers.TokensExceed(n)` | Included token count > n |
+| `CompactionTriggers.TurnsExceed(n)` | Included user-turn count > n |
+| `CompactionTriggers.GroupsExceed(n)` | Included group count > n |
+| `CompactionTriggers.HasToolCalls()` | At least one included tool-call group exists |
+| `CompactionTriggers.All(...)` | All supplied triggers fire (logical AND) |
+| `CompactionTriggers.Any(...)` | Any supplied trigger fires (logical OR) |
+
+### Pipeline ordering
+
+Order strategies from **least aggressive** to **most aggressive**. The pipeline runs every strategy whose trigger is met. Earlier strategies reduce the conversation gently so that later, more destructive strategies may not need to activate at all.
+
+```
+1. ToolResultCompactionStrategy – gentle: replaces verbose tool results with a short label
+2. SummarizationCompactionStrategy – moderate: LLM-summarizes older turns
+3. SlidingWindowCompactionStrategy – aggressive: drops turns beyond the window
+4. TruncationCompactionStrategy – emergency: hard token-budget enforcement
+```
+
+## Prerequisites
+
+- .NET 10 SDK or later
+- Azure OpenAI service endpoint and model deployment
+- Azure CLI installed and authenticated
+
+**Note**: This sample uses `DefaultAzureCredential`. Sign in with `az login` before running. For production, prefer a specific credential such as `ManagedIdentityCredential`. For more information, see the [Azure CLI authentication documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
+
+## Environment Variables
+
+```powershell
+$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Required
+$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
+```
+
+## Running the Sample
+
+```powershell
+cd dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline
+dotnet run
+```
+
+## Expected Behavior
+
+The sample runs a seven-turn shopping-assistant conversation with tool calls. After each turn it prints the current in-memory message count so you can observe the pipeline compacting the history as the conversation grows.
+
+Each of the four compaction strategies has a deliberately low threshold so that it activates during the short demonstration conversation. In a production scenario you would raise the thresholds to match your model's context window and cost requirements.
+
+## Customizing the Pipeline
+
+### Using a single strategy
+
+If you only need one compaction strategy, pass it directly to `CompactionProvider` without wrapping it in a pipeline:
+
+```csharp
+CompactionProvider provider =
+ new(new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(20)));
+```
+
+### Ad-hoc compaction outside the provider pipeline
+
+`CompactionProvider.CompactAsync` applies a strategy to an arbitrary list of messages without an active agent session:
+
+```csharp
+IEnumerable compacted = await CompactionProvider.CompactAsync(
+ new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(8000)),
+ existingMessages);
+```
+
+### Using a different model for summarization
+
+The `SummarizationCompactionStrategy` accepts any `IChatClient`. Use a smaller, cheaper model to reduce summarization cost:
+
+```csharp
+IChatClient summarizerChatClient = openAIClient.GetChatClient("gpt-4o-mini").AsIChatClient();
+new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(4000))
+```
+
+### Registering through `ChatClientAgentOptions`
+
+`AIContextProviders` can also be specified directly on `ChatClientAgentOptions` instead of calling `UseAIContextProviders` on the builder:
+
+```csharp
+AIAgent agent = agentChatClient
+ .AsBuilder()
+ .BuildAIAgent(new ChatClientAgentOptions
+ {
+ AIContextProviders = [new CompactionProvider(compactionPipeline)]
+ });
+```
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..46d8a7d1e3 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-retrieval reduction if configured
+ await CompactMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false);
}
return state.Messages;
@@ -101,7 +102,7 @@ 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 ?? []);
@@ -109,10 +110,17 @@ protected override async ValueTask StoreChatHistoryAsync(InvokedContext context,
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
{
- state.Messages = (await this.ChatReducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)).ToList();
+ // Apply pre-write reduction strategy if configured
+ await CompactMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false);
}
}
+ private static async Task CompactMessagesAsync(IChatReducer reducer, State state, CancellationToken cancellationToken = default)
+ {
+ state.Messages = [.. await reducer.ReduceAsync(state.Messages, cancellationToken).ConfigureAwait(false)];
+ return;
+ }
+
///
/// Represents the state of a stored in the .
///
diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
index 653f198402..8290c39974 100644
--- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientExtensions.cs
@@ -55,7 +55,7 @@ internal static IChatClient WithDefaultAgentMiddleware(this IChatClient chatClie
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/ChatMessageContentEquality.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatMessageContentEquality.cs
new file mode 100644
index 0000000000..8225490535
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatMessageContentEquality.cs
@@ -0,0 +1,158 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// Content-based equality comparison for instances.
+///
+internal static class ChatMessageContentEquality
+{
+ ///
+ /// Determines whether two instances represent the same message by content.
+ ///
+ ///
+ /// When both messages define a , identity is determined solely
+ /// by that identifier. Otherwise, the comparison falls through to ,
+ /// , and each item in .
+ ///
+ internal static bool ContentEquals(this ChatMessage? message, ChatMessage? other)
+ {
+ if (ReferenceEquals(message, other))
+ {
+ return true;
+ }
+
+ if (message is null || other is null)
+ {
+ return false;
+ }
+
+ // A matching MessageId is sufficient.
+ if (message.MessageId is not null && other.MessageId is not null)
+ {
+ return string.Equals(message.MessageId, other.MessageId, StringComparison.Ordinal);
+ }
+
+ if (message.Role != other.Role)
+ {
+ return false;
+ }
+
+ if (!string.Equals(message.AuthorName, other.AuthorName, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ return ContentsEqual(message.Contents, other.Contents);
+ }
+
+ private static bool ContentsEqual(IList left, IList right)
+ {
+ if (left.Count != right.Count)
+ {
+ return false;
+ }
+
+ for (int i = 0; i < left.Count; i++)
+ {
+ if (!ContentItemEquals(left[i], right[i]))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool ContentItemEquals(AIContent left, AIContent right)
+ {
+ if (ReferenceEquals(left, right))
+ {
+ return true;
+ }
+
+ if (left.GetType() != right.GetType())
+ {
+ return false;
+ }
+
+ return (left, right) switch
+ {
+ (TextContent a, TextContent b) => TextContentEquals(a, b),
+ (TextReasoningContent a, TextReasoningContent b) => TextReasoningContentEquals(a, b),
+ (DataContent a, DataContent b) => DataContentEquals(a, b),
+ (UriContent a, UriContent b) => UriContentEquals(a, b),
+ (ErrorContent a, ErrorContent b) => ErrorContentEquals(a, b),
+ (FunctionCallContent a, FunctionCallContent b) => FunctionCallContentEquals(a, b),
+ (FunctionResultContent a, FunctionResultContent b) => FunctionResultContentEquals(a, b),
+ (HostedFileContent a, HostedFileContent b) => HostedFileContentEquals(a, b),
+ (AIContent a, AIContent b) => a.GetType() == b.GetType(),
+ };
+ }
+
+ private static bool TextContentEquals(TextContent a, TextContent b) =>
+ string.Equals(a.Text, b.Text, StringComparison.Ordinal);
+
+ private static bool TextReasoningContentEquals(TextReasoningContent a, TextReasoningContent b) =>
+ string.Equals(a.Text, b.Text, StringComparison.Ordinal) &&
+ string.Equals(a.ProtectedData, b.ProtectedData, StringComparison.Ordinal);
+
+ private static bool DataContentEquals(DataContent a, DataContent b) =>
+ string.Equals(a.MediaType, b.MediaType, StringComparison.Ordinal) &&
+ string.Equals(a.Name, b.Name, StringComparison.Ordinal) &&
+ a.Data.Span.SequenceEqual(b.Data.Span);
+
+ private static bool UriContentEquals(UriContent a, UriContent b) =>
+ Equals(a.Uri, b.Uri) &&
+ string.Equals(a.MediaType, b.MediaType, StringComparison.Ordinal);
+
+ private static bool ErrorContentEquals(ErrorContent a, ErrorContent b) =>
+ string.Equals(a.Message, b.Message, StringComparison.Ordinal) &&
+ string.Equals(a.ErrorCode, b.ErrorCode, StringComparison.Ordinal);
+
+ private static bool FunctionCallContentEquals(FunctionCallContent a, FunctionCallContent b) =>
+ string.Equals(a.CallId, b.CallId, StringComparison.Ordinal) &&
+ string.Equals(a.Name, b.Name, StringComparison.Ordinal) &&
+ ArgumentsEqual(a.Arguments, b.Arguments);
+
+ private static bool FunctionResultContentEquals(FunctionResultContent a, FunctionResultContent b) =>
+ string.Equals(a.CallId, b.CallId, StringComparison.Ordinal) &&
+ Equals(a.Result, b.Result);
+
+ private static bool ArgumentsEqual(IDictionary? left, IDictionary? right)
+ {
+ if (ReferenceEquals(left, right))
+ {
+ return true;
+ }
+
+ if (left is null || right is null)
+ {
+ return false;
+ }
+
+ if (left.Count != right.Count)
+ {
+ return false;
+ }
+
+ foreach (KeyValuePair entry in left)
+ {
+ if (!right.TryGetValue(entry.Key, out object? value) || !Equals(entry.Value, value))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool HostedFileContentEquals(HostedFileContent a, HostedFileContent b) =>
+ string.Equals(a.FileId, b.FileId, StringComparison.Ordinal) &&
+ string.Equals(a.MediaType, b.MediaType, StringComparison.Ordinal) &&
+ string.Equals(a.Name, b.Name, StringComparison.Ordinal);
+}
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..f97088950e
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs
@@ -0,0 +1,82 @@
+// 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.Extensions.Logging;
+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.
+ ///
+ public ChatReducerCompactionStrategy(IChatReducer chatReducer, CompactionTrigger trigger)
+ : base(trigger)
+ {
+ this.ChatReducer = Throw.IfNull(chatReducer);
+ }
+
+ ///
+ /// Gets the chat reducer used to reduce messages.
+ ///
+ public IChatReducer ChatReducer { get; }
+
+ ///
+ protected override async ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken)
+ {
+ // No need to short-circuit on empty conversations, this is handled by .
+ List includedMessages = [.. index.GetIncludedMessages()];
+
+ 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/CompactionLogMessages.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionLogMessages.cs
new file mode 100644
index 0000000000..f6461f7a58
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionLogMessages.cs
@@ -0,0 +1,101 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+#pragma warning disable SYSLIB1006 // Multiple logging methods cannot use the same event id within a class
+
+///
+/// Extensions for logging compaction diagnostics.
+///
+///
+/// This extension uses the to
+/// generate logging code at compile time to achieve optimized code.
+///
+[ExcludeFromCodeCoverage]
+internal static partial class CompactionLogMessages
+{
+ ///
+ /// Logs when compaction is skipped because the trigger condition was not met.
+ ///
+ [LoggerMessage(
+ Level = LogLevel.Trace,
+ Message = "Compaction skipped for {StrategyName}: trigger condition not met or insufficient groups.")]
+ public static partial void LogCompactionSkipped(
+ this ILogger logger,
+ string strategyName);
+
+ ///
+ /// Logs compaction completion with before/after metrics.
+ ///
+ [LoggerMessage(
+ Level = LogLevel.Debug,
+ Message = "Compaction completed: {StrategyName} in {DurationMs}ms — Messages {BeforeMessages}→{AfterMessages}, Groups {BeforeGroups}→{AfterGroups}, Tokens {BeforeTokens}→{AfterTokens}")]
+ public static partial void LogCompactionCompleted(
+ this ILogger logger,
+ string strategyName,
+ long durationMs,
+ int beforeMessages,
+ int afterMessages,
+ int beforeGroups,
+ int afterGroups,
+ int beforeTokens,
+ int afterTokens);
+
+ ///
+ /// Logs when the compaction provider skips compaction.
+ ///
+ [LoggerMessage(
+ Level = LogLevel.Trace,
+ Message = "CompactionProvider skipped: {Reason}.")]
+ public static partial void LogCompactionProviderSkipped(
+ this ILogger logger,
+ string reason);
+
+ ///
+ /// Logs when the compaction provider begins applying a compaction strategy.
+ ///
+ [LoggerMessage(
+ Level = LogLevel.Debug,
+ Message = "CompactionProvider applying compaction to {MessageCount} messages using {StrategyName}.")]
+ public static partial void LogCompactionProviderApplying(
+ this ILogger logger,
+ int messageCount,
+ string strategyName);
+
+ ///
+ /// Logs when the compaction provider has applied compaction with result metrics.
+ ///
+ [LoggerMessage(
+ Level = LogLevel.Debug,
+ Message = "CompactionProvider compaction applied: messages {BeforeMessages}→{AfterMessages}.")]
+ public static partial void LogCompactionProviderApplied(
+ this ILogger logger,
+ int beforeMessages,
+ int afterMessages);
+
+ ///
+ /// Logs when a summarization LLM call is starting.
+ ///
+ [LoggerMessage(
+ Level = LogLevel.Debug,
+ Message = "Summarization starting for {GroupCount} groups ({MessageCount} messages) using {ChatClientType}.")]
+ public static partial void LogSummarizationStarting(
+ this ILogger logger,
+ int groupCount,
+ int messageCount,
+ string chatClientType);
+
+ ///
+ /// Logs when a summarization LLM call has completed.
+ ///
+ [LoggerMessage(
+ Level = LogLevel.Debug,
+ Message = "Summarization completed: summary length {SummaryLength} characters, inserted at index {InsertIndex}.")]
+ public static partial void LogSummarizationCompleted(
+ this ILogger logger,
+ int summaryLength,
+ int insertIndex);
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs
new file mode 100644
index 0000000000..8873213e76
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs
@@ -0,0 +1,186 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// A that applies a to compact
+/// the message list before each agent invocation.
+///
+///
+///
+/// This provider performs in-run compaction by organizing messages into atomic groups (preserving
+/// tool-call/result pairings) before applying compaction logic. Only included messages are forwarded
+/// to the agent's underlying chat client.
+///
+///
+/// The can be added to an agent's context provider pipeline
+/// via or via UseAIContextProviders
+/// on a or .
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class CompactionProvider : AIContextProvider
+{
+ private readonly CompactionStrategy _compactionStrategy;
+ private readonly ProviderSessionState _sessionState;
+ private readonly ILoggerFactory? _loggerFactory;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The compaction strategy to apply before each invocation.
+ ///
+ /// An optional key used to store the provider state in the . Provide
+ /// an explicit value if configuring multiple agents with different compaction strategies that will interact
+ /// in the same session.
+ ///
+ ///
+ /// An optional used to create a logger for provider diagnostics.
+ /// When , logging is disabled.
+ ///
+ /// is .
+ public CompactionProvider(CompactionStrategy compactionStrategy, string? stateKey = null, ILoggerFactory? loggerFactory = null)
+ {
+ this._compactionStrategy = Throw.IfNull(compactionStrategy);
+ stateKey ??= compactionStrategy.GetType().Name;
+ this.StateKeys = [stateKey];
+ this._sessionState = new ProviderSessionState(
+ _ => new State(),
+ stateKey,
+ AgentJsonUtilities.DefaultOptions);
+ this._loggerFactory = loggerFactory;
+ }
+
+ ///
+ public override IReadOnlyList StateKeys { get; }
+
+ ///
+ /// Applies compaction strategy to the provided message list and returns the compacted messages.
+ /// This can be used for ad-hoc compaction outside of the provider pipeline.
+ ///
+ /// The compaction strategy to apply before each invocation.
+ /// The messages to compact
+ /// An optional for emitting compaction diagnostics.
+ /// The to monitor for cancellation requests.
+ /// An enumeration of the compacted instances.
+ public static async Task> CompactAsync(CompactionStrategy compactionStrategy, IEnumerable messages, ILogger? logger = null, CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(compactionStrategy);
+ Throw.IfNull(messages);
+
+ List messageList = messages as List ?? [.. messages];
+ MessageIndex messageIndex = MessageIndex.Create(messageList);
+
+ await compactionStrategy.CompactAsync(messageIndex, logger, cancellationToken).ConfigureAwait(false);
+
+ return messageIndex.GetIncludedMessages();
+ }
+
+ ///
+ /// Applies the compaction strategy to the accumulated message list before forwarding it to the agent.
+ ///
+ /// Contains the request context including all accumulated messages.
+ /// The to monitor for cancellation requests.
+ ///
+ /// A task that represents the asynchronous operation. The task result contains an
+ /// with the compacted message list.
+ ///
+ protected override async ValueTask InvokingCoreAsync(InvokingContext context, CancellationToken cancellationToken = default)
+ {
+ using Activity? activity = CompactionTelemetry.ActivitySource.StartActivity(CompactionTelemetry.ActivityNames.CompactionProviderInvoke);
+
+ ILoggerFactory loggerFactory = this.GetLoggerFactory(context.Agent);
+ ILogger logger = loggerFactory.CreateLogger();
+
+ AgentSession? session = context.Session;
+ IEnumerable? allMessages = context.AIContext.Messages;
+
+ if (session is null || allMessages is null)
+ {
+ logger.LogCompactionProviderSkipped("no session or no messages");
+ return context.AIContext;
+ }
+
+ ChatClientAgentSession? chatClientSession = session.GetService();
+ if (chatClientSession is not null &&
+ !string.IsNullOrWhiteSpace(chatClientSession.ConversationId))
+ {
+ logger.LogCompactionProviderSkipped("session managed by remote service");
+ return context.AIContext;
+ }
+
+ List messageList = allMessages as List ?? [.. allMessages];
+
+ State state = this._sessionState.GetOrInitializeState(session);
+
+ MessageIndex messageIndex;
+ if (state.MessageGroups.Count > 0)
+ {
+ // Update existing index with any new messages appended since the last call.
+ messageIndex = new([.. state.MessageGroups]);
+ messageIndex.Update(messageList);
+ }
+ else
+ {
+ // First pass — initialize the message index from scratch.
+ messageIndex = MessageIndex.Create(messageList);
+ }
+
+ string strategyName = this._compactionStrategy.GetType().Name;
+ int beforeMessages = messageIndex.IncludedMessageCount;
+ logger.LogCompactionProviderApplying(beforeMessages, strategyName);
+
+ // Apply compaction
+ await this._compactionStrategy.CompactAsync(
+ messageIndex,
+ loggerFactory.CreateLogger(this._compactionStrategy.GetType()),
+ cancellationToken).ConfigureAwait(false);
+
+ int afterMessages = messageIndex.IncludedMessageCount;
+ if (afterMessages < beforeMessages)
+ {
+ logger.LogCompactionProviderApplied(beforeMessages, afterMessages);
+ }
+
+ // Persist the index
+ state.MessageGroups.Clear();
+ state.MessageGroups.AddRange(messageIndex.Groups);
+
+ return new AIContext
+ {
+ Instructions = context.AIContext.Instructions,
+ Messages = messageIndex.GetIncludedMessages(),
+ Tools = context.AIContext.Tools
+ };
+ }
+
+ private ILoggerFactory GetLoggerFactory(AIAgent agent) =>
+ this._loggerFactory ??
+ agent.GetService()?.GetService() ??
+ NullLoggerFactory.Instance;
+
+ ///
+ /// Represents the persisted state of a stored in the .
+ ///
+ internal sealed class State
+ {
+ ///
+ /// Gets or sets the message index groups used for incremental compaction updates.
+ ///
+ [JsonPropertyName("messagegroups")]
+ public List MessageGroups { 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..480b104fe2
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionStrategy.cs
@@ -0,0 +1,156 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+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; }
+
+ ///
+ /// 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 for emitting compaction diagnostics.
+ /// The to monitor for cancellation requests.
+ /// A task whose result is if any compaction was performed, otherwise.
+ protected abstract ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken);
+
+ ///
+ /// Evaluates the and, when it fires, delegates to
+ /// and reports compaction metrics.
+ ///
+ /// The message index to compact. The strategy mutates this collection in place.
+ /// An optional for emitting compaction diagnostics. When , logging is disabled.
+ /// The to monitor for cancellation requests.
+ /// A task representing the asynchronous operation. The task result is if compaction occurred, otherwise.
+ public async ValueTask CompactAsync(MessageIndex index, ILogger? logger = null, CancellationToken cancellationToken = default)
+ {
+ string strategyName = this.GetType().Name;
+ logger ??= NullLogger.Instance;
+
+ using Activity? activity = CompactionTelemetry.ActivitySource.StartActivity(CompactionTelemetry.ActivityNames.Compact);
+ activity?.SetTag(CompactionTelemetry.Tags.Strategy, strategyName);
+
+ if (index.IncludedNonSystemGroupCount <= 1 || !this.Trigger(index))
+ {
+ activity?.SetTag(CompactionTelemetry.Tags.Triggered, false);
+ logger.LogCompactionSkipped(strategyName);
+ return false;
+ }
+
+ activity?.SetTag(CompactionTelemetry.Tags.Triggered, true);
+
+ int beforeTokens = index.IncludedTokenCount;
+ int beforeGroups = index.IncludedGroupCount;
+ int beforeMessages = index.IncludedMessageCount;
+
+ Stopwatch stopwatch = Stopwatch.StartNew();
+
+ bool compacted = await this.CompactCoreAsync(index, logger, cancellationToken).ConfigureAwait(false);
+
+ stopwatch.Stop();
+
+ activity?.SetTag(CompactionTelemetry.Tags.Compacted, compacted);
+
+ if (compacted)
+ {
+ activity?
+ .SetTag(CompactionTelemetry.Tags.BeforeTokens, beforeTokens)
+ .SetTag(CompactionTelemetry.Tags.AfterTokens, index.IncludedTokenCount)
+ .SetTag(CompactionTelemetry.Tags.BeforeMessages, beforeMessages)
+ .SetTag(CompactionTelemetry.Tags.AfterMessages, index.IncludedMessageCount)
+ .SetTag(CompactionTelemetry.Tags.BeforeGroups, beforeGroups)
+ .SetTag(CompactionTelemetry.Tags.AfterGroups, index.IncludedGroupCount)
+ .SetTag(CompactionTelemetry.Tags.DurationMs, stopwatch.ElapsedMilliseconds);
+
+ logger.LogCompactionCompleted(
+ strategyName,
+ stopwatch.ElapsedMilliseconds,
+ beforeMessages,
+ index.IncludedMessageCount,
+ beforeGroups,
+ index.IncludedGroupCount,
+ beforeTokens,
+ index.IncludedTokenCount);
+ }
+
+ return compacted;
+ }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTelemetry.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTelemetry.cs
new file mode 100644
index 0000000000..11b37dfa82
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTelemetry.cs
@@ -0,0 +1,45 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// Provides shared telemetry infrastructure for compaction operations.
+///
+internal static class CompactionTelemetry
+{
+ ///
+ /// The used to create activities for compaction operations.
+ ///
+ public static readonly ActivitySource ActivitySource = new(OpenTelemetryConsts.DefaultSourceName);
+
+ ///
+ /// Activity names used by compaction tracing.
+ ///
+ public static class ActivityNames
+ {
+ public const string Compact = "compaction.compact";
+ public const string CompactionProviderInvoke = "compaction.provider.invoke";
+ public const string Summarize = "compaction.summarize";
+ }
+
+ ///
+ /// Tag names used on compaction activities.
+ ///
+ public static class Tags
+ {
+ public const string Strategy = "compaction.strategy";
+ public const string Triggered = "compaction.triggered";
+ public const string Compacted = "compaction.compacted";
+ public const string BeforeTokens = "compaction.before.tokens";
+ public const string AfterTokens = "compaction.after.tokens";
+ public const string BeforeMessages = "compaction.before.messages";
+ public const string AfterMessages = "compaction.after.messages";
+ public const string BeforeGroups = "compaction.before.groups";
+ public const string AfterGroups = "compaction.after.groups";
+ public const string DurationMs = "compaction.duration_ms";
+ public const string GroupsSummarized = "compaction.groups_summarized";
+ public const string SummaryLength = "compaction.summary_length";
+ }
+}
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..ff8bde6008
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTrigger.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// Defines a condition based on metrics used by a
+/// to determine when to trigger compaction and when the target compaction threshold has been met.
+///
+/// An index over conversation messages that provides group, token, message, and turn metrics.
+/// to indicate the condition has been met; otherwise .
+[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..18d83af06d
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionTriggers.cs
@@ -0,0 +1,134 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Agents.AI.Compaction;
+
+///
+/// Factory to create predicates.
+///
+///
+///
+/// A defines a condition based on metrics used
+/// by a to determine when to trigger compaction and when the target
+/// compaction threshold has been met.
+///
+///
+/// Combine triggers with or for compound conditions.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public static class CompactionTriggers
+{
+ ///
+ /// Always trigger, regardless of the message index state.
+ ///
+ public static readonly CompactionTrigger Always =
+ _ => true;
+
+ ///
+ /// Never trigger, 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.
+ /// 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.
+ /// 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.
+ /// 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.
+ /// A that evaluates included turn count.
+ ///
+ ///
+ /// A user turn starts with a group and includes all subsequent
+ /// non-user, non-system groups until the next user group or end of conversation. Each group is assigned
+ /// a indicating which user turn it belongs to.
+ /// System messages () are always assigned a
+ /// since they never belong to a user turn.
+ ///
+ ///
+ /// The turn count is the number of distinct values defined by .
+ ///
+ ///
+ 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.
+ /// 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..6e9a81567a
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageGroup.cs
@@ -0,0 +1,117 @@
+// 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.
+///
+///
+[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]
+ internal 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 user turn index this group belongs to, or for groups
+ /// that precede the first user message (e.g., system messages). A turn index of 0
+ /// corresponds with any non-system message that precedes the first user message,
+ /// turn index 1 corresponds with the first user message and its subsequent non-user
+ /// messages, and so on...
+ ///
+ ///
+ /// A turn starts with a group and includes all subsequent
+ /// non-user, non-system groups until the next user group or end of conversation. System messages
+ /// () are always assigned a turn index
+ /// since they never belong to a user turn.
+ ///
+ 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..bafb9df7b2
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/MessageIndex.cs
@@ -0,0 +1,486 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+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;
+
+///
+/// A collection of instances and derived metrics based on a flat list of objects.
+///
+///
+/// provides structural grouping of messages into logical units. Individual
+/// groups can be marked as excluded without being removed, allowing compaction strategies to toggle visibility while preserving
+/// the full history for diagnostics or storage. Metrics are provided both including and excluding excluded groups,
+/// allowing strategies to make informed decisions based on the impact of potential exclusions.
+///
+[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
+public sealed class MessageIndex
+{
+ private int _currentTurn;
+ private ChatMessage? _lastProcessedMessage;
+
+ ///
+ /// Gets the list of message groups in this collection.
+ ///
+ public IList Groups { get; }
+
+ ///
+ /// Gets the tokenizer used for computing token counts, or if token counts are estimated.
+ ///
+ public Tokenizer? Tokenizer { get; }
+
+ ///
+ /// Initializes a new instance of the class with the specified groups.
+ ///
+ /// The message groups.
+ /// An optional tokenizer retained for computing token counts when adding new groups.
+ public MessageIndex(IList groups, Tokenizer? tokenizer = null)
+ {
+ this.Tokenizer = tokenizer;
+ this.Groups = groups;
+
+ // Restore turn counter and last processed message from the groups
+ for (int index = groups.Count - 1; index >= 0; --index)
+ {
+ if (this._lastProcessedMessage is null && this.Groups[index].Kind != MessageGroupKind.Summary)
+ {
+ IReadOnlyList groupMessages = this.Groups[index].Messages;
+ this._lastProcessedMessage = groupMessages[^1];
+ }
+
+ if (this.Groups[index].TurnIndex.HasValue)
+ {
+ this._currentTurn = this.Groups[index].TurnIndex!.Value;
+
+ // Both values restored — no need to keep scanning
+ if (this._lastProcessedMessage is not null)
+ {
+ 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.
+ ///
+ ///
+ internal static MessageIndex Create(IList messages, Tokenizer? tokenizer = null)
+ {
+ 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.
+ ///
+ ///
+ ///
+ /// Uses reference equality on the last processed message to detect changes. If the last
+ /// processed message is found within , only the messages
+ /// after that position are processed and appended as new groups. Existing groups and their
+ /// compaction state (exclusions) are preserved.
+ ///
+ ///
+ /// If the last processed message is not found (e.group., the message list was replaced entirely
+ /// or a sliding window shifted past it), all groups are cleared and rebuilt from scratch.
+ ///
+ ///
+ /// If the last message in is the same reference as the last
+ /// processed message, no work is performed.
+ ///
+ ///
+ internal void Update(IList allMessages)
+ {
+ if (allMessages.Count == 0)
+ {
+ this.Groups.Clear();
+ this._currentTurn = 0;
+ this._lastProcessedMessage = null;
+ return;
+ }
+
+ // If the last message is unchanged and the list hasn't shrunk, there is nothing new to process.
+ if (this._lastProcessedMessage is not null &&
+ allMessages.Count >= this.RawMessageCount &&
+ allMessages[allMessages.Count - 1].ContentEquals(this._lastProcessedMessage))
+ {
+ return;
+ }
+
+ // Walk backwards to locate where we left off.
+ int foundIndex = -1;
+ if (this._lastProcessedMessage is not null)
+ {
+ for (int i = allMessages.Count - 1; i >= 0; --i)
+ {
+ if (allMessages[i].ContentEquals(this._lastProcessedMessage))
+ {
+ foundIndex = i;
+ break;
+ }
+ }
+ }
+
+ if (foundIndex < 0)
+ {
+ // Last processed message not found — total rebuild.
+ this.Groups.Clear();
+ this._currentTurn = 0;
+ this.AppendFromMessages(allMessages, 0);
+ return;
+ }
+
+ // Guard against a sliding window that removed messages from the front:
+ // the number of messages up to (and including) the found position must
+ // match the number of messages already represented by existing groups.
+ if (foundIndex + 1 < this.RawMessageCount)
+ {
+ // Front of the message list was trimmed — rebuild.
+ this.Groups.Clear();
+ this._currentTurn = 0;
+ this.AppendFromMessages(allMessages, 0);
+ return;
+ }
+
+ // Process only the delta messages.
+ this.AppendFromMessages(allMessages, foundIndex + 1);
+ }
+
+ 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++;
+ }
+ }
+
+ if (messages.Count > 0)
+ {
+ this._lastProcessedMessage = messages[^1];
+ }
+ }
+
+ ///
+ /// 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(group => group.MessageCount);
+
+ ///
+ /// Gets the total UTF-8 byte count across all groups, including excluded ones.
+ ///
+ public int TotalByteCount => this.Groups.Sum(group => group.ByteCount);
+
+ ///
+ /// Gets the total token count across all groups, including excluded ones.
+ ///
+ public int TotalTokenCount => this.Groups.Sum(group => group.TokenCount);
+
+ ///
+ /// Gets the total number of groups that are not excluded.
+ ///
+ public int IncludedGroupCount => this.Groups.Count(group => !group.IsExcluded);
+
+ ///
+ /// Gets the total number of messages across all included (non-excluded) groups.
+ ///
+ public int IncludedMessageCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.MessageCount);
+
+ ///
+ /// Gets the total UTF-8 byte count across all included (non-excluded) groups.
+ ///
+ public int IncludedByteCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.ByteCount);
+
+ ///
+ /// Gets the total token count across all included (non-excluded) groups.
+ ///
+ public int IncludedTokenCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.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 && group.TurnIndex is not null && group.TurnIndex > 0).Select(group => group.TurnIndex).Distinct().Count();
+
+ ///
+ /// Gets the total number of groups across all included (non-excluded) groups that are not .
+ ///
+ public int IncludedNonSystemGroupCount => this.Groups.Count(group => !group.IsExcluded && group.Kind != MessageGroupKind.System);
+
+ ///
+ /// Gets the total number of original messages (that are not summaries).
+ ///
+ public int RawMessageCount => this.Groups.Where(group => group.Kind != MessageGroupKind.Summary).Sum(group => group.MessageCount);
+
+ ///
+ /// 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(group => group.TurnIndex == turnIndex);
+
+ ///
+ /// Computes the UTF-8 byte count for a set of messages across all content types.
+ ///
+ /// The messages to compute byte count for.
+ /// The total UTF-8 byte count of all message content.
+ internal static int ComputeByteCount(IReadOnlyList messages)
+ {
+ int total = 0;
+ for (int i = 0; i < messages.Count; i++)
+ {
+ IList contents = messages[i].Contents;
+ for (int j = 0; j < contents.Count; j++)
+ {
+ total += ComputeContentByteCount(contents[j]);
+ }
+ }
+
+ 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 content.
+ ///
+ /// Text-bearing content ( and )
+ /// is tokenized directly. All other content types estimate tokens as byteCount / 4.
+ ///
+ internal static int ComputeTokenCount(IReadOnlyList messages, Tokenizer tokenizer)
+ {
+ int total = 0;
+ for (int i = 0; i < messages.Count; i++)
+ {
+ IList contents = messages[i].Contents;
+ for (int j = 0; j < contents.Count; j++)
+ {
+ AIContent content = contents[j];
+ switch (content)
+ {
+ case TextContent text:
+ if (text.Text is { Length: > 0 } t)
+ {
+ total += tokenizer.CountTokens(t);
+ }
+
+ break;
+
+ case TextReasoningContent reasoning:
+ if (reasoning.Text is { Length: > 0 } rt)
+ {
+ total += tokenizer.CountTokens(rt);
+ }
+
+ if (reasoning.ProtectedData is { Length: > 0 } pd)
+ {
+ total += tokenizer.CountTokens(pd);
+ }
+
+ break;
+
+ default:
+ total += ComputeContentByteCount(content) / 4;
+ break;
+ }
+ }
+ }
+
+ return total;
+ }
+
+ private static int ComputeContentByteCount(AIContent content)
+ {
+ switch (content)
+ {
+ case TextContent text:
+ return GetStringByteCount(text.Text);
+
+ case TextReasoningContent reasoning:
+ return GetStringByteCount(reasoning.Text) + GetStringByteCount(reasoning.ProtectedData);
+
+ case DataContent data:
+ return data.Data.Length + GetStringByteCount(data.MediaType) + GetStringByteCount(data.Name);
+
+ case UriContent uri:
+ return (uri.Uri is Uri uriValue ? GetStringByteCount(uriValue.OriginalString) : 0) + GetStringByteCount(uri.MediaType);
+
+ case FunctionCallContent call:
+ int callBytes = GetStringByteCount(call.CallId) + GetStringByteCount(call.Name);
+ if (call.Arguments is not null)
+ {
+ foreach (KeyValuePair arg in call.Arguments)
+ {
+ callBytes += GetStringByteCount(arg.Key);
+ callBytes += GetStringByteCount(arg.Value?.ToString());
+ }
+ }
+
+ return callBytes;
+
+ case FunctionResultContent result:
+ return GetStringByteCount(result.CallId) + GetStringByteCount(result.Result?.ToString());
+
+ case ErrorContent error:
+ return GetStringByteCount(error.Message) + GetStringByteCount(error.ErrorCode) + GetStringByteCount(error.Details);
+
+ case HostedFileContent file:
+ return GetStringByteCount(file.FileId) + GetStringByteCount(file.MediaType) + GetStringByteCount(file.Name);
+
+ default:
+ return 0;
+ }
+ }
+
+ private static int GetStringByteCount(string? value) =>
+ value is { Length: > 0 } ? Encoding.UTF8.GetByteCount(value) : 0;
+
+ 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..c3edbfb891
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/PipelineCompactionStrategy.cs
@@ -0,0 +1,63 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+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 ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken)
+ {
+ bool anyCompacted = false;
+
+ foreach (CompactionStrategy strategy in this.Strategies)
+ {
+ bool compacted = await strategy.CompactAsync(index, logger, 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..ac33a81aff
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SlidingWindowCompactionStrategy.cs
@@ -0,0 +1,147 @@
+// 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.Logging;
+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 ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken)
+ {
+ // Forward pass: count non-system included groups and pre-index them by TurnIndex.
+ int nonSystemIncludedCount = 0;
+ Dictionary> turnGroups = [];
+ List turnOrder = [];
+
+ for (int i = 0; i < index.Groups.Count; i++)
+ {
+ MessageGroup group = index.Groups[i];
+ if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
+ {
+ nonSystemIncludedCount++;
+
+ if (group.TurnIndex is int turnIndex)
+ {
+ if (!turnGroups.TryGetValue(turnIndex, out List? indices))
+ {
+ indices = [];
+ turnGroups[turnIndex] = indices;
+ turnOrder.Add(turnIndex);
+ }
+
+ indices.Add(i);
+ }
+ }
+ }
+
+ // Backward pass: identify the protected tail (last MinimumPreserved non-system included groups).
+ int protectedCount = Math.Min(this.MinimumPreserved, nonSystemIncludedCount);
+#if NETSTANDARD
+ HashSet protectedIndices = [];
+#else
+ HashSet protectedIndices = new(protectedCount);
+#endif
+ int remaining = protectedCount;
+ for (int i = index.Groups.Count - 1; i >= 0 && remaining > 0; i--)
+ {
+ MessageGroup group = index.Groups[i];
+ if (!group.IsExcluded && group.Kind != MessageGroupKind.System)
+ {
+ protectedIndices.Add(i);
+ remaining--;
+ }
+ }
+
+ // Exclude turns oldest-first using the pre-built index, checking target after each turn.
+ bool compacted = false;
+
+ for (int t = 0; t < turnOrder.Count; t++)
+ {
+ List groupIndices = turnGroups[turnOrder[t]];
+ bool anyExcluded = false;
+
+ for (int g = 0; g < groupIndices.Count; g++)
+ {
+ int idx = groupIndices[g];
+ if (!protectedIndices.Contains(idx))
+ {
+ index.Groups[idx].IsExcluded = true;
+ index.Groups[idx].ExcludeReason = $"Excluded by {nameof(SlidingWindowCompactionStrategy)}";
+ anyExcluded = true;
+ }
+ }
+
+ if (anyExcluded)
+ {
+ compacted = true;
+
+ if (this.Target(index))
+ {
+ break;
+ }
+ }
+ }
+
+ return new ValueTask(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..7de30fab41
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/SummarizationCompactionStrategy.cs
@@ -0,0 +1,180 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging;
+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. 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 ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, 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
+ List summarizationMessages = [new ChatMessage(ChatRole.System, this.SummarizationPrompt)];
+ 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;
+ }
+
+ // Collect messages from this group for summarization
+ summarizationMessages.AddRange(group.Messages);
+
+ 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)
+ logger.LogSummarizationStarting(summarized, summarizationMessages.Count - 1, this.ChatClient.GetType().Name);
+
+ using Activity? summarizeActivity = CompactionTelemetry.ActivitySource.StartActivity(CompactionTelemetry.ActivityNames.Summarize);
+ summarizeActivity?.SetTag(CompactionTelemetry.Tags.GroupsSummarized, summarized);
+
+ ChatResponse response = await this.ChatClient.GetResponseAsync(
+ summarizationMessages,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ string summaryText = string.IsNullOrWhiteSpace(response.Text) ? "[Summary unavailable]" : response.Text;
+
+ summarizeActivity?.SetTag(CompactionTelemetry.Tags.SummaryLength, summaryText.Length);
+
+ // 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]);
+
+ logger.LogSummarizationCompleted(summaryText.Length, insertIndex);
+
+ 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..4d4d6c0fce
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ToolResultCompactionStrategy.cs
@@ -0,0 +1,156 @@
+// 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.Extensions.Logging;
+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. Use
+/// for common trigger conditions such as token thresholds.
+///
+///
+[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 ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, 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 new ValueTask(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)}]";
+
+ ChatMessage summaryMessage = new(ChatRole.Assistant, summary);
+ (summaryMessage.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = true;
+
+ index.InsertGroup(idx + 1, MessageGroupKind.Summary, [summaryMessage], group.TurnIndex);
+ offset++; // Each insertion shifts subsequent indices by 1
+
+ compacted = true;
+
+ // Stop when target condition is met
+ if (this.Target(index))
+ {
+ break;
+ }
+ }
+
+ return new ValueTask(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..bb13ee7773
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI/Compaction/TruncationCompactionStrategy.cs
@@ -0,0 +1,110 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+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 ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, 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 new ValueTask(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 new ValueTask(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/ChatMessageContentEqualityTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatMessageContentEqualityTests.cs
new file mode 100644
index 0000000000..0ec84f3cb3
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ChatMessageContentEqualityTests.cs
@@ -0,0 +1,518 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Text;
+using Microsoft.Agents.AI.Compaction;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.UnitTests.Compaction;
+
+///
+/// Contains tests for the extension methods.
+///
+public class ChatMessageContentEqualityTests
+{
+ #region Null and reference handling
+
+ [Fact]
+ public void BothNullReturnsTrue()
+ {
+ ChatMessage? a = null;
+ ChatMessage? b = null;
+
+ Assert.True(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void LeftNullReturnsFalse()
+ {
+ ChatMessage? a = null;
+ ChatMessage b = new(ChatRole.User, "Hello");
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void RightNullReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.User, "Hello");
+ ChatMessage? b = null;
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void SameReferenceReturnsTrue()
+ {
+ ChatMessage a = new(ChatRole.User, "Hello");
+
+ Assert.True(a.ContentEquals(a));
+ }
+
+ #endregion
+
+ #region MessageId shortcut
+
+ [Fact]
+ public void MatchingMessageIdReturnsTrue()
+ {
+ ChatMessage a = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
+ ChatMessage b = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
+
+ Assert.True(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void MatchingMessageIdSufficientDespiteDifferentContent()
+ {
+ ChatMessage a = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
+ ChatMessage b = new(ChatRole.Assistant, "Goodbye") { MessageId = "msg-1" };
+
+ Assert.True(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentMessageIdReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
+ ChatMessage b = new(ChatRole.User, "Hello") { MessageId = "msg-2" };
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void OnlyLeftHasMessageIdFallsThroughToContentComparison()
+ {
+ ChatMessage a = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
+ ChatMessage b = new(ChatRole.User, "Hello");
+
+ Assert.True(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void OnlyRightHasMessageIdFallsThroughToContentComparison()
+ {
+ ChatMessage a = new(ChatRole.User, "Hello");
+ ChatMessage b = new(ChatRole.User, "Hello") { MessageId = "msg-1" };
+
+ Assert.True(a.ContentEquals(b));
+ }
+
+ #endregion
+
+ #region Role and AuthorName
+
+ [Fact]
+ public void DifferentRoleReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.User, "Hello");
+ ChatMessage b = new(ChatRole.Assistant, "Hello");
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentAuthorNameReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.User, "Hello") { AuthorName = "Alice" };
+ ChatMessage b = new(ChatRole.User, "Hello") { AuthorName = "Bob" };
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void BothNullAuthorNamesAreEqual()
+ {
+ ChatMessage a = new(ChatRole.User, "Hello");
+ ChatMessage b = new(ChatRole.User, "Hello");
+
+ Assert.True(a.ContentEquals(b));
+ }
+
+ #endregion
+
+ #region TextContent
+
+ [Fact]
+ public void EqualTextContentReturnsTrue()
+ {
+ ChatMessage a = new(ChatRole.User, "Hello world");
+ ChatMessage b = new(ChatRole.User, "Hello world");
+
+ Assert.True(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentTextContentReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.User, "Hello");
+ ChatMessage b = new(ChatRole.User, "Goodbye");
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void TextContentIsCaseSensitive()
+ {
+ ChatMessage a = new(ChatRole.User, "Hello");
+ ChatMessage b = new(ChatRole.User, "hello");
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ #endregion
+
+ #region TextReasoningContent
+
+ [Fact]
+ public void EqualTextReasoningContentReturnsTrue()
+ {
+ ChatMessage a = new(ChatRole.Assistant, [new TextReasoningContent("thinking...") { ProtectedData = "opaque" }]);
+ ChatMessage b = new(ChatRole.Assistant, [new TextReasoningContent("thinking...") { ProtectedData = "opaque" }]);
+
+ Assert.True(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentReasoningTextReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.Assistant, [new TextReasoningContent("alpha")]);
+ ChatMessage b = new(ChatRole.Assistant, [new TextReasoningContent("beta")]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentProtectedDataReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.Assistant, [new TextReasoningContent("same") { ProtectedData = "x" }]);
+ ChatMessage b = new(ChatRole.Assistant, [new TextReasoningContent("same") { ProtectedData = "y" }]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ #endregion
+
+ #region DataContent
+
+ [Fact]
+ public void EqualDataContentReturnsTrue()
+ {
+ byte[] data = Encoding.UTF8.GetBytes("payload");
+ ChatMessage a = new(ChatRole.User, [new DataContent(data, "application/octet-stream") { Name = "file.bin" }]);
+ ChatMessage b = new(ChatRole.User, [new DataContent(data, "application/octet-stream") { Name = "file.bin" }]);
+
+ Assert.True(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentDataBytesReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.User, [new DataContent(Encoding.UTF8.GetBytes("aaa"), "text/plain")]);
+ ChatMessage b = new(ChatRole.User, [new DataContent(Encoding.UTF8.GetBytes("bbb"), "text/plain")]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentMediaTypeReturnsFalse()
+ {
+ byte[] data = [1, 2, 3];
+ ChatMessage a = new(ChatRole.User, [new DataContent(data, "image/png")]);
+ ChatMessage b = new(ChatRole.User, [new DataContent(data, "image/jpeg")]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentDataContentNameReturnsFalse()
+ {
+ byte[] data = [1, 2, 3];
+ ChatMessage a = new(ChatRole.User, [new DataContent(data, "image/png") { Name = "a.png" }]);
+ ChatMessage b = new(ChatRole.User, [new DataContent(data, "image/png") { Name = "b.png" }]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ #endregion
+
+ #region UriContent
+
+ [Fact]
+ public void EqualUriContentReturnsTrue()
+ {
+ ChatMessage a = new(ChatRole.User, [new UriContent(new Uri("https://example.com/image.png"), "image/png")]);
+ ChatMessage b = new(ChatRole.User, [new UriContent(new Uri("https://example.com/image.png"), "image/png")]);
+
+ Assert.True(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentUriReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.User, [new UriContent(new Uri("https://a.com/x"), "image/png")]);
+ ChatMessage b = new(ChatRole.User, [new UriContent(new Uri("https://b.com/x"), "image/png")]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentUriMediaTypeReturnsFalse()
+ {
+ Uri uri = new("https://example.com/file");
+ ChatMessage a = new(ChatRole.User, [new UriContent(uri, "image/png")]);
+ ChatMessage b = new(ChatRole.User, [new UriContent(uri, "image/jpeg")]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ #endregion
+
+ #region ErrorContent
+
+ [Fact]
+ public void EqualErrorContentReturnsTrue()
+ {
+ ChatMessage a = new(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E001" }]);
+ ChatMessage b = new(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E001" }]);
+
+ Assert.True(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentErrorMessageReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.Assistant, [new ErrorContent("fail")]);
+ ChatMessage b = new(ChatRole.Assistant, [new ErrorContent("crash")]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentErrorCodeReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E001" }]);
+ ChatMessage b = new(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E002" }]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ #endregion
+
+ #region FunctionCallContent
+
+ [Fact]
+ public void EqualFunctionCallContentReturnsTrue()
+ {
+ ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather") { Arguments = new Dictionary { ["city"] = "Seattle" } }]);
+ ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather") { Arguments = new Dictionary { ["city"] = "Seattle" } }]);
+
+ Assert.True(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentCallIdReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather")]);
+ ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-2", "get_weather")]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentFunctionNameReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather")]);
+ ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_time")]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentArgumentsReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary { ["x"] = "1" } }]);
+ ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary { ["x"] = "2" } }]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void NullArgumentsBothSidesReturnsTrue()
+ {
+ ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn")]);
+ ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn")]);
+
+ Assert.True(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void OneNullArgumentsReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn")]);
+ ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary { ["x"] = "1" } }]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentArgumentCountReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary { ["x"] = "1" } }]);
+ ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary { ["x"] = "1", ["y"] = "2" } }]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ #endregion
+
+ #region FunctionResultContent
+
+ [Fact]
+ public void EqualFunctionResultContentReturnsTrue()
+ {
+ ChatMessage a = new(ChatRole.Tool, [new FunctionResultContent("call-1", "sunny")]);
+ ChatMessage b = new(ChatRole.Tool, [new FunctionResultContent("call-1", "sunny")]);
+
+ Assert.True(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentResultCallIdReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.Tool, [new FunctionResultContent("call-1", "sunny")]);
+ ChatMessage b = new(ChatRole.Tool, [new FunctionResultContent("call-2", "sunny")]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentResultValueReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.Tool, [new FunctionResultContent("call-1", "sunny")]);
+ ChatMessage b = new(ChatRole.Tool, [new FunctionResultContent("call-1", "rainy")]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ #endregion
+
+ #region HostedFileContent
+
+ [Fact]
+ public void EqualHostedFileContentReturnsTrue()
+ {
+ ChatMessage a = new(ChatRole.User, [new HostedFileContent("file-abc") { MediaType = "text/csv", Name = "data.csv" }]);
+ ChatMessage b = new(ChatRole.User, [new HostedFileContent("file-abc") { MediaType = "text/csv", Name = "data.csv" }]);
+
+ Assert.True(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentFileIdReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.User, [new HostedFileContent("file-abc")]);
+ ChatMessage b = new(ChatRole.User, [new HostedFileContent("file-xyz")]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentHostedFileMediaTypeReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.User, [new HostedFileContent("file-abc") { MediaType = "text/csv" }]);
+ ChatMessage b = new(ChatRole.User, [new HostedFileContent("file-abc") { MediaType = "text/plain" }]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentHostedFileNameReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.User, [new HostedFileContent("file-abc") { Name = "a.csv" }]);
+ ChatMessage b = new(ChatRole.User, [new HostedFileContent("file-abc") { Name = "b.csv" }]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ #endregion
+
+ #region Content list structure
+
+ [Fact]
+ public void DifferentContentCountReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.User, [new TextContent("one"), new TextContent("two")]);
+ ChatMessage b = new(ChatRole.User, [new TextContent("one")]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void MixedContentTypesInSameOrderReturnsTrue()
+ {
+ ChatMessage a = new(ChatRole.Assistant, new AIContent[] { new TextContent("reply"), new FunctionCallContent("c1", "fn") });
+ ChatMessage b = new(ChatRole.Assistant, new AIContent[] { new TextContent("reply"), new FunctionCallContent("c1", "fn") });
+
+ Assert.True(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void MismatchedContentTypeOrderReturnsFalse()
+ {
+ ChatMessage a = new(ChatRole.Assistant, new AIContent[] { new TextContent("reply"), new FunctionCallContent("c1", "fn") });
+ ChatMessage b = new(ChatRole.Assistant, new AIContent[] { new FunctionCallContent("c1", "fn"), new TextContent("reply") });
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void EmptyContentsListsAreEqual()
+ {
+ ChatMessage a = new() { Role = ChatRole.User, Contents = [] };
+ ChatMessage b = new() { Role = ChatRole.User, Contents = [] };
+
+ Assert.True(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void SameContentItemReferenceReturnsTrue()
+ {
+ // Exercises the ReferenceEquals fast-path on individual AIContent items.
+ TextContent shared = new("Hello");
+ ChatMessage a = new(ChatRole.User, [shared]);
+ ChatMessage b = new(ChatRole.User, [shared]);
+
+ Assert.True(a.ContentEquals(b));
+ }
+
+ #endregion
+
+ #region Unknown AIContent subtype
+
+ [Fact]
+ public void UnknownContentSubtypeSameTypeReturnsTrue()
+ {
+ // Unknown subtypes with the same concrete type are considered equal.
+ ChatMessage a = new(ChatRole.User, [new StubContent()]);
+ ChatMessage b = new(ChatRole.User, [new StubContent()]);
+
+ Assert.True(a.ContentEquals(b));
+ }
+
+ [Fact]
+ public void DifferentUnknownContentSubtypesReturnFalse()
+ {
+ ChatMessage a = new(ChatRole.User, [new StubContent()]);
+ ChatMessage b = new(ChatRole.User, [new OtherStubContent()]);
+
+ Assert.False(a.ContentEquals(b));
+ }
+
+ private sealed class StubContent : AIContent;
+
+ private sealed class OtherStubContent : AIContent;
+
+ #endregion
+}
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..07e7dae96e
--- /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 cancellationSource = new();
+ CancellationToken capturedToken = default;
+ TestChatReducer reducer = new((messages, cancellationToken) =>
+ {
+ capturedToken = cancellationToken;
+ 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, logger: null, cancellationSource.Token);
+
+ // Assert
+ Assert.Equal(cancellationSource.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/CompactionProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs
new file mode 100644
index 0000000000..358c4e591b
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs
@@ -0,0 +1,366 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+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 CompactionProviderTests
+{
+ [Fact]
+ public void ConstructorThrowsOnNullStrategy()
+ {
+ Assert.Throws(() => new CompactionProvider(null!));
+ }
+
+ [Fact]
+ public void StateKeysReturnsExpectedKey()
+ {
+ // Arrange
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
+ CompactionProvider provider = new(strategy);
+
+ // Act & Assert — default state key is provider name + strategy type name
+ Assert.Single(provider.StateKeys);
+ Assert.Equal(nameof(TruncationCompactionStrategy), provider.StateKeys[0]);
+ }
+
+ [Fact]
+ public void StateKeysAreStableAcrossEquivalentInstances()
+ {
+ // Arrange — two providers with equivalent (but distinct) strategies
+ CompactionProvider provider1 = new(new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(100000)));
+ CompactionProvider provider2 = new(new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(100000)));
+
+ // Act & Assert — default keys must be identical for session state stability
+ Assert.Equal(provider1.StateKeys[0], provider2.StateKeys[0]);
+ }
+
+ [Fact]
+ public void StateKeysReturnsCustomKeyWhenProvided()
+ {
+ // Arrange
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
+ CompactionProvider provider = new(strategy, stateKey: "my-custom-key");
+
+ // Act & Assert
+ Assert.Single(provider.StateKeys);
+ Assert.Equal("my-custom-key", provider.StateKeys[0]);
+ }
+
+ [Fact]
+ public async Task InvokingAsyncNoSessionPassesThroughAsync()
+ {
+ // Arrange — no session → passthrough
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
+ CompactionProvider provider = new(strategy);
+
+ Mock mockAgent = new() { CallBase = true };
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Hello"),
+ ];
+
+ AIContextProvider.InvokingContext context = new(
+ mockAgent.Object,
+ session: null,
+ new AIContext { Messages = messages });
+
+ // Act
+ AIContext result = await provider.InvokingAsync(context);
+
+ // Assert — original context returned unchanged
+ Assert.Same(messages, result.Messages);
+ }
+
+ [Fact]
+ public async Task InvokingAsyncNullMessagesPassesThroughAsync()
+ {
+ // Arrange — messages is null → passthrough
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
+ CompactionProvider provider = new(strategy);
+
+ Mock mockAgent = new() { CallBase = true };
+ TestAgentSession session = new();
+ AIContextProvider.InvokingContext context = new(
+ mockAgent.Object,
+ session,
+ new AIContext { Messages = null });
+
+ // Act
+ AIContext result = await provider.InvokingAsync(context);
+
+ // Assert — original context returned unchanged
+ Assert.Null(result.Messages);
+ }
+
+ [Fact]
+ public async Task InvokingAsyncAppliesCompactionWhenTriggeredAsync()
+ {
+ // Arrange — strategy that always triggers and keeps only 1 group
+ TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1);
+ CompactionProvider provider = new(strategy);
+
+ Mock mockAgent = new() { CallBase = true };
+ TestAgentSession session = new();
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ];
+
+ AIContextProvider.InvokingContext context = new(
+ mockAgent.Object,
+ session,
+ new AIContext { Messages = messages });
+
+ // Act
+ AIContext result = await provider.InvokingAsync(context);
+
+ // Assert — compaction should have reduced the message count
+ Assert.NotNull(result.Messages);
+ List resultList = [.. result.Messages!];
+ Assert.True(resultList.Count < messages.Count);
+ }
+
+ [Fact]
+ public async Task InvokingAsyncNoCompactionNeededReturnsOriginalMessagesAsync()
+ {
+ // Arrange — trigger never fires → no compaction
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
+ CompactionProvider provider = new(strategy);
+
+ Mock mockAgent = new() { CallBase = true };
+ TestAgentSession session = new();
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Hello"),
+ ];
+
+ AIContextProvider.InvokingContext context = new(
+ mockAgent.Object,
+ session,
+ new AIContext { Messages = messages });
+
+ // Act
+ AIContext result = await provider.InvokingAsync(context);
+
+ // Assert — original messages passed through
+ Assert.NotNull(result.Messages);
+ List resultList = [.. result.Messages!];
+ Assert.Single(resultList);
+ Assert.Equal("Hello", resultList[0].Text);
+ }
+
+ [Fact]
+ public async Task InvokingAsyncPreservesInstructionsAndToolsAsync()
+ {
+ // Arrange
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
+ CompactionProvider provider = new(strategy);
+
+ Mock mockAgent = new() { CallBase = true };
+ TestAgentSession session = new();
+ List messages = [new ChatMessage(ChatRole.User, "Hello")];
+ AITool[] tools = [AIFunctionFactory.Create(() => "tool", "MyTool")];
+
+ AIContextProvider.InvokingContext context = new(
+ mockAgent.Object,
+ session,
+ new AIContext
+ {
+ Instructions = "Be helpful",
+ Messages = messages,
+ Tools = tools
+ });
+
+ // Act
+ AIContext result = await provider.InvokingAsync(context);
+
+ // Assert — instructions and tools are preserved
+ Assert.Equal("Be helpful", result.Instructions);
+ Assert.Same(tools, result.Tools);
+ }
+
+ [Fact]
+ public async Task InvokingAsyncWithExistingIndexUpdatesAsync()
+ {
+ // Arrange — call twice to exercise the "existing index" path
+ TruncationCompactionStrategy strategy = new(_ => true, minimumPreserved: 1);
+ CompactionProvider provider = new(strategy);
+
+ Mock mockAgent = new() { CallBase = true };
+ TestAgentSession session = new();
+
+ List messages1 =
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ];
+
+ AIContextProvider.InvokingContext context1 = new(
+ mockAgent.Object,
+ session,
+ new AIContext { Messages = messages1 });
+
+ // First call — initializes state
+ await provider.InvokingAsync(context1);
+
+ 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"),
+ ];
+
+ AIContextProvider.InvokingContext context2 = new(
+ mockAgent.Object,
+ session,
+ new AIContext { Messages = messages2 });
+
+ // Act — second call exercises the update path
+ AIContext result = await provider.InvokingAsync(context2);
+
+ // Assert
+ Assert.NotNull(result.Messages);
+ }
+
+ [Fact]
+ public async Task InvokingAsyncWithNonListEnumerableCreatesListCopyAsync()
+ {
+ // Arrange — pass IEnumerable (not List) to exercise the list copy branch
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
+ CompactionProvider provider = new(strategy);
+
+ Mock mockAgent = new() { CallBase = true };
+ TestAgentSession session = new();
+
+ // Use an IEnumerable (not a List) to trigger the copy path
+ IEnumerable messages = [new ChatMessage(ChatRole.User, "Hello")];
+
+ AIContextProvider.InvokingContext context = new(
+ mockAgent.Object,
+ session,
+ new AIContext { Messages = messages });
+
+ // Act
+ AIContext result = await provider.InvokingAsync(context);
+
+ // Assert
+ Assert.NotNull(result.Messages);
+ List resultList = [.. result.Messages!];
+ Assert.Single(resultList);
+ Assert.Equal("Hello", resultList[0].Text);
+ }
+
+ [Fact]
+ public async Task CompactAsyncThrowsOnNullStrategyAsync()
+ {
+ List messages = [new ChatMessage(ChatRole.User, "Hello")];
+
+ await Assert.ThrowsAsync(() => CompactionProvider.CompactAsync(null!, messages));
+ }
+
+ [Fact]
+ public async Task CompactAsyncReturnsAllMessagesWhenTriggerDoesNotFireAsync()
+ {
+ // Arrange — trigger never fires → no compaction
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ];
+
+ // Act
+ IEnumerable result = await CompactionProvider.CompactAsync(strategy, messages);
+
+ // Assert — all messages preserved
+ List resultList = [.. result];
+ Assert.Equal(messages.Count, resultList.Count);
+ Assert.Equal("Q1", resultList[0].Text);
+ Assert.Equal("A1", resultList[1].Text);
+ Assert.Equal("Q2", resultList[2].Text);
+ }
+
+ [Fact]
+ public async Task CompactAsyncReducesMessagesWhenTriggeredAsync()
+ {
+ // Arrange — strategy that always triggers and keeps only 1 group
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ new ChatMessage(ChatRole.User, "Q2"),
+ ];
+
+ // Act
+ IEnumerable result = await CompactionProvider.CompactAsync(strategy, messages);
+
+ // Assert — compaction should have reduced the message count
+ List resultList = [.. result];
+ Assert.True(resultList.Count < messages.Count);
+ }
+
+ [Fact]
+ public async Task CompactAsyncHandlesEmptyMessageListAsync()
+ {
+ // Arrange
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreserved: 1);
+ List messages = [];
+
+ // Act
+ IEnumerable result = await CompactionProvider.CompactAsync(strategy, messages);
+
+ // Assert
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public async Task CompactAsyncWorksWithNonListEnumerableAsync()
+ {
+ // Arrange — IEnumerable (not a List) to exercise the list copy branch
+ TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
+ IEnumerable messages = [new ChatMessage(ChatRole.User, "Hello")];
+
+ // Act
+ IEnumerable result = await CompactionProvider.CompactAsync(strategy, messages);
+
+ // Assert
+ List resultList = [.. result];
+ Assert.Single(resultList);
+ Assert.Equal("Hello", resultList[0].Text);
+ }
+
+ [Fact]
+ public void CompactionStateAssignment()
+ {
+ // Arrange
+ CompactionProvider.State state = new();
+
+ // Assert
+ Assert.NotNull(state.MessageGroups);
+ Assert.Empty(state.MessageGroups);
+
+ // Act
+ state.MessageGroups = [new MessageGroup(MessageGroupKind.User, [], 0, 0, 0)];
+
+ // Assert
+ Assert.Single(state.MessageGroups);
+ }
+
+ 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..40d9715609
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionStrategyTests.cs
@@ -0,0 +1,236 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Compaction;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging;
+
+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, but enough non-system groups to pass short-circuit
+ TestStrategy strategy = new(_ => false);
+ 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, strategy.ApplyCallCount);
+ }
+
+ [Fact]
+ public async Task CompactAsyncTriggerMetCallsApplyAsync()
+ {
+ // Arrange — trigger always fires, enough non-system groups
+ TestStrategy strategy = new(_ => true, applyFunc: _ => true);
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Hello"),
+ new ChatMessage(ChatRole.Assistant, "Hi!"),
+ ]);
+
+ // 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"),
+ new ChatMessage(ChatRole.Assistant, "Hi!"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert
+ Assert.False(result);
+ Assert.Equal(1, strategy.ApplyCallCount);
+ }
+
+ [Fact]
+ public async Task CompactAsyncSingleNonSystemGroupShortCircuitsAsync()
+ {
+ // Arrange — trigger would fire, but only 1 non-system group → short-circuit
+ TestStrategy strategy = new(_ => true, applyFunc: _ => true);
+ MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert — short-circuited before trigger or Apply
+ Assert.False(result);
+ Assert.Equal(0, strategy.ApplyCallCount);
+ }
+
+ [Fact]
+ public async Task CompactAsyncSingleNonSystemGroupWithSystemShortCircuitsAsync()
+ {
+ // Arrange — system group + 1 non-system group → still short-circuits
+ TestStrategy strategy = new(_ => true, applyFunc: _ => true);
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.System, "You are helpful."),
+ new ChatMessage(ChatRole.User, "Hello"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert — system groups don't count, still only 1 non-system group
+ Assert.False(result);
+ Assert.Equal(0, strategy.ApplyCallCount);
+ }
+
+ [Fact]
+ public async Task CompactAsyncTwoNonSystemGroupsProceedsToTriggerAsync()
+ {
+ // Arrange — exactly 2 non-system groups: boundary passes, trigger fires
+ TestStrategy strategy = new(_ => true, applyFunc: _ => true);
+ MessageIndex index = MessageIndex.Create(
+ [
+ new ChatMessage(ChatRole.User, "Hello"),
+ new ChatMessage(ChatRole.Assistant, "Hi!"),
+ ]);
+
+ // Act
+ bool result = await strategy.CompactAsync(index);
+
+ // Assert — not short-circuited, Apply was called
+ Assert.True(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"),
+ new ChatMessage(ChatRole.Assistant, "Hi!"),
+ ]);
+
+ // 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 ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken)
+ {
+ this.ApplyCallCount++;
+ bool result = this._applyFunc?.Invoke(index) ?? false;
+ return new(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..6c5c0aec9d
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/MessageIndexTests.cs
@@ -0,0 +1,1245 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+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 CreateToolCallWithResultsCreatesAtomicGroup()
+ {
+ // Arrange
+ ChatMessage assistantMessage = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "Seattle" })]);
+ ChatMessage toolResult = new(ChatRole.Tool, [new FunctionResultContent("call1", "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 CreateToolCallWithTextCreatesAtomicGroup()
+ {
+ // Arrange
+ ChatMessage assistantMessage = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "Seattle" })]);
+ ChatMessage toolResult = new(ChatRole.Tool, [new TextContent("Sunny, 72°F"), new FunctionResultContent("call1", "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) + 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(9, groups.Groups[0].ByteCount); // FunctionCallContent: "call1" (5) + "fn" (2) = 7, "OK" = 2 → 9 total
+ }
+
+ [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 CreateNonTextContentHasAccurateCounts()
+ {
+ // Arrange — message with pure function call (no text)
+ ChatMessage msg = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
+ ChatMessage tool = new(ChatRole.Tool, string.Empty);
+ MessageIndex groups = MessageIndex.Create([msg, tool]);
+
+ // Assert — FunctionCallContent: "call1" (5) + "get_weather" (11) = 16 bytes
+ Assert.Equal(2, groups.Groups[0].MessageCount);
+ Assert.Equal(16, groups.Groups[0].ByteCount);
+ Assert.Equal(4, groups.Groups[0].TokenCount); // 16 / 4 = 4 estimated tokens
+ }
+
+ [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 FunctionCallContent + 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(9, groups.Groups[0].ByteCount); // FunctionCallContent: "call1" (5) + "fn" (2) = 7, "OK" = 2 → 9 total
+ 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.RawMessageCount);
+
+ // 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.RawMessageCount);
+ 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.RawMessageCount);
+ }
+
+ [Fact]
+ public void UpdateWithEmptyListClearsGroups()
+ {
+ // Arrange — create with messages
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ ];
+ MessageIndex index = MessageIndex.Create(messages);
+ Assert.Equal(2, index.Groups.Count);
+
+ // Act — update with empty list
+ index.Update([]);
+
+ // Assert — fully cleared
+ Assert.Empty(index.Groups);
+ Assert.Equal(0, index.TotalTurnCount);
+ Assert.Equal(0, index.RawMessageCount);
+ }
+
+ [Fact]
+ public void UpdateRebuildsWhenLastProcessedMessageNotFound()
+ {
+ // Arrange — create with messages
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Q1"),
+ new ChatMessage(ChatRole.Assistant, "A1"),
+ ];
+ MessageIndex index = MessageIndex.Create(messages);
+ Assert.Equal(2, index.Groups.Count);
+ index.Groups[0].IsExcluded = true;
+
+ // Act — update with completely different messages (last processed "A1" is absent)
+ List replaced =
+ [
+ new ChatMessage(ChatRole.User, "X1"),
+ new ChatMessage(ChatRole.Assistant, "X2"),
+ new ChatMessage(ChatRole.User, "X3"),
+ ];
+ index.Update(replaced);
+
+ // Assert — rebuilt from scratch, exclusion state gone
+ Assert.Equal(3, index.Groups.Count);
+ Assert.All(index.Groups, g => Assert.False(g.IsExcluded));
+ Assert.Equal(3, index.RawMessageCount);
+ }
+
+ [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 ComputeTokenCountEmptyContentsReturnsZero()
+ {
+ // Arrange — message with empty contents
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, []),
+ ];
+
+ SimpleWordTokenizer tokenizer = new();
+ int tokenCount = MessageIndex.ComputeTokenCount(messages, tokenizer);
+
+ // Assert — no 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 CreateWithSummaryPropertyFalseIsNotSummary()
+ {
+ // Summary property key present but value is false — not a summary
+ ChatMessage assistant = new(ChatRole.Assistant, "Not a summary");
+ (assistant.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = false;
+
+ MessageIndex index = MessageIndex.Create([assistant]);
+
+ Assert.Single(index.Groups);
+ Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind);
+ }
+
+ [Fact]
+ public void CreateWithSummaryPropertyNonBoolIsNotSummary()
+ {
+ // Summary property key present but value is a string, not a bool
+ ChatMessage assistant = new(ChatRole.Assistant, "Not a summary");
+ (assistant.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = "true";
+
+ MessageIndex index = MessageIndex.Create([assistant]);
+
+ Assert.Single(index.Groups);
+ Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind);
+ }
+
+ [Fact]
+ public void CreateWithSummaryPropertyNullValueIsNotSummary()
+ {
+ // Summary property key present but value is null
+ ChatMessage assistant = new(ChatRole.Assistant, "Not a summary");
+ (assistant.AdditionalProperties ??= [])[MessageGroup.SummaryPropertyKey] = null!;
+
+ MessageIndex index = MessageIndex.Create([assistant]);
+
+ Assert.Single(index.Groups);
+ Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind);
+ }
+
+ [Fact]
+ public void CreateWithNoAdditionalPropertiesIsNotSummary()
+ {
+ // Assistant message with no AdditionalProperties at all
+ ChatMessage assistant = new(ChatRole.Assistant, "Plain response");
+
+ MessageIndex index = MessageIndex.Create([assistant]);
+
+ Assert.Single(index.Groups);
+ Assert.Equal(MessageGroupKind.AssistantText, index.Groups[0].Kind);
+ }
+
+ [Fact]
+ public void ComputeByteCountHandlesTextAndNonTextContent()
+ {
+ // Mix of messages: one with text (non-null), one with FunctionCallContent
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, "Hello"),
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
+ ];
+
+ int byteCount = MessageIndex.ComputeByteCount(messages);
+
+ // "Hello" = 5 bytes, FunctionCallContent("c1", "fn") = "c1" (2) + "fn" (2) = 4 bytes
+ Assert.Equal(9, byteCount);
+ }
+
+ [Fact]
+ public void ComputeTokenCountHandlesTextAndNonTextContent()
+ {
+ // Mix: one with text, one with FunctionCallContent
+ 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);
+
+ // "Hello world" = 2 tokens (tokenized), FunctionCallContent("c1","fn") = 4 bytes → 1 token (estimated)
+ Assert.Equal(3, tokenCount);
+ }
+
+ [Fact]
+ public void ComputeByteCountTextContent()
+ {
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, [new TextContent("Hello")]),
+ ];
+
+ Assert.Equal(5, MessageIndex.ComputeByteCount(messages));
+ }
+
+ [Fact]
+ public void ComputeByteCountTextReasoningContent()
+ {
+ List messages =
+ [
+ new ChatMessage(ChatRole.Assistant, [new TextReasoningContent("think") { ProtectedData = "secret" }]),
+ ];
+
+ // "think" = 5 bytes, "secret" = 6 bytes
+ Assert.Equal(11, MessageIndex.ComputeByteCount(messages));
+ }
+
+ [Fact]
+ public void ComputeByteCountDataContent()
+ {
+ byte[] payload = new byte[100];
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, [new DataContent(payload, "image/png") { Name = "pic" }]),
+ ];
+
+ // 100 (data) + 9 ("image/png") + 3 ("pic")
+ Assert.Equal(112, MessageIndex.ComputeByteCount(messages));
+ }
+
+ [Fact]
+ public void ComputeByteCountUriContent()
+ {
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, [new UriContent(new Uri("https://example.com/image.png"), "image/png")]),
+ ];
+
+ // "https://example.com/image.png" = 29 bytes, "image/png" = 9 bytes
+ Assert.Equal(38, MessageIndex.ComputeByteCount(messages));
+ }
+
+ [Fact]
+ public void ComputeByteCountFunctionCallContentWithArguments()
+ {
+ List messages =
+ [
+ new ChatMessage(ChatRole.Assistant,
+ [
+ new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "Seattle" }),
+ ]),
+ ];
+
+ // "call1" = 5, "get_weather" = 11, "city" = 4, "Seattle" = 7
+ Assert.Equal(27, MessageIndex.ComputeByteCount(messages));
+ }
+
+ [Fact]
+ public void ComputeByteCountFunctionCallContentWithoutArguments()
+ {
+ List messages =
+ [
+ new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
+ ];
+
+ // "c1" = 2, "fn" = 2
+ Assert.Equal(4, MessageIndex.ComputeByteCount(messages));
+ }
+
+ [Fact]
+ public void ComputeByteCountFunctionResultContent()
+ {
+ List messages =
+ [
+ new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call1", "Sunny, 72°F")]),
+ ];
+
+ // "call1" = 5, "Sunny, 72°F" = 13 bytes (° is 2 bytes in UTF-8)
+ Assert.Equal(5 + System.Text.Encoding.UTF8.GetByteCount("Sunny, 72°F"), MessageIndex.ComputeByteCount(messages));
+ }
+
+ [Fact]
+ public void ComputeByteCountErrorContent()
+ {
+ List messages =
+ [
+ new ChatMessage(ChatRole.Assistant, [new ErrorContent("fail") { ErrorCode = "E001" }]),
+ ];
+
+ // "fail" = 4, "E001" = 4
+ Assert.Equal(8, MessageIndex.ComputeByteCount(messages));
+ }
+
+ [Fact]
+ public void ComputeByteCountHostedFileContent()
+ {
+ List messages =
+ [
+ new ChatMessage(ChatRole.Assistant, [new HostedFileContent("file-abc") { MediaType = "text/plain", Name = "readme.txt" }]),
+ ];
+
+ // "file-abc" = 8, "text/plain" = 10, "readme.txt" = 10
+ Assert.Equal(28, MessageIndex.ComputeByteCount(messages));
+ }
+
+ [Fact]
+ public void ComputeByteCountMixedContentInSingleMessage()
+ {
+ List messages =
+ [
+ new ChatMessage(ChatRole.User,
+ [
+ new TextContent("Hello"),
+ new DataContent(new byte[50], "image/png"),
+ ]),
+ ];
+
+ // TextContent: "Hello" = 5 bytes
+ // DataContent: 50 (data) + 9 ("image/png") = 59 bytes
+ Assert.Equal(64, MessageIndex.ComputeByteCount(messages));
+ }
+
+ [Fact]
+ public void ComputeByteCountEmptyContentsReturnsZero()
+ {
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, []),
+ ];
+
+ Assert.Equal(0, MessageIndex.ComputeByteCount(messages));
+ }
+
+ [Fact]
+ public void ComputeByteCountUnknownContentTypeReturnsZero()
+ {
+ List messages =
+ [
+ new ChatMessage(ChatRole.Assistant, [new UsageContent(new UsageDetails())]),
+ ];
+
+ Assert.Equal(0, MessageIndex.ComputeByteCount(messages));
+ }
+
+ [Fact]
+ public void ComputeTokenCountTextReasoningContentUsesTokenizer()
+ {
+ SimpleWordTokenizer tokenizer = new();
+ List messages =
+ [
+ new ChatMessage(ChatRole.Assistant, [new TextReasoningContent("deep thinking here") { ProtectedData = "hidden data" }]),
+ ];
+
+ // "deep thinking here" = 3 words, "hidden data" = 2 words → 5 tokens via tokenizer
+ Assert.Equal(5, MessageIndex.ComputeTokenCount(messages, tokenizer));
+ }
+
+ [Fact]
+ public void ComputeTokenCountNonTextContentEstimatesFromBytes()
+ {
+ SimpleWordTokenizer tokenizer = new();
+ byte[] payload = new byte[40];
+ List messages =
+ [
+ new ChatMessage(ChatRole.User, [new DataContent(payload, "image/png")]),
+ ];
+
+ // DataContent: 40 (data) + 9 ("image/png") = 49 bytes → 49/4 = 12 tokens (estimated)
+ Assert.Equal(12, MessageIndex.ComputeTokenCount(messages, tokenizer));
+ }
+
+ [Fact]
+ public void ComputeTokenCountMixedTextAndNonTextContent()
+ {
+ SimpleWordTokenizer tokenizer = new();
+ List messages =
+ [
+ new ChatMessage(ChatRole.User,
+ [
+ new TextContent("Hello world"),
+ new DataContent(new byte[40], "image/png"),
+ ]),
+ ];
+
+ // TextContent: "Hello world" = 2 tokens (tokenized)
+ // DataContent: 40 + 9 = 49 bytes → 12 tokens (estimated)
+ Assert.Equal(14, MessageIndex.ComputeTokenCount(messages, tokenizer));
+ }
+
+ [Fact]
+ public void CreateGroupByteCountIncludesAllContentTypes()
+ {
+ // Verify that MessageIndex.Create produces groups with accurate byte counts for non-text content
+ ChatMessage assistantMessage = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary { ["city"] = "Seattle" })]);
+ ChatMessage toolResult = new(ChatRole.Tool, [new FunctionResultContent("call1", "Sunny")]);
+ List messages = [assistantMessage, toolResult];
+
+ MessageIndex index = MessageIndex.Create(messages);
+
+ // ToolCall group: FunctionCallContent("call1","get_weather",{city=Seattle}) + FunctionResultContent("call1","Sunny")
+ // = (5 + 11 + 4 + 7) + (5 + 5) = 27 + 10 = 37
+ Assert.Single(index.Groups);
+ Assert.Equal(37, index.Groups[0].ByteCount);
+ Assert.True(index.Groups[0].TokenCount > 0);
+ }
+
+ ///
+ /// 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, ReadOnlySpan textSpan, EncodeSettings settings)
+ {
+ // Simple word-based encoding
+ string input = text ?? textSpan.ToString();
+ if (string.IsNullOrWhiteSpace(input))
+ {
+ return new EncodeResults
+ {
+ Tokens = [],
+ 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 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, 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..a648085e65
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/PipelineCompactionStrategyTests.cs
@@ -0,0 +1,208 @@
+// 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;
+using Microsoft.Extensions.Logging;
+
+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"),
+ new ChatMessage(ChatRole.Assistant, "Hi!"),
+ ]);
+
+ // 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"),
+ new ChatMessage(ChatRole.Assistant, "Hi!"),
+ ]);
+
+ // 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"),
+ new ChatMessage(ChatRole.Assistant, "Hi!"),
+ ]);
+
+ // 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"),
+ new ChatMessage(ChatRole.Assistant, "Hi!"),
+ ]);
+
+ // 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 ValueTask CompactCoreAsync(MessageIndex index, ILogger logger, CancellationToken cancellationToken)
+ {
+ this.ApplyCallCount++;
+ return new(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..9d2f4b25b2
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/SummarizationCompactionStrategyTests.cs
@@ -0,0 +1,414 @@
+// 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 system message, followed by the original messages
+ Assert.NotNull(capturedMessages);
+ Assert.Equal(2, capturedMessages.Count);
+ Assert.Equal(ChatRole.System, capturedMessages![0].Role);
+ Assert.Equal(CustomPrompt, capturedMessages[0].Text);
+ Assert.Equal(ChatRole.User, capturedMessages[1].Role);
+ Assert.Equal("Q1", capturedMessages[1].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..346d8d2fb3
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/ToolResultCompactionStrategyTests.cs
@@ -0,0 +1,263 @@
+// 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.User, "Q0"),
+ 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 last 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 @@
+