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
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:
+518
@@ -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
|
||||
}
|
||||
+255
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1508
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
}
|
||||
}
|
||||
+219
@@ -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);
|
||||
}
|
||||
}
|
||||
+208
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+311
@@ -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
|
||||
}
|
||||
}
|
||||
+613
@@ -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
|
||||
}
|
||||
+438
@@ -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);
|
||||
}
|
||||
}
|
||||
+328
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user