chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -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;
/// <summary>
/// Contains tests for the <see cref="ChatMessageContentEquality"/> extension methods.
/// </summary>
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<string, object?> { ["city"] = "Seattle" } }]);
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "get_weather") { Arguments = new Dictionary<string, object?> { ["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<string, object?> { ["x"] = "1" } }]);
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary<string, object?> { ["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<string, object?> { ["x"] = "1" } }]);
Assert.False(a.ContentEquals(b));
}
[Fact]
public void DifferentArgumentCountReturnsFalse()
{
ChatMessage a = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary<string, object?> { ["x"] = "1" } }]);
ChatMessage b = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "fn") { Arguments = new Dictionary<string, object?> { ["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
}
@@ -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;
/// <summary>
/// Contains tests for the <see cref="ChatReducerCompactionStrategy"/> class.
/// </summary>
public class ChatReducerCompactionStrategyTests
{
[Fact]
public void ConstructorNullReducerThrows()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => 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);
CompactionMessageIndex index = CompactionMessageIndex.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);
CompactionMessageIndex index = CompactionMessageIndex.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);
CompactionMessageIndex index = CompactionMessageIndex.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);
CompactionMessageIndex index = CompactionMessageIndex.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);
CompactionMessageIndex index = CompactionMessageIndex.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(CompactionGroupKind.System, index.Groups[0].Kind);
Assert.Equal("You are helpful.", index.Groups[0].Messages[0].Text);
Assert.Equal(CompactionGroupKind.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);
CompactionMessageIndex index = CompactionMessageIndex.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(CompactionGroupKind.ToolCall, index.Groups[0].Kind);
Assert.Equal(2, index.Groups[0].Messages.Count);
Assert.Equal(CompactionGroupKind.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);
CompactionMessageIndex index = CompactionMessageIndex.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<IEnumerable<ChatMessage>>(messages.Skip(messages.Count() - 1).ToList());
});
ChatReducerCompactionStrategy strategy = new(reducer, CompactionTriggers.Always);
CompactionMessageIndex index = CompactionMessageIndex.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);
}
/// <summary>
/// A test implementation of <see cref="IChatReducer"/> that applies a configurable reduction function.
/// </summary>
private sealed class TestChatReducer : IChatReducer
{
private readonly Func<IEnumerable<ChatMessage>, CancellationToken, Task<IEnumerable<ChatMessage>>> _reduceFunc;
public TestChatReducer(Func<IEnumerable<ChatMessage>, IEnumerable<ChatMessage>> reduceFunc)
{
this._reduceFunc = (messages, _) => Task.FromResult(reduceFunc(messages));
}
public TestChatReducer(Func<IEnumerable<ChatMessage>, CancellationToken, Task<IEnumerable<ChatMessage>>> reduceFunc)
{
this._reduceFunc = reduceFunc;
}
public int CallCount { get; private set; }
public async Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
{
this.CallCount++;
return await this._reduceFunc(messages, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -0,0 +1,159 @@
// 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;
/// <summary>
/// Contains tests for the <see cref="ChatStrategyExtensions"/> class.
/// </summary>
public class ChatStrategyExtensionsTests
{
[Fact]
public void AsChatReducerNullStrategyThrows()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => ((CompactionStrategy)null!).AsChatReducer());
}
[Fact]
public void AsChatReducerReturnsIChatReducer()
{
// Arrange
ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Always);
// Act
IChatReducer reducer = strategy.AsChatReducer();
// Assert
Assert.NotNull(reducer);
}
[Fact]
public async Task ReduceAsyncReturnsAllMessagesWhenStrategyDoesNotCompactAsync()
{
// Arrange — trigger never fires, so no compaction occurs
ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Never);
IChatReducer reducer = strategy.AsChatReducer();
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi!"),
];
// Act
IEnumerable<ChatMessage> result = await reducer.ReduceAsync(messages, CancellationToken.None);
// Assert
Assert.Equal(messages, result);
}
[Fact]
public async Task ReduceAsyncCompactsMessagesWhenStrategyFiresAsync()
{
// Arrange — reducer keeps only the last message
ChatReducerCompactionStrategy strategy = new(
new TakeLastReducer(1),
CompactionTriggers.Always);
IChatReducer reducer = strategy.AsChatReducer();
List<ChatMessage> messages =
[
new(ChatRole.User, "First"),
new(ChatRole.Assistant, "Response 1"),
new(ChatRole.User, "Second"),
];
// Act
IEnumerable<ChatMessage> result = await reducer.ReduceAsync(messages, CancellationToken.None);
// Assert
List<ChatMessage> resultList = [.. result];
Assert.Single(resultList);
Assert.Equal("Second", resultList[0].Text);
}
[Fact]
public async Task ReduceAsyncPassesCancellationTokenToStrategyAsync()
{
// Arrange
using CancellationTokenSource cts = new();
CancellationToken capturedToken = default;
CapturingReducer capturingReducer = new(token => capturedToken = token);
ChatReducerCompactionStrategy strategy = new(capturingReducer, CompactionTriggers.Always);
IChatReducer reducer = strategy.AsChatReducer();
List<ChatMessage> messages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.User, "World"),
];
// Act
await reducer.ReduceAsync(messages, cts.Token);
// Assert
Assert.Equal(cts.Token, capturedToken);
}
[Fact]
public async Task ReduceAsyncEmptyMessagesReturnsEmptyAsync()
{
// Arrange
ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Always);
IChatReducer reducer = strategy.AsChatReducer();
// Act
IEnumerable<ChatMessage> result = await reducer.ReduceAsync([], CancellationToken.None);
// Assert
Assert.Empty(result);
}
/// <summary>
/// An <see cref="IChatReducer"/> that returns messages unchanged.
/// </summary>
private sealed class IdentityReducer : IChatReducer
{
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
=> Task.FromResult(messages);
}
/// <summary>
/// An <see cref="IChatReducer"/> that keeps only the last <c>n</c> messages.
/// </summary>
private sealed class TakeLastReducer : IChatReducer
{
private readonly int _count;
public TakeLastReducer(int count) => this._count = count;
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
=> Task.FromResult(messages.Reverse().Take(this._count));
}
/// <summary>
/// An <see cref="IChatReducer"/> that captures the <see cref="CancellationToken"/> passed to <see cref="ReduceAsync"/>.
/// </summary>
private sealed class CapturingReducer : IChatReducer
{
private readonly Action<CancellationToken> _capture;
public CapturingReducer(Action<CancellationToken> capture) => this._capture = capture;
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
{
this._capture(cancellationToken);
IEnumerable<ChatMessage> reducedMessages = [messages.Reverse().First()];
return Task.FromResult(reducedMessages);
}
}
}
@@ -0,0 +1,447 @@
// 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;
/// <summary>
/// Contains tests for the <see cref="CompactionProvider"/> class.
/// </summary>
public sealed class CompactionProviderTests
{
[Fact]
public void ConstructorThrowsOnNullStrategy()
{
Assert.Throws<ArgumentNullException>(() => new CompactionProvider(null!));
}
[Fact]
public void StateKeysReturnsExpectedKey()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactionProvider provider = new(strategy);
// Act & Assert — default state key is the 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<AIAgent> mockAgent = new() { CallBase = true };
List<ChatMessage> 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<AIAgent> 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, minimumPreservedGroups: 1);
CompactionProvider provider = new(strategy);
Mock<AIAgent> mockAgent = new() { CallBase = true };
TestAgentSession session = new();
List<ChatMessage> 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<ChatMessage> 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<AIAgent> mockAgent = new() { CallBase = true };
TestAgentSession session = new();
List<ChatMessage> 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<ChatMessage> 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<AIAgent> mockAgent = new() { CallBase = true };
TestAgentSession session = new();
List<ChatMessage> 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, minimumPreservedGroups: 1);
CompactionProvider provider = new(strategy);
Mock<AIAgent> mockAgent = new() { CallBase = true };
TestAgentSession session = new();
List<ChatMessage> 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<ChatMessage> 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<ChatMessage>) to exercise the list copy branch
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactionProvider provider = new(strategy);
Mock<AIAgent> mockAgent = new() { CallBase = true };
TestAgentSession session = new();
// Use an IEnumerable (not a List) to trigger the copy path
IEnumerable<ChatMessage> 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<ChatMessage> resultList = [.. result.Messages!];
Assert.Single(resultList);
Assert.Equal("Hello", resultList[0].Text);
}
[Fact]
public async Task CompactAsyncThrowsOnNullStrategyAsync()
{
List<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
await Assert.ThrowsAsync<ArgumentNullException>(() => CompactionProvider.CompactAsync(null!, messages));
}
[Fact]
public async Task CompactAsyncReturnsAllMessagesWhenTriggerDoesNotFireAsync()
{
// Arrange — trigger never fires → no compaction
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
];
// Act
IEnumerable<ChatMessage> result = await CompactionProvider.CompactAsync(strategy, messages);
// Assert — all messages preserved
List<ChatMessage> 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, minimumPreservedGroups: 1);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
];
// Act
IEnumerable<ChatMessage> result = await CompactionProvider.CompactAsync(strategy, messages);
// Assert — compaction should have reduced the message count
List<ChatMessage> resultList = [.. result];
Assert.True(resultList.Count < messages.Count);
}
[Fact]
public async Task CompactAsyncHandlesEmptyMessageListAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
List<ChatMessage> messages = [];
// Act
IEnumerable<ChatMessage> result = await CompactionProvider.CompactAsync(strategy, messages);
// Assert
Assert.Empty(result);
}
[Fact]
public async Task CompactAsyncWorksWithNonListEnumerableAsync()
{
// Arrange — IEnumerable (not a List<ChatMessage>) to exercise the list copy branch
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
IEnumerable<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
// Act
IEnumerable<ChatMessage> result = await CompactionProvider.CompactAsync(strategy, messages);
// Assert
List<ChatMessage> 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 CompactionMessageGroup(CompactionGroupKind.User, [], 0, 0, 0)];
// Assert
Assert.Single(state.MessageGroups);
}
[Fact]
public async Task InvokingAsyncMarksOnlyPreviouslySeenMessagesAsChatHistoryAsync()
{
// Arrange — no-compaction strategy so we can observe marking behavior only
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactionProvider provider = new(strategy);
Mock<AIAgent> mockAgent = new() { CallBase = true };
TestAgentSession session = new();
// --- First invocation: [Q1, A1, Q2] ---
ChatMessage q1 = new(ChatRole.User, "Q1");
ChatMessage a1 = new(ChatRole.Assistant, "A1");
ChatMessage q2 = new(ChatRole.User, "Q2");
AIContextProvider.InvokingContext context1 = new(
mockAgent.Object,
session,
new AIContext { Messages = new List<ChatMessage> { q1, a1, q2 } });
AIContext result1 = await provider.InvokingAsync(context1);
// Assert — on first invocation, no messages should be marked as ChatHistory
List<ChatMessage> resultList1 = [.. result1.Messages!];
Assert.Equal(3, resultList1.Count);
foreach (ChatMessage message in resultList1)
{
Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, message.GetAgentRequestMessageSourceType());
}
// --- Second invocation: [Q1, A1, Q2, A2, Q3] ---
ChatMessage a2 = new(ChatRole.Assistant, "A2");
ChatMessage q3 = new(ChatRole.User, "Q3");
AIContextProvider.InvokingContext context2 = new(
mockAgent.Object,
session,
new AIContext { Messages = new List<ChatMessage> { q1, a1, q2, a2, q3 } });
AIContext result2 = await provider.InvokingAsync(context2);
// Assert — messages from the first invocation should be marked as ChatHistory,
// while new messages should not.
List<ChatMessage> resultList2 = [.. result2.Messages!];
Assert.Equal(5, resultList2.Count);
// Q1, A1, Q2 were already in the provider state — they should be ChatHistory
Assert.Equal(AgentRequestMessageSourceType.ChatHistory, resultList2[0].GetAgentRequestMessageSourceType());
Assert.Equal(AgentRequestMessageSourceType.ChatHistory, resultList2[1].GetAgentRequestMessageSourceType());
Assert.Equal(AgentRequestMessageSourceType.ChatHistory, resultList2[2].GetAgentRequestMessageSourceType());
// A2, Q3 are new to the provider — they should NOT be ChatHistory
Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, resultList2[3].GetAgentRequestMessageSourceType());
Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, resultList2[4].GetAgentRequestMessageSourceType());
// --- Third invocation: [Q1, A1, Q2, A2, Q3, A3, Q4] ---
ChatMessage a3 = new(ChatRole.Assistant, "A3");
ChatMessage q4 = new(ChatRole.User, "Q4");
AIContextProvider.InvokingContext context3 = new(
mockAgent.Object,
session,
new AIContext { Messages = new List<ChatMessage> { q1, a1, q2, a2, q3, a3, q4 } });
AIContext result3 = await provider.InvokingAsync(context3);
// Assert — all previously seen messages should be ChatHistory, only brand-new ones should not
List<ChatMessage> resultList3 = [.. result3.Messages!];
Assert.Equal(7, resultList3.Count);
// Q1, A1, Q2, A2, Q3 were already in the provider state — they should be ChatHistory
for (int i = 0; i < 5; i++)
{
Assert.Equal(AgentRequestMessageSourceType.ChatHistory, resultList3[i].GetAgentRequestMessageSourceType());
}
// A3, Q4 are new — they should NOT be ChatHistory
Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, resultList3[5].GetAgentRequestMessageSourceType());
Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, resultList3[6].GetAgentRequestMessageSourceType());
}
private sealed class TestAgentSession : AgentSession;
}
@@ -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;
/// <summary>
/// Contains tests for the <see cref="CompactionStrategy"/> abstract base class.
/// </summary>
public class CompactionStrategyTests
{
[Fact]
public void ConstructorNullTriggerThrows()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() => 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);
CompactionMessageIndex index = CompactionMessageIndex.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);
CompactionMessageIndex index = CompactionMessageIndex.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);
CompactionMessageIndex index = CompactionMessageIndex.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);
CompactionMessageIndex index = CompactionMessageIndex.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);
CompactionMessageIndex index = CompactionMessageIndex.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);
CompactionMessageIndex index = CompactionMessageIndex.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 (CompactionMessageGroup group in index.Groups)
{
if (!group.IsExcluded && group.Kind != CompactionGroupKind.System)
{
group.IsExcluded = true;
// Target (default = !trigger) returns true when groups <= 2
// So the strategy would check Target after this exclusion
break;
}
}
return true;
});
CompactionMessageIndex index = CompactionMessageIndex.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(CompactionMessageIndex _)
{
targetCalled = true;
return true;
}
TestStrategy strategy = new(_ => true, CustomTarget, _ =>
{
// Access the target from within the strategy
return true;
});
CompactionMessageIndex index = CompactionMessageIndex.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);
}
/// <summary>
/// A concrete test implementation of <see cref="CompactionStrategy"/> for testing the base class.
/// </summary>
private sealed class TestStrategy : CompactionStrategy
{
private readonly Func<CompactionMessageIndex, bool>? _applyFunc;
public TestStrategy(
CompactionTrigger trigger,
CompactionTrigger? target = null,
Func<CompactionMessageIndex, bool>? applyFunc = null)
: base(trigger, target)
{
this._applyFunc = applyFunc;
}
public int ApplyCallCount { get; private set; }
/// <summary>
/// Exposes the protected Target property for test verification.
/// </summary>
public bool InvokeTarget(CompactionMessageIndex index) => this.Target(index);
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
this.ApplyCallCount++;
bool result = this._applyFunc?.Invoke(index) ?? false;
return new(result);
}
}
}
@@ -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;
/// <summary>
/// Contains tests for <see cref="CompactionTrigger"/> and <see cref="CompactionTriggers"/>.
/// </summary>
public class CompactionTriggersTests
{
[Fact]
public void TokensExceedReturnsTrueWhenAboveThreshold()
{
// Arrange — use a long message to guarantee tokens > 0
CompactionTrigger trigger = CompactionTriggers.TokensExceed(0);
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]);
// Act & Assert
Assert.True(trigger(index));
}
[Fact]
public void TokensExceedReturnsFalseWhenBelowThreshold()
{
CompactionTrigger trigger = CompactionTriggers.TokensExceed(999_999);
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]);
Assert.False(trigger(index));
}
[Fact]
public void MessagesExceedReturnsExpectedResult()
{
CompactionTrigger trigger = CompactionTriggers.MessagesExceed(2);
CompactionMessageIndex small = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.User, "B"),
]);
CompactionMessageIndex large = CompactionMessageIndex.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);
CompactionMessageIndex oneTurn = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
]);
CompactionMessageIndex twoTurns = CompactionMessageIndex.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);
CompactionMessageIndex index = CompactionMessageIndex.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();
CompactionMessageIndex index = CompactionMessageIndex.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();
CompactionMessageIndex index = CompactionMessageIndex.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));
CompactionMessageIndex small = CompactionMessageIndex.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));
CompactionMessageIndex index = CompactionMessageIndex.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();
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
Assert.True(trigger(index));
}
[Fact]
public void AnyEmptyTriggersReturnsFalse()
{
CompactionTrigger trigger = CompactionTriggers.Any();
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
Assert.False(trigger(index));
}
[Fact]
public void TokensBelowReturnsTrueWhenBelowThreshold()
{
CompactionTrigger trigger = CompactionTriggers.TokensBelow(999_999);
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]);
Assert.True(trigger(index));
}
[Fact]
public void TokensBelowReturnsFalseWhenAboveThreshold()
{
CompactionTrigger trigger = CompactionTriggers.TokensBelow(0);
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]);
Assert.False(trigger(index));
}
[Fact]
public void AlwaysReturnsTrue()
{
CompactionMessageIndex index = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
Assert.True(CompactionTriggers.Always(index));
}
}
@@ -0,0 +1,219 @@
// 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;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="ContextWindowCompactionStrategy"/> class.
/// </summary>
public class ContextWindowCompactionStrategyTests
{
[Fact]
public void Constructor_ValidParameters_SetsProperties()
{
// Arrange & Act
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 1_050_000,
maxOutputTokens: 128_000);
// Assert
Assert.Equal(1_050_000, strategy.MaxContextWindowTokens);
Assert.Equal(128_000, strategy.MaxOutputTokens);
Assert.Equal(922_000, strategy.InputBudgetTokens);
Assert.Equal(ContextWindowCompactionStrategy.DefaultToolEvictionThreshold, strategy.ToolEvictionThreshold);
Assert.Equal(ContextWindowCompactionStrategy.DefaultTruncationThreshold, strategy.TruncationThreshold);
}
[Fact]
public void Constructor_CustomThresholds_SetsProperties()
{
// Arrange & Act
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 1_000_000,
maxOutputTokens: 100_000,
toolEvictionThreshold: 0.3,
truncationThreshold: 0.6);
// Assert
Assert.Equal(900_000, strategy.InputBudgetTokens);
Assert.Equal(0.3, strategy.ToolEvictionThreshold);
Assert.Equal(0.6, strategy.TruncationThreshold);
}
[Theory]
[InlineData(0, 100)] // maxContextWindowTokens <= 0
[InlineData(-1, 100)] // maxContextWindowTokens negative
public void Constructor_InvalidContextWindow_Throws(int contextWindow, int maxOutput)
{
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() =>
new ContextWindowCompactionStrategy(contextWindow, maxOutput));
}
[Theory]
[InlineData(1000, -1)] // maxOutputTokens negative
[InlineData(1000, 1000)] // maxOutputTokens == contextWindow
[InlineData(1000, 1001)] // maxOutputTokens > contextWindow
public void Constructor_InvalidOutputTokens_Throws(int contextWindow, int maxOutput)
{
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() =>
new ContextWindowCompactionStrategy(contextWindow, maxOutput));
}
[Theory]
[InlineData(0.0)] // Zero threshold
[InlineData(-0.1)] // Negative threshold
[InlineData(1.1)] // Over 1.0
public void Constructor_InvalidToolEvictionThreshold_Throws(double threshold)
{
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() =>
new ContextWindowCompactionStrategy(1000, 100, toolEvictionThreshold: threshold));
}
[Theory]
[InlineData(0.0)] // Zero threshold
[InlineData(-0.1)] // Negative threshold
[InlineData(1.1)] // Over 1.0
public void Constructor_InvalidTruncationThreshold_Throws(double threshold)
{
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() =>
new ContextWindowCompactionStrategy(1000, 100, truncationThreshold: threshold));
}
[Fact]
public void Constructor_TruncationBelowToolEviction_Throws()
{
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() =>
new ContextWindowCompactionStrategy(1000, 100, toolEvictionThreshold: 0.8, truncationThreshold: 0.5));
}
[Fact]
public async Task CompactAsync_BelowToolEvictionThreshold_NoCompactionAsync()
{
// Arrange — input budget = 900 tokens, tool eviction at 450, truncation at 720
// A few short messages should be well below any threshold.
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 1000,
maxOutputTokens: 100);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi there!"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert
Assert.False(result);
Assert.Equal(2, index.IncludedGroupCount);
}
[Fact]
public async Task CompactAsync_AboveTruncationThreshold_TruncatesOldestAsync()
{
// Arrange — use a budget of 5 tokens with truncation at 80% = 4 token threshold.
// Even the shortest messages will exceed this, ensuring truncation fires.
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 10,
maxOutputTokens: 5,
toolEvictionThreshold: 0.5,
truncationThreshold: 0.8);
// Verify internal budget calculation
Assert.Equal(5, strategy.InputBudgetTokens);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First user message"),
new ChatMessage(ChatRole.Assistant, "First response"),
new ChatMessage(ChatRole.User, "Second user message"),
new ChatMessage(ChatRole.Assistant, "Second response"),
]);
int groupsBefore = index.IncludedGroupCount;
int tokensBefore = index.IncludedTokenCount;
// Verify tokens actually exceed the truncation threshold (80% of 5 = 4)
Assert.True(tokensBefore > 4, $"Expected tokens > 4 but got {tokensBefore}");
Assert.True(groupsBefore > 1, $"Expected groups > 1 but got {groupsBefore}");
// Act
bool result = await strategy.CompactAsync(index);
// Assert — with tokens well above a 4-token threshold, truncation should fire
Assert.True(result, $"Expected compaction to occur. Tokens before: {tokensBefore}, groups before: {groupsBefore}, NonSystemGroups: {index.IncludedNonSystemGroupCount}");
Assert.True(index.IncludedGroupCount < groupsBefore);
}
[Fact]
public async Task CompactAsync_ToolCallsAboveEvictionThreshold_CollapsesToolCallsAsync()
{
// Arrange — very small budget so tool eviction fires.
// Input budget = 5, tool eviction at 50% = 2 token threshold.
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 10,
maxOutputTokens: 5,
toolEvictionThreshold: 0.5,
truncationThreshold: 0.9);
// Build messages with a tool call group: assistant with FunctionCallContent + tool result
var assistantMessage = new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_data", arguments: new Dictionary<string, object?> { ["query"] = "test" })]);
var toolResultMessage = new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call1", "Here is a long result with many words to ensure we exceed the token threshold")]);
var userMessage = new ChatMessage(ChatRole.User, "What did you find?");
var assistantResponse = new ChatMessage(ChatRole.Assistant, "Based on the results I found information.");
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
assistantMessage,
toolResultMessage,
userMessage,
assistantResponse,
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — compaction should succeed for tool calls above the eviction threshold.
// Do not assert on IncludedTokenCount because tool-result compaction preserves content
// in summary form and tokenization can make the count stay the same or increase.
Assert.True(result);
}
[Fact]
public void Constructor_EqualThresholds_Succeeds()
{
// Arrange & Act — truncation == tool eviction should be valid
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 1000,
maxOutputTokens: 100,
toolEvictionThreshold: 0.7,
truncationThreshold: 0.7);
// Assert
Assert.Equal(0.7, strategy.ToolEvictionThreshold);
Assert.Equal(0.7, strategy.TruncationThreshold);
}
[Fact]
public void Constructor_ZeroMaxOutputTokens_FullBudget()
{
// Arrange & Act
var strategy = new ContextWindowCompactionStrategy(
maxContextWindowTokens: 1_000_000,
maxOutputTokens: 0);
// Assert — entire context window is the input budget
Assert.Equal(1_000_000, strategy.InputBudgetTokens);
}
}
@@ -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;
/// <summary>
/// Contains tests for the <see cref="PipelineCompactionStrategy"/> class.
/// </summary>
public class PipelineCompactionStrategyTests
{
[Fact]
public async Task CompactAsyncExecutesAllStrategiesInOrderAsync()
{
// Arrange
List<string> executionOrder = [];
TestCompactionStrategy strategy1 = new(
_ =>
{
executionOrder.Add("first");
return false;
});
TestCompactionStrategy strategy2 = new(
_ =>
{
executionOrder.Add("second");
return false;
});
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
CompactionMessageIndex groups = CompactionMessageIndex.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);
CompactionMessageIndex groups = CompactionMessageIndex.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);
CompactionMessageIndex groups = CompactionMessageIndex.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);
CompactionMessageIndex groups = CompactionMessageIndex.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(CompactionMessageIndex index)
{
int excluded = 0;
foreach (CompactionMessageGroup group in index.Groups)
{
if (!group.IsExcluded && group.Kind != CompactionGroupKind.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);
CompactionMessageIndex groups = CompactionMessageIndex.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<ChatMessage> 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<CompactionStrategy>());
CompactionMessageIndex groups = CompactionMessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.False(result);
}
/// <summary>
/// A simple test implementation of <see cref="CompactionStrategy"/> that delegates to a synchronous callback.
/// </summary>
private sealed class TestCompactionStrategy : CompactionStrategy
{
private readonly Func<CompactionMessageIndex, bool> _applyFunc;
public TestCompactionStrategy(Func<CompactionMessageIndex, bool> applyFunc)
: base(CompactionTriggers.Always)
{
this._applyFunc = applyFunc;
}
public int ApplyCallCount { get; private set; }
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
{
this.ApplyCallCount++;
return new(this._applyFunc(index));
}
}
}
@@ -0,0 +1,311 @@
// 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;
/// <summary>
/// Contains tests for the <see cref="SlidingWindowCompactionStrategy"/> class.
/// </summary>
public class SlidingWindowCompactionStrategyTests
{
[Fact]
public async Task CompactAsyncBelowMaxTurnsReturnsFalseAsync()
{
// Arrange — trigger requires > 3 turns, conversation has 2
SlidingWindowCompactionStrategy strategy = new(CompactionTriggers.TurnsExceed(3));
CompactionMessageIndex groups = CompactionMessageIndex.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));
CompactionMessageIndex groups = CompactionMessageIndex.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));
CompactionMessageIndex groups = CompactionMessageIndex.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));
CompactionMessageIndex groups = CompactionMessageIndex.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));
CompactionMessageIndex groups = CompactionMessageIndex.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));
CompactionMessageIndex groups = CompactionMessageIndex.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<ChatMessage> 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(CompactionMessageIndex _) => ++removeCount >= 1;
SlidingWindowCompactionStrategy strategy = new(
CompactionTriggers.TurnsExceed(1),
minimumPreservedTurns: 0,
target: TargetAfterOne);
CompactionMessageIndex index = CompactionMessageIndex.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),
minimumPreservedTurns: 2,
target: _ => false);
CompactionMessageIndex index = CompactionMessageIndex.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 protects the last 2 turns
Assert.True(result);
Assert.Equal(4, index.IncludedGroupCount);
// Turn 1 excluded
Assert.True(index.Groups[0].IsExcluded); // Q1
Assert.True(index.Groups[1].IsExcluded); // A1
// Last 2 turns must be preserved
Assert.False(index.Groups[2].IsExcluded); // Q2
Assert.False(index.Groups[3].IsExcluded); // A2
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),
minimumPreservedTurns: 0);
CompactionMessageIndex index = CompactionMessageIndex.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
}
[Fact]
public async Task CompactAsyncPreservesTurnIndexZeroAsync()
{
// Arrange — assistant message before first user turn gets TurnIndex = 0
SlidingWindowCompactionStrategy strategy = new(
CompactionTriggers.TurnsExceed(1),
minimumPreservedTurns: 0,
target: _ => false);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.Assistant, "Welcome!"), // TurnIndex = 0
new ChatMessage(ChatRole.User, "Q1"), // TurnIndex = 1
new ChatMessage(ChatRole.Assistant, "A1"), // TurnIndex = 1
new ChatMessage(ChatRole.User, "Q2"), // TurnIndex = 2
new ChatMessage(ChatRole.Assistant, "A2"), // TurnIndex = 2
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — TurnIndex = 0 is always preserved even with minimumPreservedTurns = 0
Assert.True(result);
Assert.False(index.Groups[0].IsExcluded); // Welcome (TurnIndex 0) preserved
Assert.True(index.Groups[1].IsExcluded); // Q1 (TurnIndex 1) excluded
Assert.True(index.Groups[2].IsExcluded); // A1 (TurnIndex 1) excluded
Assert.True(index.Groups[3].IsExcluded); // Q2 (TurnIndex 2) excluded
Assert.True(index.Groups[4].IsExcluded); // A2 (TurnIndex 2) excluded
}
[Fact]
public async Task CompactAsyncPreservesNullTurnIndexAsync()
{
// Arrange — system messages (TurnIndex = null) should never be removed
SlidingWindowCompactionStrategy strategy = new(
CompactionTriggers.TurnsExceed(0),
minimumPreservedTurns: 0,
target: _ => false);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — system message (TurnIndex null) always preserved
Assert.True(result);
Assert.False(index.Groups[0].IsExcluded); // System (TurnIndex null) preserved
Assert.True(index.Groups[1].IsExcluded); // Q1 excluded
Assert.True(index.Groups[2].IsExcluded); // A1 excluded
}
}
@@ -0,0 +1,613 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="SummarizationCompactionStrategy"/> class.
/// </summary>
public class SummarizationCompactionStrategyTests
{
/// <summary>
/// Creates a mock <see cref="IChatClient"/> that returns the specified summary text.
/// </summary>
private static IChatClient CreateMockChatClient(string summaryText = "Summary of conversation.")
{
Mock<IChatClient> mock = new();
mock.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.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),
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.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,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.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<ChatMessage> 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,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.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<ChatMessage> 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,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.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
CompactionMessageGroup summaryGroup = index.Groups.First(g => g.Kind == CompactionGroupKind.Summary);
Assert.NotNull(summaryGroup);
Assert.Contains("[Summary]", summaryGroup.Messages[0].Text);
Assert.True(summaryGroup.Messages[0].AdditionalProperties!.ContainsKey(CompactionMessageGroup.SummaryPropertyKey));
}
[Fact]
public async Task CompactAsyncHandlesEmptyLlmResponseAsync()
{
// Arrange — LLM returns whitespace
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient(" "),
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — should use fallback text
List<ChatMessage> 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,
minimumPreservedGroups: 5);
CompactionMessageIndex index = CompactionMessageIndex.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<ChatMessage>? capturedMessages = null;
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, 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,
minimumPreservedGroups: 1,
summarizationPrompt: CustomPrompt);
CompactionMessageIndex index = CompactionMessageIndex.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,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Old"),
new ChatMessage(ChatRole.User, "New"),
]);
// Act
await strategy.CompactAsync(index);
// Assert
CompactionMessageGroup 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(CompactionMessageIndex _) => ++exclusionCount >= 1;
SummarizationCompactionStrategy strategy = new(
CreateMockChatClient("Partial summary."),
CompactionTriggers.Always,
minimumPreservedGroups: 1,
target: TargetAfterOne);
CompactionMessageIndex index = CompactionMessageIndex.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,
minimumPreservedGroups: 2);
CompactionMessageIndex index = CompactionMessageIndex.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<ChatMessage> 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,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.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(CompactionGroupKind.Summary, index.Groups[0].Kind);
Assert.Equal(CompactionGroupKind.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,
minimumPreservedGroups: 3,
target: _ => false);
CompactionMessageIndex index = CompactionMessageIndex.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,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.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,
minimumPreservedGroups: 1);
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
];
CompactionMessageIndex index = CompactionMessageIndex.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);
}
#region Error resilience
[Fact]
public async Task CompactAsyncLlmFailureRestoresGroupsAsync()
{
// Arrange — chat client throws a non-cancellation exception
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Service unavailable"));
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
int originalGroupCount = index.Groups.Count;
// Act
bool result = await strategy.CompactAsync(index);
// Assert — returns false, all groups restored to non-excluded
Assert.False(result);
Assert.Equal(originalGroupCount, index.Groups.Count);
Assert.All(index.Groups, g => Assert.False(g.IsExcluded));
Assert.All(index.Groups, g => Assert.Null(g.ExcludeReason));
}
[Fact]
public async Task CompactAsyncLlmFailurePreservesAllOriginalMessagesAsync()
{
// Arrange — verify that after failure, GetIncludedMessages returns all original messages
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpRequestException("Timeout"));
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
List<ChatMessage> originalIncluded = [.. index.GetIncludedMessages()];
// Act
await strategy.CompactAsync(index);
// Assert — all original messages still included
List<ChatMessage> afterIncluded = [.. index.GetIncludedMessages()];
Assert.Equal(originalIncluded.Count, afterIncluded.Count);
for (int i = 0; i < originalIncluded.Count; i++)
{
Assert.Same(originalIncluded[i], afterIncluded[i]);
}
}
[Fact]
public async Task CompactAsyncLlmFailureDoesNotInsertSummaryGroupAsync()
{
// Arrange
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("API error"));
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(index);
// Assert — no Summary group was inserted
Assert.DoesNotContain(index.Groups, g => g.Kind == CompactionGroupKind.Summary);
}
[Fact]
public async Task CompactAsyncCancellationPropagatesAsync()
{
// Arrange — OperationCanceledException should NOT be caught
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new OperationCanceledException("Cancelled"));
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act & Assert — OperationCanceledException propagates
await Assert.ThrowsAsync<OperationCanceledException>(
() => strategy.CompactAsync(index).AsTask());
}
[Fact]
public async Task CompactAsyncTaskCancellationPropagatesAsync()
{
// Arrange — TaskCanceledException (subclass of OperationCanceledException) should also propagate
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new TaskCanceledException("Task cancelled"));
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreservedGroups: 1);
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act & Assert — TaskCanceledException propagates (inherits from OperationCanceledException)
await Assert.ThrowsAsync<TaskCanceledException>(
() => strategy.CompactAsync(index).AsTask());
}
[Fact]
public async Task CompactAsyncLlmFailureWithMultipleExcludedGroupsRestoresAllAsync()
{
// Arrange — multiple groups excluded before failure, all must be restored
Mock<IChatClient> mockClient = new();
mockClient.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Rate limited"));
SummarizationCompactionStrategy strategy = new(
mockClient.Object,
CompactionTriggers.Always,
minimumPreservedGroups: 1,
target: _ => false); // Never stop — exclude as many as possible
CompactionMessageIndex index = CompactionMessageIndex.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"),
]);
// Act
bool result = await strategy.CompactAsync(index);
// Assert — all non-system groups restored
Assert.False(result);
Assert.All(index.Groups, g => Assert.False(g.IsExcluded));
Assert.All(index.Groups, g => Assert.Null(g.ExcludeReason));
Assert.Equal(6, index.IncludedGroupCount);
}
#endregion
}
@@ -0,0 +1,438 @@
// 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;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="ToolResultCompactionStrategy"/> class.
/// </summary>
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");
CompactionMessageIndex groups = CompactionMessageIndex.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,
minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.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<ChatMessage> included = [.. groups.GetIncludedMessages()];
// Q1 + collapsed tool summary + Q2
Assert.Equal(3, included.Count);
Assert.Equal("Q1", included[0].Text);
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny and 72°F", 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,
minimumPreservedGroups: 3);
CompactionMessageIndex groups = CompactionMessageIndex.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,
minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.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<ChatMessage> 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,
minimumPreservedGroups: 1);
ChatMessage multiToolCall = new(ChatRole.Assistant,
[
new FunctionCallContent("c1", "get_weather"),
new FunctionCallContent("c2", "search_docs"),
]);
CompactionMessageIndex groups = CompactionMessageIndex.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<ChatMessage> included = [.. groups.GetIncludedMessages()];
string collapsed = included[1].Text!;
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny\nsearch_docs:\n - Found 3 docs", collapsed);
}
[Fact]
public async Task CompactAsyncNoToolGroupsReturnsFalseAsync()
{
// Arrange — trigger fires but no tool groups to collapse
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 0);
CompactionMessageIndex groups = CompactionMessageIndex.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()),
minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.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(CompactionMessageIndex _) => ++collapseCount >= 1;
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1,
target: TargetAfterOne);
CompactionMessageIndex index = CompactionMessageIndex.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 (CompactionMessageGroup group in index.Groups)
{
if (group.IsExcluded && group.Kind == CompactionGroupKind.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, minimumPreservedGroups: 0);
List<ChatMessage> 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"),
];
CompactionMessageIndex index = CompactionMessageIndex.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
}
[Fact]
public async Task CompactAsyncDeduplicatesDuplicateToolNamesAsync()
{
// Arrange — same tool called multiple times
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("c1", "get_weather"),
new FunctionCallContent("c2", "get_weather"),
]),
new ChatMessage(ChatRole.Tool, "Sunny"),
new ChatMessage(ChatRole.Tool, "Rainy"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert — duplicate names listed once with all results
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny\n - Rainy", included[1].Text);
}
[Fact]
public async Task CompactAsyncIncludesResultsFromFunctionResultContentAsync()
{
// Arrange — tool results provided as FunctionResultContent (matched by CallId)
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("c1", "get_weather"),
new FunctionCallContent("c2", "search_docs"),
]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny and 72°F")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c2", "Found 3 docs")]),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert — results matched by CallId and included in summary
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny and 72°F\nsearch_docs:\n - Found 3 docs", included[1].Text);
}
[Fact]
public async Task CompactAsyncDeduplicatesWithFunctionResultContentAsync()
{
// Arrange — same tool called multiple times with FunctionResultContent
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant,
[
new FunctionCallContent("c1", "get_weather"),
new FunctionCallContent("c2", "get_weather"),
new FunctionCallContent("c3", "search_docs"),
]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c1", "Sunny")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c2", "Rainy")]),
new ChatMessage(ChatRole.Tool, [new FunctionResultContent("c3", "Found 3 docs")]),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert — duplicate tool name results listed under same key
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny\n - Rainy\nsearch_docs:\n - Found 3 docs", included[1].Text);
}
[Fact]
public async Task CompactAsyncUsesCustomFormatterAsync()
{
// Arrange — custom formatter that produces a collapsed message count
static string CustomFormatter(CompactionMessageGroup group) =>
$"[Collapsed: {group.Messages.Count} messages]";
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1)
{
ToolCallFormatter = CustomFormatter,
};
CompactionMessageIndex groups = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
new ChatMessage(ChatRole.Tool, "Sunny"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert — custom formatter output used instead of default YAML-like format
Assert.True(result);
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("[Collapsed: 2 messages]", included[1].Text);
}
[Fact]
public void ToolCallFormatterPropertyIsNullWhenNoneProvided()
{
// Arrange
ToolResultCompactionStrategy strategy = new(CompactionTriggers.Always);
// Assert — ToolCallFormatter is null when no custom formatter is provided
Assert.Null(strategy.ToolCallFormatter);
}
[Fact]
public void ToolCallFormatterPropertyReturnsCustomFormatterWhenProvided()
{
// Arrange
Func<CompactionMessageGroup, string> customFormatter = static _ => "custom";
ToolResultCompactionStrategy strategy = new(
CompactionTriggers.Always)
{
ToolCallFormatter = customFormatter
};
// Assert — ToolCallFormatter is the injected custom function
Assert.Same(customFormatter, strategy.ToolCallFormatter);
}
[Fact]
public async Task CompactAsyncCustomFormatterCanDelegateToDefaultAsync()
{
// Arrange — custom formatter that wraps the default output
static string WrappingFormatter(CompactionMessageGroup group) =>
$"CUSTOM_PREFIX\n{ToolResultCompactionStrategy.DefaultToolCallFormatter(group)}";
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
minimumPreservedGroups: 1)
{
ToolCallFormatter = WrappingFormatter
};
CompactionMessageIndex groups = CompactionMessageIndex.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
await strategy.CompactAsync(groups);
// Assert — wrapped default output
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("CUSTOM_PREFIX\n[Tool Calls]\nfn:\n - result", included[1].Text);
}
}
@@ -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;
/// <summary>
/// Contains tests for the <see cref="TruncationCompactionStrategy"/> class.
/// </summary>
public class TruncationCompactionStrategyTests
{
[Fact]
public async Task CompactAsyncAlwaysTriggerCompactsToPreserveRecentAsync()
{
// Arrange — always-trigger means always compact
TruncationCompactionStrategy strategy = new(CompactionTriggers.Always, minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.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(
minimumPreservedGroups: 1,
trigger: CompactionTriggers.TokensExceed(1000));
CompactionMessageIndex groups = CompactionMessageIndex.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(
minimumPreservedGroups: 1,
trigger: CompactionTriggers.GroupsExceed(2));
CompactionMessageIndex groups = CompactionMessageIndex.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, minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.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(CompactionGroupKind.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, minimumPreservedGroups: 1);
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
ChatMessage finalResponse = new(ChatRole.User, "Thanks!");
CompactionMessageIndex groups = CompactionMessageIndex.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(CompactionGroupKind.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, minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.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, minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.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, minimumPreservedGroups: 2);
CompactionMessageIndex groups = CompactionMessageIndex.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, minimumPreservedGroups: 5);
CompactionMessageIndex groups = CompactionMessageIndex.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(CompactionMessageIndex _) => ++targetChecks >= 1;
TruncationCompactionStrategy strategy = new(
CompactionTriggers.Always,
minimumPreservedGroups: 1,
target: TargetAfterOne);
CompactionMessageIndex groups = CompactionMessageIndex.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),
minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.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, minimumPreservedGroups: 2, target: CompactionTriggers.Never);
CompactionMessageIndex groups = CompactionMessageIndex.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, minimumPreservedGroups: 1);
CompactionMessageIndex groups = CompactionMessageIndex.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
}
}