Compare commits

..
7 changed files with 108 additions and 281 deletions
@@ -104,15 +104,17 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
{
State state = this._sessionState.GetOrInitializeState(context.Session);
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
{
// Reduce existing messages before adding new messages from the current turn.
// This ensures messages from the current turn (including function calls and tool results)
// are always preserved in full and are not immediately reduced.
await ReduceMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false);
}
// Add request and response messages to the provider
var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []);
state.Messages.AddRange(allNewMessages);
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
{
// Apply pre-write reduction strategy if configured
await ReduceMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false);
}
}
private static async Task ReduceMessagesAsync(IChatReducer reducer, State state, CancellationToken cancellationToken = default)
@@ -69,11 +69,13 @@ public sealed class ChatReducerCompactionStrategy : CompactionStrategy
return false;
}
// Rebuild the index from the reduced messages.
// Use Update() rather than directly manipulating Groups so that
// cached metrics (IncludedGroupCount, token counts, etc.) are
// properly invalidated.
index.Update(reducedMessages);
// Rebuild the index from the reduced messages
CompactionMessageIndex rebuilt = CompactionMessageIndex.Create(reducedMessages, index.Tokenizer);
index.Groups.Clear();
foreach (CompactionMessageGroup group in rebuilt.Groups)
{
index.Groups.Add(group);
}
return true;
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
@@ -40,15 +39,9 @@ public sealed class CompactionMessageGroup
/// </remarks>
public static readonly string SummaryPropertyKey = "_is_summary";
private readonly Action _exclusionChangedCallback;
/// <summary>
/// Initializes a new instance of the <see cref="CompactionMessageGroup"/> class.
/// </summary>
/// <param name="exclusionChangedCallback">
/// A callback invoked when <see cref="IsExcluded"/> changes value.
/// Used internally by <see cref="CompactionMessageIndex"/> to invalidate cached aggregates.
/// </param>
/// <param name="kind">The kind of message group.</param>
/// <param name="messages">The messages in this group. The list is captured as a read-only snapshot.</param>
/// <param name="byteCount">The total UTF-8 byte count of the text content in the messages.</param>
@@ -57,15 +50,8 @@ public sealed class CompactionMessageGroup
/// The user turn this group belongs to, or <see langword="null"/> for <see cref="CompactionGroupKind.System"/>.
/// </param>
[JsonConstructor]
internal CompactionMessageGroup(
Action exclusionChangedCallback,
CompactionGroupKind kind,
IReadOnlyList<ChatMessage> messages,
int byteCount,
int tokenCount,
int? turnIndex = null)
internal CompactionMessageGroup(CompactionGroupKind kind, IReadOnlyList<ChatMessage> messages, int byteCount, int tokenCount, int? turnIndex = null)
{
this._exclusionChangedCallback = exclusionChangedCallback;
this.Kind = kind;
this.Messages = messages;
this.MessageCount = messages.Count;
@@ -121,18 +107,7 @@ public sealed class CompactionMessageGroup
/// Excluded groups are preserved in the collection for diagnostics or storage purposes
/// but are not included when calling <see cref="CompactionMessageIndex.GetIncludedMessages"/>.
/// </remarks>
public bool IsExcluded
{
get;
set
{
if (field != value)
{
field = value;
this._exclusionChangedCallback.Invoke();
}
}
}
public bool IsExcluded { get; set; }
/// <summary>
/// Gets or sets an optional reason explaining why this group was excluded.
@@ -27,20 +27,6 @@ public sealed class CompactionMessageIndex
private int _currentTurn;
private ChatMessage? _lastProcessedMessage;
// Cached values for derived properties — invalidated whenever groups are added/removed
// or a group's IsExcluded state changes.
private int? _cachedTotalMessageCount;
private int? _cachedTotalByteCount;
private int? _cachedTotalTokenCount;
private int? _cachedIncludedGroupCount;
private int? _cachedIncludedMessageCount;
private int? _cachedIncludedByteCount;
private int? _cachedIncludedTokenCount;
private int? _cachedTotalTurnCount;
private int? _cachedIncludedTurnCount;
private int? _cachedIncludedNonSystemGroupCount;
private int? _cachedRawMessageCount;
/// <summary>
/// Gets the list of message groups in this collection.
/// </summary>
@@ -137,7 +123,6 @@ public sealed class CompactionMessageIndex
this.Groups.Clear();
this._currentTurn = 0;
this._lastProcessedMessage = null;
this.InvalidateCache();
return;
}
@@ -199,13 +184,13 @@ public sealed class CompactionMessageIndex
if (message.Role == ChatRole.System)
{
// System messages are not part of any turn
this.AddGroup(CompactionGroupKind.System, [message], turnIndex: null);
this.Groups.Add(CreateGroup(CompactionGroupKind.System, [message], this.Tokenizer, turnIndex: null));
index++;
}
else if (message.Role == ChatRole.User)
{
this._currentTurn++;
this.AddGroup(CompactionGroupKind.User, [message], this._currentTurn);
this.Groups.Add(CreateGroup(CompactionGroupKind.User, [message], this.Tokenizer, this._currentTurn));
index++;
}
else if (message.Role == ChatRole.Assistant && HasToolCalls(message))
@@ -222,11 +207,11 @@ public sealed class CompactionMessageIndex
index++;
}
this.AddGroup(CompactionGroupKind.ToolCall, groupMessages, this._currentTurn);
this.Groups.Add(CreateGroup(CompactionGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
}
else if (message.Role == ChatRole.Assistant && IsSummaryMessage(message))
{
this.AddGroup(CompactionGroupKind.Summary, [message], this._currentTurn);
this.Groups.Add(CreateGroup(CompactionGroupKind.Summary, [message], this.Tokenizer, this._currentTurn));
index++;
}
else if (message.Role == ChatRole.Assistant && HasOnlyReasoning(message))
@@ -262,17 +247,17 @@ public sealed class CompactionMessageIndex
index++;
}
this.AddGroup(CompactionGroupKind.ToolCall, groupMessages, this._currentTurn);
this.Groups.Add(CreateGroup(CompactionGroupKind.ToolCall, groupMessages, this.Tokenizer, this._currentTurn));
}
else
{
this.AddGroup(CompactionGroupKind.AssistantText, [message], this._currentTurn);
this.Groups.Add(CreateGroup(CompactionGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
index++;
}
}
else
{
this.AddGroup(CompactionGroupKind.AssistantText, [message], this._currentTurn);
this.Groups.Add(CreateGroup(CompactionGroupKind.AssistantText, [message], this.Tokenizer, this._currentTurn));
index++;
}
}
@@ -281,8 +266,6 @@ public sealed class CompactionMessageIndex
{
this._lastProcessedMessage = messages[^1];
}
this.InvalidateCache();
}
/// <summary>
@@ -296,9 +279,8 @@ public sealed class CompactionMessageIndex
/// <returns>The newly created <see cref="CompactionMessageGroup"/>.</returns>
public CompactionMessageGroup InsertGroup(int index, CompactionGroupKind kind, IReadOnlyList<ChatMessage> messages, int? turnIndex = null)
{
CompactionMessageGroup group = this.CreateGroup(kind, messages, this.Tokenizer, turnIndex); // %%% DERIVE TURNINDEX
CompactionMessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
this.Groups.Insert(index, group);
this.InvalidateCache();
return group;
}
@@ -312,9 +294,8 @@ public sealed class CompactionMessageIndex
/// <returns>The newly created <see cref="CompactionMessageGroup"/>.</returns>
public CompactionMessageGroup AddGroup(CompactionGroupKind kind, IReadOnlyList<ChatMessage> messages, int? turnIndex = null)
{
CompactionMessageGroup group = this.CreateGroup(kind, messages, this.Tokenizer, turnIndex); // %%% DERIVE TURNINDEX
CompactionMessageGroup group = CreateGroup(kind, messages, this.Tokenizer, turnIndex);
this.Groups.Add(group);
this.InvalidateCache();
return group;
}
@@ -339,57 +320,57 @@ public sealed class CompactionMessageIndex
/// <summary>
/// Gets the total number of messages across all groups, including excluded ones.
/// </summary>
public int TotalMessageCount => this._cachedTotalMessageCount ??= this.Groups.Sum(group => group.MessageCount);
public int TotalMessageCount => this.Groups.Sum(group => group.MessageCount);
/// <summary>
/// Gets the total UTF-8 byte count across all groups, including excluded ones.
/// </summary>
public int TotalByteCount => this._cachedTotalByteCount ??= this.Groups.Sum(group => group.ByteCount);
public int TotalByteCount => this.Groups.Sum(group => group.ByteCount);
/// <summary>
/// Gets the total token count across all groups, including excluded ones.
/// </summary>
public int TotalTokenCount => this._cachedTotalTokenCount ??= this.Groups.Sum(group => group.TokenCount);
public int TotalTokenCount => this.Groups.Sum(group => group.TokenCount);
/// <summary>
/// Gets the total number of groups that are not excluded.
/// </summary>
public int IncludedGroupCount => this._cachedIncludedGroupCount ??= this.Groups.Count(group => !group.IsExcluded);
public int IncludedGroupCount => this.Groups.Count(group => !group.IsExcluded);
/// <summary>
/// Gets the total number of messages across all included (non-excluded) groups.
/// </summary>
public int IncludedMessageCount => this._cachedIncludedMessageCount ??= this.Groups.Where(group => !group.IsExcluded).Sum(group => group.MessageCount);
public int IncludedMessageCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.MessageCount);
/// <summary>
/// Gets the total UTF-8 byte count across all included (non-excluded) groups.
/// </summary>
public int IncludedByteCount => this._cachedIncludedByteCount ??= this.Groups.Where(group => !group.IsExcluded).Sum(group => group.ByteCount);
public int IncludedByteCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.ByteCount);
/// <summary>
/// Gets the total token count across all included (non-excluded) groups.
/// </summary>
public int IncludedTokenCount => this._cachedIncludedTokenCount ??= this.Groups.Where(group => !group.IsExcluded).Sum(group => group.TokenCount);
public int IncludedTokenCount => this.Groups.Where(group => !group.IsExcluded).Sum(group => group.TokenCount);
/// <summary>
/// Gets the total number of user turns across all groups (including those with excluded groups).
/// </summary>
public int TotalTurnCount => this._cachedTotalTurnCount ??= this.Groups.Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null && turnIndex > 0);
public int TotalTurnCount => this.Groups.Select(group => group.TurnIndex).Distinct().Count(turnIndex => turnIndex is not null && turnIndex > 0);
/// <summary>
/// Gets the number of user turns that have at least one non-excluded group.
/// </summary>
public int IncludedTurnCount => this._cachedIncludedTurnCount ??= this.Groups.Where(group => !group.IsExcluded && group.TurnIndex is not null && group.TurnIndex > 0).Select(group => group.TurnIndex).Distinct().Count();
public int IncludedTurnCount => this.Groups.Where(group => !group.IsExcluded && group.TurnIndex is not null && group.TurnIndex > 0).Select(group => group.TurnIndex).Distinct().Count();
/// <summary>
/// Gets the total number of groups across all included (non-excluded) groups that are not <see cref="CompactionGroupKind.System"/>.
/// </summary>
public int IncludedNonSystemGroupCount => this._cachedIncludedNonSystemGroupCount ??= this.Groups.Count(group => !group.IsExcluded && group.Kind != CompactionGroupKind.System);
public int IncludedNonSystemGroupCount => this.Groups.Count(group => !group.IsExcluded && group.Kind != CompactionGroupKind.System);
/// <summary>
/// Gets the total number of original messages (that are not summaries).
/// </summary>
public int RawMessageCount => this._cachedRawMessageCount ??= this.Groups.Where(group => group.Kind != CompactionGroupKind.Summary).Sum(group => group.MessageCount);
public int RawMessageCount => this.Groups.Where(group => group.Kind != CompactionGroupKind.Summary).Sum(group => group.MessageCount);
/// <summary>
/// Returns all groups that belong to the specified user turn.
@@ -398,21 +379,6 @@ public sealed class CompactionMessageIndex
/// <returns>The groups belonging to the turn, in order.</returns>
public IEnumerable<CompactionMessageGroup> GetTurnGroups(int turnIndex) => this.Groups.Where(group => group.TurnIndex == turnIndex);
private void InvalidateCache()
{
this._cachedTotalMessageCount = null;
this._cachedTotalByteCount = null;
this._cachedTotalTokenCount = null;
this._cachedIncludedGroupCount = null;
this._cachedIncludedMessageCount = null;
this._cachedIncludedByteCount = null;
this._cachedIncludedTokenCount = null;
this._cachedTotalTurnCount = null;
this._cachedIncludedTurnCount = null;
this._cachedIncludedNonSystemGroupCount = null;
this._cachedRawMessageCount = null;
}
/// <summary>
/// Computes the UTF-8 byte count for a set of messages across all content types.
/// </summary>
@@ -531,14 +497,14 @@ public sealed class CompactionMessageIndex
private static int GetStringByteCount(string? value) =>
value is { Length: > 0 } ? Encoding.UTF8.GetByteCount(value) : 0;
private CompactionMessageGroup CreateGroup(CompactionGroupKind kind, IReadOnlyList<ChatMessage> messages, Tokenizer? tokenizer, int? turnIndex)
private static CompactionMessageGroup CreateGroup(CompactionGroupKind kind, IReadOnlyList<ChatMessage> messages, Tokenizer? tokenizer, int? turnIndex)
{
int byteCount = ComputeByteCount(messages);
int tokenCount = tokenizer is not null
? ComputeTokenCount(messages, tokenizer)
: byteCount / 4;
return new CompactionMessageGroup(this.InvalidateCache, kind, messages, byteCount, tokenCount, turnIndex);
return new CompactionMessageGroup(kind, messages, byteCount, tokenCount, turnIndex);
}
private static bool HasToolCalls(ChatMessage message)
@@ -243,7 +243,8 @@ public class InMemoryChatHistoryProviderTests
var session = CreateMockSession();
// Arrange
var originalMessages = new List<ChatMessage>
// Existing messages in state from a previous turn.
var existingMessages = new List<ChatMessage>
{
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there!")
@@ -253,22 +254,78 @@ public class InMemoryChatHistoryProviderTests
new(ChatRole.User, "Reduced")
};
// New messages being added in the current turn.
var newRequestMessage = new ChatMessage(ChatRole.User, "New message");
var newResponseMessage = new ChatMessage(ChatRole.Assistant, "New response");
var reducerMock = new Mock<IChatReducer>();
reducerMock
.Setup(r => r.ReduceAsync(It.Is<List<ChatMessage>>(x => x.SequenceEqual(originalMessages)), It.IsAny<CancellationToken>()))
.Setup(r => r.ReduceAsync(It.Is<List<ChatMessage>>(x => x.SequenceEqual(existingMessages)), It.IsAny<CancellationToken>()))
.ReturnsAsync(reducedMessages);
var provider = new InMemoryChatHistoryProvider(new() { ChatReducer = reducerMock.Object, ReducerTriggerEvent = InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded });
provider.SetMessages(session, new List<ChatMessage>(existingMessages));
// Act
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, originalMessages, []);
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, [newRequestMessage], [newResponseMessage]);
await provider.InvokedAsync(context, CancellationToken.None);
// Assert
// The reducer is called on existing messages before the new ones are added.
reducerMock.Verify(r => r.ReduceAsync(It.Is<List<ChatMessage>>(x => x.SequenceEqual(existingMessages)), It.IsAny<CancellationToken>()), Times.Once);
// Final state: reduced existing messages + new current-turn messages (preserved in full).
var messages = provider.GetMessages(session);
Assert.Single(messages);
Assert.Equal(3, messages.Count);
Assert.Equal("Reduced", messages[0].Text);
reducerMock.Verify(r => r.ReduceAsync(It.Is<List<ChatMessage>>(x => x.SequenceEqual(originalMessages)), It.IsAny<CancellationToken>()), Times.Once);
Assert.Equal("New message", messages[1].Text);
Assert.Equal("New response", messages[2].Text);
}
[Fact]
public async Task AddMessagesAsync_WithReducer_AfterMessageAdded_PreservesCurrentTurnFunctionCallsAsync()
{
var session = CreateMockSession();
// Arrange - verify that function call and tool result messages from the current turn are preserved
// even when a reducer is configured with AfterMessageAdded trigger. The reducer should only
// be applied to existing (previous-turn) messages, not to the new messages being added.
var existingMessages = new List<ChatMessage>
{
new(ChatRole.User, "Previous question"),
new(ChatRole.Assistant, "Previous answer")
};
var reducerMock = new Mock<IChatReducer>();
reducerMock
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([]); // Simulates an aggressive reducer that clears all messages it receives
var provider = new InMemoryChatHistoryProvider(new() { ChatReducer = reducerMock.Object, ReducerTriggerEvent = InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded });
provider.SetMessages(session, new List<ChatMessage>(existingMessages));
var requestMessages = new List<ChatMessage>
{
new(ChatRole.User, "What is the weather in Taggia?")
};
var responseMessages = new List<ChatMessage>
{
new(ChatRole.Assistant, [new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["location"] = "Taggia" })]),
new(ChatRole.Tool, [new FunctionResultContent("call1", "Cloudy with a high of 15°C")]),
new(ChatRole.Assistant, "The weather in Taggia is cloudy with a high of 15°C.")
};
// Act
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, requestMessages, responseMessages);
await provider.InvokedAsync(context, CancellationToken.None);
// Assert - all current-turn messages (including function call and tool result) are preserved
var messages = provider.GetMessages(session);
Assert.Equal(4, messages.Count);
Assert.Equal("What is the weather in Taggia?", messages[0].Text);
Assert.True(messages[1].Contents.OfType<FunctionCallContent>().Any(), "Function call message should be preserved");
Assert.True(messages[2].Contents.OfType<FunctionResultContent>().Any(), "Tool result message should be preserved");
Assert.Equal("The weather in Taggia is cloudy with a high of 15°C.", messages[3].Text);
}
[Fact]
@@ -263,7 +263,7 @@ public class CompactionMessageIndexTests
public void MessageGroupStoresPassedCounts()
{
// Arrange & Act
CompactionMessageGroup group = new(InvalidateCallback, CompactionGroupKind.User, [new ChatMessage(ChatRole.User, "Hello")], byteCount: 5, tokenCount: 2);
CompactionMessageGroup group = new(CompactionGroupKind.User, [new ChatMessage(ChatRole.User, "Hello")], byteCount: 5, tokenCount: 2);
// Assert
Assert.Equal(1, group.MessageCount);
@@ -276,7 +276,7 @@ public class CompactionMessageIndexTests
{
// Arrange
IReadOnlyList<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Hello")];
CompactionMessageGroup group = new(InvalidateCallback, CompactionGroupKind.User, messages, byteCount: 5, tokenCount: 1);
CompactionMessageGroup group = new(CompactionGroupKind.User, messages, byteCount: 5, tokenCount: 1);
// Assert — Messages is IReadOnlyList, not IList
Assert.IsType<IReadOnlyList<ChatMessage>>(group.Messages, exactMatch: false);
@@ -752,9 +752,9 @@ public class CompactionMessageIndexTests
public void ConstructorWithGroupsRestoresTurnIndex()
{
// Arrange — pre-existing groups with turn indices
CompactionMessageGroup group1 = new(InvalidateCallback, CompactionGroupKind.User, [new ChatMessage(ChatRole.User, "Q1")], 2, 1, turnIndex: 1);
CompactionMessageGroup group2 = new(InvalidateCallback, CompactionGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, "A1")], 2, 1, turnIndex: 1);
CompactionMessageGroup group3 = new(InvalidateCallback, CompactionGroupKind.User, [new ChatMessage(ChatRole.User, "Q2")], 2, 1, turnIndex: 2);
CompactionMessageGroup group1 = new(CompactionGroupKind.User, [new ChatMessage(ChatRole.User, "Q1")], 2, 1, turnIndex: 1);
CompactionMessageGroup group2 = new(CompactionGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, "A1")], 2, 1, turnIndex: 1);
CompactionMessageGroup group3 = new(CompactionGroupKind.User, [new ChatMessage(ChatRole.User, "Q2")], 2, 1, turnIndex: 2);
List<CompactionMessageGroup> groups = [group1, group2, group3];
// Act — constructor should restore _currentTurn from the last group's TurnIndex
@@ -789,7 +789,7 @@ public class CompactionMessageIndexTests
public void ConstructorWithGroupsWithoutTurnIndexSkipsRestore()
{
// Arrange — groups without turn indices (system messages)
CompactionMessageGroup systemGroup = new(InvalidateCallback, CompactionGroupKind.System, [new ChatMessage(ChatRole.System, "Be helpful")], 10, 3, turnIndex: null);
CompactionMessageGroup systemGroup = new(CompactionGroupKind.System, [new ChatMessage(ChatRole.System, "Be helpful")], 10, 3, turnIndex: null);
List<CompactionMessageGroup> groups = [systemGroup];
// Act — constructor won't find a TurnIndex to restore
@@ -1474,179 +1474,4 @@ public class CompactionMessageIndexTests
Assert.Equal(CompactionGroupKind.ToolCall, index.Groups[2].Kind);
Assert.Equal(3, index.Groups[2].MessageCount); // reasoning + toolCall + toolResult
}
// -----------------------------------------------------------------------
// Cache invalidation tests
// -----------------------------------------------------------------------
[Fact]
public void CachedMetricsAreInvalidatedWhenIsExcludedChanges()
{
// Arrange — two groups, read Included* properties (populates cache)
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "AAAA"), // 4 bytes, 1 token
new ChatMessage(ChatRole.Assistant, "BBBB"), // 4 bytes, 1 token
]);
// Prime the cache by reading properties
Assert.Equal(2, index.IncludedGroupCount);
Assert.Equal(2, index.IncludedMessageCount);
Assert.Equal(8, index.IncludedByteCount);
Assert.Equal(2, index.IncludedTokenCount);
Assert.Equal(1, index.IncludedTurnCount);
Assert.Equal(2, index.IncludedNonSystemGroupCount);
// Act — exclude both groups in turn 1; cache must be invalidated
index.Groups[0].IsExcluded = true;
index.Groups[1].IsExcluded = true;
// Assert — Included* properties now reflect the exclusions
Assert.Equal(0, index.IncludedGroupCount);
Assert.Equal(0, index.IncludedMessageCount);
Assert.Equal(0, index.IncludedByteCount);
Assert.Equal(0, index.IncludedTokenCount);
Assert.Equal(0, index.IncludedTurnCount); // turn 1 is fully excluded
Assert.Equal(0, index.IncludedNonSystemGroupCount);
}
[Fact]
public void CachedMetricsAreInvalidatedWhenGroupAddedViaUpdate()
{
// Arrange — start with two messages, read RawMessageCount (populates cache)
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
];
CompactionMessageIndex index = CompactionMessageIndex.Create(messages);
// Prime the cache
Assert.Equal(2, index.RawMessageCount);
Assert.Equal(2, index.IncludedGroupCount);
// Act — add two more messages
messages.Add(new ChatMessage(ChatRole.User, "Q2"));
messages.Add(new ChatMessage(ChatRole.Assistant, "A2"));
index.Update(messages);
// Assert — cached values updated
Assert.Equal(4, index.RawMessageCount);
Assert.Equal(4, index.IncludedGroupCount);
}
[Fact]
public void CachedMetricsAreInvalidatedWhenGroupAddedViaAddGroup()
{
// Arrange — prime the cache
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
]);
Assert.Equal(1, index.IncludedGroupCount);
Assert.Equal(1, index.TotalMessageCount);
// Act — add a group manually
index.AddGroup(CompactionGroupKind.AssistantText, [new ChatMessage(ChatRole.Assistant, "Hi")], turnIndex: 1);
// Assert — cache invalidated and recomputed
Assert.Equal(2, index.IncludedGroupCount);
Assert.Equal(2, index.TotalMessageCount);
}
[Fact]
public void CachedMetricsAreInvalidatedWhenGroupInsertedViaInsertGroup()
{
// Arrange — prime the cache
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Hello"),
]);
Assert.Equal(1, index.IncludedGroupCount);
Assert.Equal(1, index.TotalMessageCount);
// Act — insert a group
ChatMessage summaryMsg = new(ChatRole.Assistant, "[Summary]");
(summaryMsg.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true;
index.InsertGroup(0, CompactionGroupKind.Summary, [summaryMsg]);
// Assert — cache invalidated and recomputed
Assert.Equal(2, index.IncludedGroupCount);
Assert.Equal(2, index.TotalMessageCount);
Assert.Equal(1, index.RawMessageCount); // Summary excluded from RawMessageCount
}
[Fact]
public void CachedMetricsAreInvalidatedWhenIndexRebuiltByUpdate()
{
// Arrange — populate and prime cache
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
];
CompactionMessageIndex index = CompactionMessageIndex.Create(messages);
index.Groups[0].IsExcluded = true;
// Prime the cache after exclusion
Assert.Equal(1, index.IncludedGroupCount);
// Act — update with a completely different (shorter) list — forces full rebuild
List<ChatMessage> newMessages =
[
new ChatMessage(ChatRole.User, "NewQ"),
];
index.Update(newMessages);
// Assert — full rebuild, previously excluded group is gone, cache is correct
Assert.Equal(1, index.IncludedGroupCount);
Assert.Equal(1, index.RawMessageCount);
}
[Fact]
public void CachedMetricsAreInvalidatedWhenUpdateClearsAllMessages()
{
// Arrange — prime the cache
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "Q1"),
new ChatMessage(ChatRole.Assistant, "A1"),
]);
Assert.Equal(2, index.IncludedGroupCount);
Assert.Equal(2, index.RawMessageCount);
// Act — update with empty list
index.Update([]);
// Assert — all cached metrics reset
Assert.Equal(0, index.IncludedGroupCount);
Assert.Equal(0, index.TotalGroupCount);
Assert.Equal(0, index.RawMessageCount);
Assert.Equal(0, index.IncludedTokenCount);
}
[Fact]
public void CachedTotalMetricsUnchangedWhenOnlyExcludedStateChanges()
{
// Arrange — prime the cache
CompactionMessageIndex index = CompactionMessageIndex.Create(
[
new ChatMessage(ChatRole.User, "AAAA"), // 4 bytes
new ChatMessage(ChatRole.Assistant, "BBBB"), // 4 bytes
]);
Assert.Equal(2, index.TotalGroupCount);
Assert.Equal(2, index.TotalMessageCount);
Assert.Equal(8, index.TotalByteCount);
// Act — exclude a group
index.Groups[0].IsExcluded = true;
// Assert — Total* properties still include both groups
Assert.Equal(2, index.TotalGroupCount);
Assert.Equal(2, index.TotalMessageCount);
Assert.Equal(8, index.TotalByteCount);
Assert.Equal(2, index.TotalTokenCount);
}
private static void InvalidateCallback() { }
}
@@ -356,7 +356,7 @@ public sealed class CompactionProviderTests
Assert.Empty(state.MessageGroups);
// Act
state.MessageGroups = [new CompactionMessageGroup(() => { }, CompactionGroupKind.User, [], 0, 0, 0)];
state.MessageGroups = [new CompactionMessageGroup(CompactionGroupKind.User, [], 0, 0, 0)];
// Assert
Assert.Single(state.MessageGroups);