This commit is contained in:
Chris Rickman
2026-03-05 01:48:40 -08:00
Unverified
parent fcd60daed5
commit eb8406214e
27 changed files with 1450 additions and 576 deletions
@@ -1,282 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="PipelineCompactionStrategy"/> class.
/// </summary>
public class PipelineCompactionStrategyTests
{
[Fact]
public async Task CompactAsync_ExecutesAllStrategiesInOrder()
{
// Arrange
List<string> executionOrder = [];
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.Callback(() => executionOrder.Add("first"))
.ReturnsAsync(false);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.Callback(() => executionOrder.Add("second"))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object]);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
await pipeline.CompactAsync(groups);
// Assert
Assert.Equal(["first", "second"], executionOrder);
}
[Fact]
public async Task CompactAsync_ReturnsFalse_WhenNoStrategyCompacts()
{
// Arrange
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new([strategy1.Object]);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsync_ReturnsTrue_WhenAnyStrategyCompacts()
{
// Arrange
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object]);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.True(result);
}
[Fact]
public async Task CompactAsync_ContinuesAfterFirstCompaction_WhenEarlyStopDisabled()
{
// Arrange
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object]);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
await pipeline.CompactAsync(groups);
// Assert — both strategies were called
strategy1.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CompactAsync_StopsEarly_WhenTargetReached()
{
// Arrange — first strategy reduces to target
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.Callback<MessageIndex, CancellationToken>((groups, _) =>
{
// Exclude the first group to bring count down
groups.Groups[0].IsExcluded = true;
})
.ReturnsAsync(true);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object])
{
EarlyStop = true,
TargetIncludedGroupCount = 2,
};
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response"),
new ChatMessage(ChatRole.User, "Second"),
]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert — strategy2 should not have been called
Assert.True(result);
strategy1.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task CompactAsync_DoesNotStopEarly_WhenTargetNotReached()
{
// Arrange — first strategy does NOT bring count to target
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object])
{
EarlyStop = true,
TargetIncludedGroupCount = 1,
};
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.User, "Second"),
new ChatMessage(ChatRole.User, "Third"),
]);
// Act
await pipeline.CompactAsync(groups);
// Assert — both strategies were called since target was never reached
strategy1.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CompactAsync_EarlyStopIgnored_WhenNoTargetSet()
{
// Arrange
Mock<ICompactionStrategy> strategy1 = new();
strategy1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
Mock<ICompactionStrategy> strategy2 = new();
strategy2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
PipelineCompactionStrategy pipeline = new([strategy1.Object, strategy2.Object])
{
EarlyStop = true,
// TargetIncludedGroupCount is null
};
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
await pipeline.CompactAsync(groups);
// Assert — both strategies called because no target to check against
strategy1.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
strategy2.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CompactAsync_ComposesStrategies_EndToEnd()
{
// Arrange — pipeline: first exclude oldest 2 non-system groups, then exclude 2 more
Mock<ICompactionStrategy> phase1 = new();
phase1.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.Callback<MessageIndex, CancellationToken>((groups, _) =>
{
int excluded = 0;
foreach (MessageGroup group in groups.Groups)
{
if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2)
{
group.IsExcluded = true;
excluded++;
}
}
})
.ReturnsAsync(true);
Mock<ICompactionStrategy> phase2 = new();
phase2.Setup(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()))
.Callback<MessageIndex, CancellationToken>((groups, _) =>
{
int excluded = 0;
foreach (MessageGroup group in groups.Groups)
{
if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2)
{
group.IsExcluded = true;
excluded++;
}
}
})
.ReturnsAsync(true);
PipelineCompactionStrategy pipeline = new([phase1.Object, phase2.Object]);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert — system is preserved, phase1 excluded Q1+A1, phase2 excluded Q2+A2 → System + Q3
Assert.True(result);
Assert.Equal(2, groups.IncludedGroupCount);
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal(2, included.Count);
Assert.Equal("You are helpful.", included[0].Text);
Assert.Equal("Q3", included[1].Text);
phase1.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
phase2.Verify(s => s.CompactAsync(It.IsAny<MessageIndex>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CompactAsync_EmptyPipeline_ReturnsFalseAsync()
{
// Arrange
PipelineCompactionStrategy pipeline = new(new List<ICompactionStrategy>());
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.False(result);
}
}
@@ -0,0 +1,155 @@
// 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 TokensExceed_ReturnsTrueWhenAboveThreshold()
{
// Arrange — use a long message to guarantee tokens > 0
CompactionTrigger trigger = CompactionTriggers.TokensExceed(0);
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello world")]);
// Act & Assert
Assert.True(trigger(index));
}
[Fact]
public void TokensExceed_ReturnsFalseWhenBelowThreshold()
{
CompactionTrigger trigger = CompactionTriggers.TokensExceed(999_999);
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hi")]);
Assert.False(trigger(index));
}
[Fact]
public void MessagesExceed_ReturnsExpectedResult()
{
CompactionTrigger trigger = CompactionTriggers.MessagesExceed(2);
MessageIndex small = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.User, "B"),
]);
MessageIndex large = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.User, "B"),
new ChatMessage(ChatRole.User, "C"),
]);
Assert.False(trigger(small));
Assert.True(trigger(large));
}
[Fact]
public void TurnsExceed_ReturnsExpectedResult()
{
CompactionTrigger trigger = CompactionTriggers.TurnsExceed(1);
MessageIndex oneTurn = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
]);
MessageIndex twoTurns = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
Assert.False(trigger(oneTurn));
Assert.True(trigger(twoTurns));
}
[Fact]
public void GroupsExceed_ReturnsExpectedResult()
{
CompactionTrigger trigger = CompactionTriggers.GroupsExceed(2);
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "A"),
new ChatMessage(ChatRole.Assistant, "B"),
new ChatMessage(ChatRole.User, "C"),
]);
Assert.True(trigger(index));
}
[Fact]
public void HasToolCalls_ReturnsTrueWhenToolCallGroupExists()
{
CompactionTrigger trigger = CompactionTriggers.HasToolCalls();
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
new ChatMessage(ChatRole.Tool, "result"),
]);
Assert.True(trigger(index));
}
[Fact]
public void HasToolCalls_ReturnsFalseWhenNoToolCallGroup()
{
CompactionTrigger trigger = CompactionTriggers.HasToolCalls();
MessageIndex index = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
Assert.False(trigger(index));
}
[Fact]
public void All_RequiresAllConditions()
{
CompactionTrigger trigger = CompactionTriggers.All(
CompactionTriggers.TokensExceed(0),
CompactionTriggers.MessagesExceed(5));
MessageIndex small = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
// Tokens > 0 is true, but messages > 5 is false
Assert.False(trigger(small));
}
[Fact]
public void Any_RequiresAtLeastOneCondition()
{
CompactionTrigger trigger = CompactionTriggers.Any(
CompactionTriggers.TokensExceed(999_999),
CompactionTriggers.MessagesExceed(0));
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
// Tokens not exceeded, but messages > 0 is true
Assert.True(trigger(index));
}
[Fact]
public void All_EmptyTriggers_ReturnsTrue()
{
CompactionTrigger trigger = CompactionTriggers.All();
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
Assert.True(trigger(index));
}
[Fact]
public void Any_EmptyTriggers_ReturnsFalse()
{
CompactionTrigger trigger = CompactionTriggers.Any();
MessageIndex index = MessageIndex.Create([new ChatMessage(ChatRole.User, "A")]);
Assert.False(trigger(index));
}
}
@@ -8,7 +8,7 @@
//using Microsoft.Extensions.AI;
//using Moq;
//namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
//namespace Microsoft.Agents.AI.UnitTests.Compaction;
///// <summary>
///// Contains tests for the compaction integration with <see cref="InMemoryChatHistoryProvider"/>.
@@ -1,10 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Abstractions.UnitTests.Compaction;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="MessageIndex"/> class.
@@ -0,0 +1,191 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// <summary>
/// Contains tests for the <see cref="PipelineCompactionStrategy"/> class.
/// </summary>
public class PipelineCompactionStrategyTests
{
[Fact]
public async Task CompactAsync_ExecutesAllStrategiesInOrderAsync()
{
// 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);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
await pipeline.CompactAsync(groups);
// Assert
Assert.Equal(["first", "second"], executionOrder);
}
[Fact]
public async Task CompactAsync_ReturnsFalse_WhenNoStrategyCompactsAsync()
{
// Arrange
TestCompactionStrategy strategy1 = new(_ => false);
PipelineCompactionStrategy pipeline = new(strategy1);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsync_ReturnsTrue_WhenAnyStrategyCompactsAsync()
{
// Arrange
TestCompactionStrategy strategy1 = new(_ => false);
TestCompactionStrategy strategy2 = new(_ => true);
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert
Assert.True(result);
}
[Fact]
public async Task CompactAsync_ContinuesAfterFirstCompaction_WhenEarlyStopDisabledAsync()
{
// Arrange
TestCompactionStrategy strategy1 = new(_ => true);
TestCompactionStrategy strategy2 = new(_ => false);
PipelineCompactionStrategy pipeline = new(strategy1, strategy2);
MessageIndex groups = MessageIndex.Create([new ChatMessage(ChatRole.User, "Hello")]);
// Act
await pipeline.CompactAsync(groups);
// Assert — both strategies were called
Assert.Equal(1, strategy1.ApplyCallCount);
Assert.Equal(1, strategy2.ApplyCallCount);
}
[Fact]
public async Task CompactAsync_ComposesStrategies_EndToEndAsync()
{
// Arrange — pipeline: first exclude oldest 2 non-system groups, then exclude 2 more
static void ExcludeOldest2(MessageIndex index)
{
int excluded = 0;
foreach (MessageGroup group in index.Groups)
{
if (!group.IsExcluded && group.Kind != MessageGroupKind.System && excluded < 2)
{
group.IsExcluded = true;
excluded++;
}
}
}
TestCompactionStrategy phase1 = new(
index =>
{
ExcludeOldest2(index);
return true;
});
TestCompactionStrategy phase2 = new(
index =>
{
ExcludeOldest2(index);
return true;
});
PipelineCompactionStrategy pipeline = new(phase1, phase2);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act
bool result = await pipeline.CompactAsync(groups);
// Assert — system is preserved, phase1 excluded Q1+A1, phase2 excluded Q2+A2 → System + Q3
Assert.True(result);
Assert.Equal(2, groups.IncludedGroupCount);
List<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 CompactAsync_EmptyPipeline_ReturnsFalseAsync()
{
// Arrange
PipelineCompactionStrategy pipeline = new(new List<CompactionStrategy>());
MessageIndex groups = MessageIndex.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<MessageIndex, bool> _applyFunc;
public TestCompactionStrategy(Func<MessageIndex, bool> applyFunc)
: base(CompactionTriggers.Always)
{
this._applyFunc = applyFunc;
}
public int ApplyCallCount { get; private set; }
protected override Task<bool> ApplyCompactionAsync(MessageIndex index, CancellationToken cancellationToken)
{
this.ApplyCallCount++;
return Task.FromResult(this._applyFunc(index));
}
}
}
@@ -0,0 +1,160 @@
// 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 CompactAsync_BelowMaxTurns_ReturnsFalseAsync()
{
// Arrange
SlidingWindowCompactionStrategy strategy = new(maximumTurns: 3);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsync_ExceedsMaxTurns_ExcludesOldestTurnsAsync()
{
// Arrange — keep 2 turns, conversation has 3
SlidingWindowCompactionStrategy strategy = new(maximumTurns: 2);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
new ChatMessage(ChatRole.User, "Q3"),
new ChatMessage(ChatRole.Assistant, "A3"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
// Turn 1 (Q1 + A1) should be excluded
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
// Turn 2 and 3 should remain
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
Assert.False(groups.Groups[4].IsExcluded);
Assert.False(groups.Groups[5].IsExcluded);
}
[Fact]
public async Task CompactAsync_PreservesSystemMessagesAsync()
{
// Arrange
SlidingWindowCompactionStrategy strategy = new(maximumTurns: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
Assert.False(groups.Groups[0].IsExcluded); // System preserved
Assert.True(groups.Groups[1].IsExcluded); // Turn 1 excluded
Assert.True(groups.Groups[2].IsExcluded); // Turn 1 response excluded
Assert.False(groups.Groups[3].IsExcluded); // Turn 2 kept
}
[Fact]
public async Task CompactAsync_PreservesToolCallGroupsInKeptTurnsAsync()
{
// Arrange
SlidingWindowCompactionStrategy strategy = new(maximumTurns: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]),
new ChatMessage(ChatRole.Tool, "Results"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
// Turn 1 excluded
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
// Turn 2 kept (user + tool call group)
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsync_CustomTrigger_OverridesDefaultAsync()
{
// Arrange — custom trigger: only compact when tokens exceed threshold
SlidingWindowCompactionStrategy strategy = new(maximumTurns: 99);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.User, "Q3"),
]);
// Act — tokens are tiny, trigger not met
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsync_IncludedMessages_ContainOnlyKeptTurnsAsync()
{
// Arrange
SlidingWindowCompactionStrategy strategy = new(maximumTurns: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "System"),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert
List<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);
}
}
@@ -0,0 +1,195 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
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="ToolResultCompactionStrategy"/> class.
/// </summary>
public class ToolResultCompactionStrategyTests
{
[Fact]
public async Task CompactAsync_TriggerNotMet_ReturnsFalseAsync()
{
// Arrange — trigger requires > 1000 tokens
ToolResultCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(1000));
ChatMessage toolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "What's the weather?"),
toolCall,
toolResult,
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsync_CollapsesOldToolGroupsAsync()
{
// Arrange — always trigger
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
preserveRecentGroups: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]),
new ChatMessage(ChatRole.Tool, "Sunny and 72°F"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
// Q1 + collapsed tool summary + Q2
Assert.Equal(3, included.Count);
Assert.Equal("Q1", included[0].Text);
Assert.Contains("[Tool calls: get_weather]", included[1].Text);
Assert.Equal("Q2", included[2].Text);
}
[Fact]
public async Task CompactAsync_PreservesRecentToolGroupsAsync()
{
// Arrange — protect 2 recent non-system groups (the tool group + Q2)
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
preserveRecentGroups: 3);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "search")]),
new ChatMessage(ChatRole.Tool, "Results"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert — all groups are in the protected window, nothing to collapse
Assert.False(result);
}
[Fact]
public async Task CompactAsync_PreservesSystemMessagesAsync()
{
// Arrange
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
preserveRecentGroups: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "fn")]),
new ChatMessage(ChatRole.Tool, "result"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
Assert.Equal("You are helpful.", included[0].Text);
}
[Fact]
public async Task CompactAsync_ExtractsMultipleToolNamesAsync()
{
// Arrange — assistant calls two tools
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
preserveRecentGroups: 1);
ChatMessage multiToolCall = new(ChatRole.Assistant,
[
new FunctionCallContent("c1", "get_weather"),
new FunctionCallContent("c2", "search_docs"),
]);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
multiToolCall,
new ChatMessage(ChatRole.Tool, "Sunny"),
new ChatMessage(ChatRole.Tool, "Found 3 docs"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
await strategy.CompactAsync(groups);
// Assert
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
string collapsed = included[1].Text!;
Assert.Contains("get_weather", collapsed);
Assert.Contains("search_docs", collapsed);
}
[Fact]
public async Task CompactAsync_NoToolGroups_ReturnsFalseAsync()
{
// Arrange — trigger fires but no tool groups to collapse
ToolResultCompactionStrategy strategy = new(
trigger: _ => true,
preserveRecentGroups: 0);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsync_CompoundTrigger_RequiresTokensAndToolCallsAsync()
{
// Arrange — compound: tokens > 0 AND has tool calls
ToolResultCompactionStrategy strategy = new(
CompactionTriggers.All(
CompactionTriggers.TokensExceed(0),
CompactionTriggers.HasToolCalls()),
preserveRecentGroups: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
new ChatMessage(ChatRole.Tool, "result"),
new ChatMessage(ChatRole.User, "Q2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
}
}
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
@@ -11,78 +12,91 @@ namespace Microsoft.Agents.AI.UnitTests.Compaction;
/// </summary>
public class TruncationCompactionStrategyTests
{
private static readonly CompactionTrigger s_alwaysTrigger = _ => true;
[Fact]
public async Task CompactAsync_BelowLimit_ReturnsFalseAsync()
public async Task CompactAsync_AlwaysTrigger_CompactsToPreserveRecentAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 5);
// Arrange — always-trigger means always compact
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
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.False(result);
Assert.Equal(2, groups.IncludedGroupCount);
}
[Fact]
public async Task CompactAsync_AtLimit_ReturnsFalseAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 2);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
[Fact]
public async Task CompactAsync_ExceedsLimit_ExcludesOldestGroupsAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 2);
ChatMessage msg1 = new(ChatRole.User, "First");
ChatMessage msg2 = new(ChatRole.Assistant, "Response 1");
ChatMessage msg3 = new(ChatRole.User, "Second");
ChatMessage msg4 = new(ChatRole.Assistant, "Response 2");
MessageIndex groups = MessageIndex.Create([msg1, msg2, msg3, msg4]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
Assert.Equal(1, groups.Groups.Count(g => !g.IsExcluded));
}
[Fact]
public async Task CompactAsync_TriggerNotMet_ReturnsFalseAsync()
{
// Arrange — trigger requires > 1000 tokens, conversation is tiny
TruncationCompactionStrategy strategy = new(
preserveRecentGroups: 1,
trigger: CompactionTriggers.TokensExceed(1000));
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
Assert.Equal(2, groups.IncludedGroupCount);
}
[Fact]
public async Task CompactAsync_TriggerMet_ExcludesOldestGroupsAsync()
{
// Arrange — trigger on groups > 2
TruncationCompactionStrategy strategy = new(
preserveRecentGroups: 1,
trigger: CompactionTriggers.GroupsExceed(2));
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
new ChatMessage(ChatRole.User, "Second"),
new ChatMessage(ChatRole.Assistant, "Response 2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
Assert.Equal(1, groups.IncludedGroupCount);
// Oldest 3 excluded, newest 1 kept
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
Assert.False(groups.Groups[2].IsExcluded);
Assert.True(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsync_PreservesSystemMessages_WhenEnabledAsync()
public async Task CompactAsync_PreservesSystemMessagesAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 2, preserveSystemMessages: true);
ChatMessage systemMsg = new(ChatRole.System, "You are helpful.");
ChatMessage msg1 = new(ChatRole.User, "First");
ChatMessage msg2 = new(ChatRole.Assistant, "Response 1");
ChatMessage msg3 = new(ChatRole.User, "Second");
MessageIndex groups = MessageIndex.Create([systemMsg, msg1, msg2, msg3]);
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.System, "You are helpful."),
new ChatMessage(ChatRole.User, "First"),
new ChatMessage(ChatRole.Assistant, "Response 1"),
new ChatMessage(ChatRole.User, "Second"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
@@ -92,34 +106,10 @@ public class TruncationCompactionStrategyTests
// System message should be preserved
Assert.False(groups.Groups[0].IsExcluded);
Assert.Equal(MessageGroupKind.System, groups.Groups[0].Kind);
// Oldest non-system groups should be excluded
// Oldest non-system groups excluded
Assert.True(groups.Groups[1].IsExcluded);
Assert.True(groups.Groups[2].IsExcluded);
// Most recent should remain
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsync_DoesNotPreserveSystemMessages_WhenDisabledAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 2, preserveSystemMessages: false);
ChatMessage systemMsg = new(ChatRole.System, "You are helpful.");
ChatMessage msg1 = new(ChatRole.User, "First");
ChatMessage msg2 = new(ChatRole.Assistant, "Response");
ChatMessage msg3 = new(ChatRole.User, "Second");
MessageIndex groups = MessageIndex.Create([systemMsg, msg1, msg2, msg3]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
// System message should be excluded (oldest)
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
Assert.False(groups.Groups[2].IsExcluded);
// Most recent kept
Assert.False(groups.Groups[3].IsExcluded);
}
@@ -127,9 +117,9 @@ public class TruncationCompactionStrategyTests
public async Task CompactAsync_PreservesToolCallGroupAtomicityAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 1);
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1);
ChatMessage assistantToolCall = new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage assistantToolCall= new(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather")]);
ChatMessage toolResult = new(ChatRole.Tool, "Sunny");
ChatMessage finalResponse = new(ChatRole.User, "Thanks!");
@@ -151,7 +141,7 @@ public class TruncationCompactionStrategyTests
public async Task CompactAsync_SetsExcludeReasonAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 1);
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Old"),
@@ -170,7 +160,7 @@ public class TruncationCompactionStrategyTests
public async Task CompactAsync_SkipsAlreadyExcludedGroupsAsync()
{
// Arrange
TruncationCompactionStrategy strategy = new(maxGroups: 1);
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 1);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Already excluded"),
@@ -188,4 +178,46 @@ public class TruncationCompactionStrategyTests
Assert.True(groups.Groups[1].IsExcluded); // newly excluded
Assert.False(groups.Groups[2].IsExcluded); // kept
}
[Fact]
public async Task CompactAsync_PreserveRecentGroups_KeepsMultipleAsync()
{
// Arrange — keep 2 most recent
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 2);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
new ChatMessage(ChatRole.User, "Q2"),
new ChatMessage(ChatRole.Assistant, "A2"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.True(result);
Assert.True(groups.Groups[0].IsExcluded);
Assert.True(groups.Groups[1].IsExcluded);
Assert.False(groups.Groups[2].IsExcluded);
Assert.False(groups.Groups[3].IsExcluded);
}
[Fact]
public async Task CompactAsync_NothingToRemove_ReturnsFalseAsync()
{
// Arrange — preserve 5 but only 2 groups
TruncationCompactionStrategy strategy = new(s_alwaysTrigger, preserveRecentGroups: 5);
MessageIndex groups = MessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
new ChatMessage(ChatRole.Assistant, "Hi!"),
]);
// Act
bool result = await strategy.CompactAsync(groups);
// Assert
Assert.False(result);
}
}