diff --git a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs index e658265c642..9134a4d5bad 100644 --- a/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI/AgentJsonUtilities.cs @@ -5,6 +5,7 @@ using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Agents.AI.Compaction; namespace Microsoft.Agents.AI; @@ -70,6 +71,9 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(TextSearchProvider.TextSearchProviderState))] [JsonSerializable(typeof(ChatHistoryMemoryProvider.State))] + // CompactionProvider types + [JsonSerializable(typeof(CompactionProvider.State), TypeInfoPropertyName = "CompactionProviderState")] + // TodoProvider types [JsonSerializable(typeof(TodoState))] [JsonSerializable(typeof(TodoItem))] diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs index 493c724204f..2afb98d2aff 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/ChatReducerCompactionStrategy.cs @@ -79,13 +79,7 @@ protected override async ValueTask CompactCoreAsync(CompactionMessageIndex return false; } - // 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); - } + index.ReplaceWithReducedMessages(reducedMessages); return true; } diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionInputFingerprint.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionInputFingerprint.cs new file mode 100644 index 00000000000..417446a1eb0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionInputFingerprint.cs @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Numerics; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Compaction; + +/// +/// Captures input identity and content independently of mutable message objects without retaining the original history. +/// +internal static class CompactionInputFingerprint +{ + internal static string? Compute(IList messages, int count) + { + try + { + using SHA256 hash = SHA256.Create(); + using CryptoStream stream = new(Stream.Null, hash, CryptoStreamMode.Write); + using Utf8JsonWriter writer = new(stream); + writer.WriteStartArray(); + for (int i = 0; i < count; i++) + { + ChatMessage message = messages[i]; + writer.WriteStartArray(); + writer.WriteStringValue(message.MessageId); + writer.WriteStringValue(message.Role.Value); + writer.WriteStringValue(message.AuthorName); + writer.WriteBooleanValue(CompactionMessageIndex.IsSummaryMessage(message)); + writer.WriteStartArray(); + foreach (AIContent content in message.Contents) + { + if (content.GetType() == typeof(TextContent) && content is TextContent { Annotations: null, AdditionalProperties: null } text) + { + // Plain text needs no intermediate JSON document or property sorting. + writer.WriteStartArray(); + writer.WriteStringValue("text"); + writer.WriteStringValue(text.Text); + writer.WriteEndArray(); + continue; + } + + JsonElement serialized; + try + { + serialized = JsonSerializer.SerializeToElement(content, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIContent))); + } + catch (Exception ex) when (ex is InvalidOperationException or ArgumentException) + { + // Undefined JSON values and non-finite numbers cannot validate the saved prefix. + return null; + } + + string? nullPropertyToOmit = content is FunctionResultContent ? "result" : null; + WriteCanonical(writer, serialized, nullPropertyToOmit); + } + + writer.WriteEndArray(); + writer.WriteEndArray(); + } + + writer.WriteEndArray(); + writer.Flush(); + stream.FlushFinalBlock(); + return Convert.ToBase64String(hash.Hash!); + } + catch (NotSupportedException) + { + // Content without a serialization contract cannot validate a saved prefix; rebuild it instead. + return null; + } + catch (JsonException) + { + return null; + } + } + + private static void WriteCanonical(Utf8JsonWriter writer, JsonElement value, string? nullPropertyToOmit = null) + { + switch (value.ValueKind) + { + case JsonValueKind.Object: + writer.WriteStartObject(); + foreach (JsonProperty property in value.EnumerateObject().OrderBy(property => property.Name, StringComparer.Ordinal)) + { + // Optional payload fields are omitted for CLR null but written for a JSON null value. + if (nullPropertyToOmit is not null && property.NameEquals(nullPropertyToOmit) && property.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + + writer.WritePropertyName(property.Name); + WriteCanonical(writer, property.Value); + } + + writer.WriteEndObject(); + break; + case JsonValueKind.Array: + writer.WriteStartArray(); + foreach (JsonElement item in value.EnumerateArray()) + { + WriteCanonical(writer, item); + } + + writer.WriteEndArray(); + break; + case JsonValueKind.String: + writer.WriteStringValue(value.GetString()); + break; + case JsonValueKind.Number: + WriteNumber(writer, value.GetRawText()); + break; + default: + value.WriteTo(writer); + break; + } + } + + private static void WriteNumber(Utf8JsonWriter writer, string number) + { + // Normalize the exact decimal coefficient and exponent without rounding through a CLR numeric type. + int exponentIndex = number.IndexOfAny(['e', 'E']); + BigInteger exponent = exponentIndex < 0 ? BigInteger.Zero : BigInteger.Parse(number.Substring(exponentIndex + 1), CultureInfo.InvariantCulture); + string coefficient = exponentIndex < 0 ? number : number.Substring(0, exponentIndex); + int decimalIndex = coefficient.IndexOf('.'); + if (decimalIndex >= 0) + { + exponent -= coefficient.Length - decimalIndex - 1; + coefficient = coefficient.Remove(decimalIndex, 1); + } + + bool negative = coefficient[0] == '-'; + if (negative) + { + coefficient = coefficient.Substring(1); + } + + coefficient = coefficient.TrimStart('0'); + string trimmed = coefficient.TrimEnd('0'); + exponent += coefficient.Length - trimmed.Length; + writer.WriteRawValue(trimmed.Length == 0 ? "0" : (negative ? "-" : "") + trimmed + "e" + exponent.ToString(CultureInfo.InvariantCulture), skipInputValidation: true); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs index 3edfb4591aa..6e8d17587d5 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs @@ -26,7 +26,7 @@ namespace Microsoft.Agents.AI.Compaction; public sealed class CompactionMessageIndex { private int _currentTurn; - private ChatMessage? _lastProcessedMessage; + private HashSet? _inputSummaryGroups; /// /// Gets the list of message groups in this collection. @@ -48,24 +48,13 @@ public CompactionMessageIndex(IList groups, Tokenizer? t this.Groups = Throw.IfNull(groups, nameof(groups)); this.Tokenizer = tokenizer; - // Restore turn counter and last processed message from the groups + // Restore the turn counter from the latest group that belongs to a turn. for (int index = groups.Count - 1; index >= 0; --index) { - if (this._lastProcessedMessage is null && this.Groups[index].Kind != CompactionGroupKind.Summary) - { - IReadOnlyList groupMessages = this.Groups[index].Messages; - this._lastProcessedMessage = groupMessages[^1]; - } - if (this.Groups[index].TurnIndex.HasValue) { this._currentTurn = this.Groups[index].TurnIndex!.Value; - - // Both values restored — no need to keep scanning - if (this._lastProcessedMessage is not null) - { - break; - } + break; } } } @@ -96,6 +85,53 @@ internal static CompactionMessageIndex Create(IList messages, Token return instance; } + internal static CompactionMessageIndex Restore(IList groups, int? processedInputMessageCount, IReadOnlyList? inputSummaryGroupIndices, string? inputPrefixFingerprint = null) + { + CompactionMessageIndex instance = new(groups); + instance.ProcessedInputMessageCount = processedInputMessageCount; + instance.InputPrefixFingerprint = processedInputMessageCount is >= 0 ? inputPrefixFingerprint : null; + + if (inputSummaryGroupIndices is not null) + { + foreach (int index in inputSummaryGroupIndices) + { + if (index < 0 || index >= groups.Count || groups[index].Kind != CompactionGroupKind.Summary || + !(instance._inputSummaryGroups ??= []).Add(groups[index])) + { + instance._inputSummaryGroups?.Clear(); + break; + } + } + } + + return instance; + } + + internal int? ProcessedInputMessageCount { get; private set; } + + internal string? InputPrefixFingerprint { get; private set; } + + internal List InputSummaryGroupIndices + { + get + { + List indices = []; + if (this._inputSummaryGroups is not null) + { + // Strategies may insert generated groups before an input summary, so record its current position. + for (int index = 0; index < this.Groups.Count; index++) + { + if (this._inputSummaryGroups.Contains(this.Groups[index])) + { + indices.Add(index); + } + } + } + + return indices; + } + } + /// /// Incrementally updates the groups with new messages from the conversation. /// @@ -105,73 +141,62 @@ internal static CompactionMessageIndex Create(IList messages, Token /// /// /// - /// Uses equality on the last processed message to detect changes. Only the messages after that position are - /// processed and appended as new groups. Existing groups and their compaction state (exclusions) are preserved. + /// Verifies the previously processed input prefix before appending new messages. Existing groups and their + /// compaction state (exclusions) are preserved when that prefix is unchanged. /// /// - /// If the last processed message is not found (e.g., the message list was replaced entirely - /// or a sliding window shifted past it), all groups are cleared and rebuilt from scratch. + /// If the input prefix differs (e.g., the message list was replaced or a sliding window shifted it), + /// all groups are cleared and rebuilt from scratch. /// /// - /// If the last message in matches the last - /// processed message, no work is performed. + /// If the prefix is unchanged and no messages have been appended, no new groups are created. /// /// internal void Update(IList allMessages) { if (allMessages.Count == 0) { - this.Groups.Clear(); - this._currentTurn = 0; - this._lastProcessedMessage = null; - return; - } - - // If the last message is unchanged and the list hasn't shrunk, there is nothing new to process. - if (this._lastProcessedMessage is not null && - allMessages.Count >= this.RawMessageCount && - allMessages[allMessages.Count - 1].ContentEquals(this._lastProcessedMessage)) - { + this.RebuildFromMessages(allMessages); return; } - // Walk backwards to locate where we left off. - int foundIndex = -1; - if (this._lastProcessedMessage is not null) + // A content fingerprint validates the original input even after strategies replace its groups + // or the caller mutates the same message objects. Older state is rebuilt once to capture it. + if (this.InputPrefixFingerprint is { } fingerprint && + this.ProcessedInputMessageCount is { } processedMessageCount && + allMessages.Count >= processedMessageCount && + string.Equals(fingerprint, CompactionInputFingerprint.Compute(allMessages, processedMessageCount), StringComparison.Ordinal)) { - for (int i = allMessages.Count - 1; i >= 0; --i) + if (allMessages.Count > processedMessageCount) { - if (allMessages[i].ContentEquals(this._lastProcessedMessage)) - { - foundIndex = i; - break; - } + this.AppendFromMessages(allMessages, processedMessageCount); } - } - if (foundIndex < 0) - { - // Last processed message not found — total rebuild. - this.Groups.Clear(); - this._currentTurn = 0; - this.AppendFromMessages(allMessages, 0); return; } - // Guard against a sliding window that removed messages from the front: - // the number of messages up to (and including) the found position must - // match the number of messages already represented by existing groups. - if (foundIndex + 1 < this.RawMessageCount) + this.RebuildFromMessages(allMessages); + } + + private void RebuildFromMessages(IList messages) + { + this.Groups.Clear(); + this._currentTurn = 0; + this._inputSummaryGroups?.Clear(); + this.AppendFromMessages(messages, 0); + } + + internal void ReplaceWithReducedMessages(IList reducedMessages) + { + CompactionMessageIndex rebuilt = Create(reducedMessages, this.Tokenizer); + this.Groups.Clear(); + foreach (CompactionMessageGroup group in rebuilt.Groups) { - // Front of the message list was trimmed — rebuild. - this.Groups.Clear(); - this._currentTurn = 0; - this.AppendFromMessages(allMessages, 0); - return; + this.Groups.Add(group); } - // Process only the delta messages. - this.AppendFromMessages(allMessages, foundIndex + 1); + this._currentTurn = rebuilt._currentTurn; + this._inputSummaryGroups?.Clear(); } private void AppendFromMessages(IList messages, int startIndex) @@ -212,7 +237,9 @@ private void AppendFromMessages(IList messages, int startIndex) } else if (message.Role == ChatRole.Assistant && IsSummaryMessage(message)) { - this.Groups.Add(CreateGroup(CompactionGroupKind.Summary, [message], this.Tokenizer, this._currentTurn)); + CompactionMessageGroup group = CreateGroup(CompactionGroupKind.Summary, [message], this.Tokenizer, this._currentTurn); + this.Groups.Add(group); + (this._inputSummaryGroups ??= []).Add(group); index++; } else if (message.Role == ChatRole.Assistant && HasOnlyReasoning(message)) @@ -263,10 +290,8 @@ private void AppendFromMessages(IList messages, int startIndex) } } - if (messages.Count > 0) - { - this._lastProcessedMessage = messages[^1]; - } + this.InputPrefixFingerprint = CompactionInputFingerprint.Compute(messages, messages.Count); + this.ProcessedInputMessageCount = messages.Count; } /// @@ -524,7 +549,7 @@ private static bool HasToolCalls(ChatMessage message) private static bool HasOnlyReasoning(ChatMessage message) => message.Contents.All(content => content is TextReasoningContent); - private static bool IsSummaryMessage(ChatMessage message) => + internal static bool IsSummaryMessage(ChatMessage message) => message.AdditionalProperties?.TryGetValue(CompactionMessageGroup.SummaryPropertyKey, out object? value) is true && value switch { diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs index 2d919f709cf..4207f97a392 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs @@ -143,9 +143,9 @@ protected override async ValueTask InvokingCoreAsync(InvokingContext State state = this._sessionState.GetOrInitializeState(session); CompactionMessageIndex messageIndex; - if (state.MessageGroups.Count > 0) + if (state.MessageGroups.Count > 0 || state.InputPrefixFingerprint is not null) { - messageIndex = new([.. state.MessageGroups]); + messageIndex = CompactionMessageIndex.Restore([.. state.MessageGroups], state.ProcessedInputMessageCount, state.InputSummaryGroupIndices, state.InputPrefixFingerprint); // Treat all messages already in the index as chat history. foreach (var message in messageIndex.Groups.SelectMany(x => x.Messages)) @@ -183,6 +183,9 @@ await this._compactionStrategy.CompactAsync( // Persist the index state.MessageGroups.Clear(); state.MessageGroups.AddRange(messageIndex.Groups); + state.ProcessedInputMessageCount = messageIndex.ProcessedInputMessageCount; + state.InputSummaryGroupIndices = messageIndex.InputSummaryGroupIndices; + state.InputPrefixFingerprint = messageIndex.InputPrefixFingerprint; // Treat any messages that were generated by the compaction strategies as chat history. // This is to avoid adding them to chat history at the end of the run, which we don't want @@ -221,5 +224,25 @@ internal sealed class State /// [JsonPropertyName("messagegroups")] public List MessageGroups { get; set; } = []; + + /// + /// Gets or sets the number of input messages already represented by the index, including input summaries. + /// + [JsonPropertyName("processedinputmessagecount")] + public int? ProcessedInputMessageCount { get; set; } + + /// + /// Gets or sets the positions of summary groups that came from input messages. + /// A null value denotes older state without summary provenance. + /// + [JsonPropertyName("inputsummarygroupindices")] + public List? InputSummaryGroupIndices { get; set; } + + /// + /// Gets or sets a fingerprint of the original input content, independent of mutable message objects. + /// A null value denotes older state or input that could not be fingerprinted. + /// + [JsonPropertyName("inputprefixfingerprint")] + public string? InputPrefixFingerprint { get; set; } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs index 4613479d2a5..7571cd412dc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs @@ -571,6 +571,696 @@ public void UpdateAppendsNewMessagesIncrementally() Assert.Equal(CompactionGroupKind.AssistantText, index.Groups[3].Kind); } + [Fact] + public void UpdateAppendsNewMessagesWhenTrailingMessageContentIsDuplicate() + { + // Arrange — TodoProvider adds the same message at the end of every turn. + const string TodoList = "### Current todo list\n- none yet"; + List messages = + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.Assistant, "Hi"), + new ChatMessage(ChatRole.User, TodoList), + ]; + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); + index.Groups[1].IsExcluded = true; + + // Act + messages.Add(new ChatMessage(ChatRole.User, "What is the weather today?")); + messages.Add(new ChatMessage(ChatRole.Assistant, "Let me check.")); + messages.Add(new ChatMessage(ChatRole.User, TodoList)); + index.Update(messages); + + // Assert — the second turn is indexed without losing compaction state. + Assert.Equal(6, index.RawMessageCount); + Assert.Equal(6, index.Groups.Count); + Assert.Same(messages[3], index.Groups[3].Messages[0]); + Assert.True(index.Groups[1].IsExcluded); + + index.Update(messages); + Assert.Equal(6, index.RawMessageCount); + } + + [Fact] + public void UpdateAppendsAfterGeneratedSummaryAndDuplicateTrailingMessage() + { + // Arrange — a compaction strategy may insert a summary absent from chat history. + const string TodoList = "### Current todo list\n- none yet"; + List messages = + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.User, TodoList), + ]; + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); + index.InsertGroup(1, CompactionGroupKind.Summary, [new ChatMessage(ChatRole.Assistant, "Summary")]); + index.Groups[0].IsExcluded = true; + + // Act + messages.Add(new ChatMessage(ChatRole.User, "What is the weather today?")); + messages.Add(new ChatMessage(ChatRole.User, TodoList)); + index.Update(messages); + + // Assert + Assert.Equal(4, index.RawMessageCount); + Assert.Equal(5, index.Groups.Count); + Assert.Same(messages[2], index.Groups[3].Messages[0]); + Assert.Same(messages[3], index.Groups[4].Messages[0]); + Assert.True(index.Groups[0].IsExcluded); + } + + [Fact] + public void UpdateAppendsAfterInputSummaryAndDuplicateTrailingMessage() + { + // Arrange — an input summary counts as consumed history even though RawMessageCount excludes it. + const string TodoList = "### Current todo list\n- none yet"; + ChatMessage summary = new(ChatRole.Assistant, "Earlier conversation"); + (summary.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true; + List messages = [summary, new ChatMessage(ChatRole.User, TodoList)]; + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); + Assert.Equal(1, index.RawMessageCount); + + // Act + messages.Add(new ChatMessage(ChatRole.User, "What is the weather today?")); + messages.Add(new ChatMessage(ChatRole.User, TodoList)); + index.Update(messages); + + // Assert + Assert.Equal(4, index.Groups.Count); + Assert.Same(messages[2], index.Groups[2].Messages[0]); + Assert.Equal(3, index.RawMessageCount); + } + + [Fact] + public void RestorePreservesExclusionsWhenLastInputIsSummary() + { + // Arrange — a persisted index cannot infer whether its trailing summary came from input. + ChatMessage summary = new(ChatRole.Assistant, "Earlier conversation"); + (summary.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true; + List messages = [new ChatMessage(ChatRole.User, "Hello"), summary]; + CompactionMessageIndex original = CompactionMessageIndex.Create(messages); + original.Groups[0].IsExcluded = true; + CompactionMessageIndex restored = CompactionMessageIndex.Restore([.. original.Groups], original.ProcessedInputMessageCount, original.InputSummaryGroupIndices, original.InputPrefixFingerprint); + + // Act + messages.Add(new ChatMessage(ChatRole.User, "What is the weather today?")); + restored.Update(messages); + + // Assert + Assert.Equal(3, restored.Groups.Count); + Assert.Same(messages[2], restored.Groups[2].Messages[0]); + Assert.True(restored.Groups[0].IsExcluded); + } + + [Fact] + public void UpdateRebuildsRestoredIndexWhenPrefixChangesButBoundaryMatches() + { + // Arrange — the repeated todo still occupies the saved boundary after earlier history changes. + const string TodoList = "### Current todo list\n- none yet"; + List originalMessages = + [ + new ChatMessage(ChatRole.User, "Old question"), + new ChatMessage(ChatRole.User, TodoList), + ]; + CompactionMessageIndex original = CompactionMessageIndex.Create(originalMessages); + original.Groups[0].IsExcluded = true; + CompactionMessageIndex restored = CompactionMessageIndex.Restore([.. original.Groups], original.ProcessedInputMessageCount, original.InputSummaryGroupIndices, original.InputPrefixFingerprint); + List replacement = + [ + new ChatMessage(ChatRole.User, "New question"), + new ChatMessage(ChatRole.User, TodoList), + new ChatMessage(ChatRole.Assistant, "New answer"), + ]; + + // Act + restored.Update(replacement); + + // Assert — stale groups and their exclusion state are removed before the suffix is indexed. + Assert.Equal(replacement.Count, restored.Groups.Count); + Assert.Equal(replacement.Count, restored.RawMessageCount); + Assert.Same(replacement[0], restored.Groups[0].Messages[0]); + Assert.Same(replacement[2], restored.Groups[2].Messages[0]); + Assert.All(restored.Groups, group => Assert.False(group.IsExcluded)); + } + + [Fact] + public void UpdateRebuildsWhenHistoryShiftsPastRepeatedBoundary() + { + // Arrange — a sliding window removes the first message, but another identical todo lands at the old boundary. + const string TodoList = "### Current todo list\n- none yet"; + List originalMessages = + [ + new ChatMessage(ChatRole.User, "Old question"), + new ChatMessage(ChatRole.User, TodoList), + ]; + CompactionMessageIndex original = CompactionMessageIndex.Create(originalMessages); + original.Groups[0].IsExcluded = true; + CompactionMessageIndex restored = new([.. original.Groups]); + List shifted = + [ + originalMessages[1], + new ChatMessage(ChatRole.User, TodoList), + new ChatMessage(ChatRole.User, "Follow-up"), + ]; + + // Act + restored.Update(shifted); + + // Assert + Assert.Equal(shifted.Count, restored.Groups.Count); + Assert.Same(shifted[0], restored.Groups[0].Messages[0]); + Assert.Same(shifted[2], restored.Groups[2].Messages[0]); + Assert.All(restored.Groups, group => Assert.False(group.IsExcluded)); + } + + [Fact] + public void UpdateRebuildsWhenMessageContentChangesWithSameMessageId() + { + // Arrange + ChatMessage originalMessage = new(ChatRole.User, "Old") { MessageId = "message-1" }; + CompactionMessageIndex index = CompactionMessageIndex.Create([originalMessage]); + index.Groups[0].IsExcluded = true; + ChatMessage replacement = new(ChatRole.User, "Edited") { MessageId = "message-1" }; + List input = [replacement, new(ChatRole.User, "Follow-up")]; + + // Act + index.Update(input); + + // Assert + Assert.Equal(2, index.Groups.Count); + Assert.Same(replacement, index.Groups[0].Messages[0]); + Assert.False(index.Groups[0].IsExcluded); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void UpdateRebuildsWhenMessageIdChanges(bool appendMessage) + { + // Arrange + ChatMessage original = new(ChatRole.User, "Same content") { MessageId = "old-id" }; + CompactionMessageIndex index = CompactionMessageIndex.Create([original]); + index.Groups[0].IsExcluded = true; + ChatMessage replacement = new(ChatRole.User, "Same content") { MessageId = "new-id" }; + List input = [replacement]; + if (appendMessage) + { + input.Add(new(ChatRole.User, "Follow-up")); + } + + // Act + index.Update(input); + + // Assert + Assert.Equal(input.Count, index.Groups.Count); + Assert.Same(replacement, index.Groups[0].Messages[0]); + Assert.Equal("new-id", index.Groups[0].Messages[0].MessageId); + Assert.False(index.Groups[0].IsExcluded); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void UpdateRebuildsWhenReducedInputMessageIdIsMutated(bool appendMessage) + { + // Arrange — the reducer discards the message whose identity is subsequently changed in place. + ChatMessage original = new(ChatRole.User, "Old") { MessageId = "old-id" }; + List input = [original, new(ChatRole.User, "Keep")]; + CompactionMessageIndex index = CompactionMessageIndex.Create(input); + index.ReplaceWithReducedMessages([input[1]]); + original.MessageId = "new-id"; + if (appendMessage) + { + input.Add(new(ChatRole.User, "Follow-up")); + } + + // Act + index.Update(input); + + // Assert + Assert.Equal(input.Count, index.Groups.Count); + Assert.Same(original, index.Groups[0].Messages[0]); + Assert.Equal("new-id", index.Groups[0].Messages[0].MessageId); + } + + [Fact] + public void UpdatePreservesExclusionWhenMessageIdAndContentAreUnchanged() + { + // Arrange + ChatMessage original = new(ChatRole.User, "Same content") { MessageId = "message-1" }; + CompactionMessageIndex index = CompactionMessageIndex.Create([original]); + index.Groups[0].IsExcluded = true; + CompactionMessageGroup originalGroup = index.Groups[0]; + ChatMessage replacement = new(ChatRole.User, "Same content") { MessageId = "message-1" }; + + // Act + index.Update([replacement, new(ChatRole.User, "Follow-up")]); + + // Assert + Assert.Equal(2, index.Groups.Count); + Assert.Same(originalGroup, index.Groups[0]); + Assert.True(index.Groups[0].IsExcluded); + Assert.Same(original, index.Groups[0].Messages[0]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void UpdateRebuildsWhenReducedInputMessageIsMutated(bool appendMessage) + { + // Arrange — the caller retains and edits the same object that the reducer discarded. + ChatMessage original = new(ChatRole.User, "Old"); + List messages = [original, new(ChatRole.User, "Keep")]; + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); + index.ReplaceWithReducedMessages([messages[1]]); + ((TextContent)original.Contents[0]).Text = "Edited"; + if (appendMessage) + { + messages.Add(new(ChatRole.User, "New")); + } + + // Act + index.Update(messages); + + // Assert — changed input invalidates the old reduction, even without an appended message. + Assert.Equal(messages.Count, index.Groups.Count); + Assert.Same(original, index.Groups[0].Messages[0]); + Assert.Equal("Edited", index.Groups[0].Messages[0].Text); + } + + [Fact] + public void UpdateRebuildsWhenExcludedToolPayloadIsMutated() + { + // Arrange — a nested mutable tool argument shares its object with the original index. + Dictionary query = new() { ["city"] = "Seattle" }; + ChatMessage toolCall = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "lookup", new Dictionary { ["query"] = query })]); + List messages = [toolCall, new(ChatRole.Tool, [new FunctionResultContent("call-1", "Result")])]; + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); + index.Groups[0].IsExcluded = true; + CompactionMessageGroup previousGroup = index.Groups[0]; + query["city"] = "London"; + + // Act + index.Update(messages); + + // Assert + Assert.NotSame(previousGroup, index.Groups[0]); + Assert.False(index.Groups[0].IsExcluded); + } + + [Fact] + public void UpdateRebuildsForContentWithoutSerializationContract() + { + // Arrange — an unknown content type cannot provide a reliable saved fingerprint. + List messages = [new(ChatRole.User, [new UnserializableContent()])]; + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); + index.Groups[0].IsExcluded = true; + CompactionMessageGroup previousGroup = index.Groups[0]; + + // Act + index.Update(messages); + + // Assert — unsupported content keeps working, but its old compaction state is not reused. + Assert.NotSame(previousGroup, index.Groups[0]); + Assert.False(index.Groups[0].IsExcluded); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void UpdateRebuildsForUndefinedJsonToolResult(bool nestedPayload) + { + // Arrange — an undefined JSON value has no valid serialization, including inside a nested payload. + object payload = nestedPayload ? new Dictionary { ["value"] = default(JsonElement) } : default(JsonElement); + List input = [new(ChatRole.Tool, [new FunctionResultContent("call-1", payload)])]; + CompactionMessageIndex index = CompactionMessageIndex.Create(input); + index.Groups[0].IsExcluded = true; + CompactionMessageGroup originalGroup = index.Groups[0]; + input.Add(new(ChatRole.User, "Follow-up")); + + // Act + index.Update(input); + + // Assert — creation and updating remain usable, without reusing unsupported input's compaction state. + Assert.Null(index.InputPrefixFingerprint); + Assert.Equal(2, index.Groups.Count); + Assert.NotSame(originalGroup, index.Groups[0]); + Assert.False(index.Groups[0].IsExcluded); + Assert.Same(input[0], index.Groups[0].Messages[0]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void UpdateRebuildsWhenToolResultBecomesUndefinedJson(bool nestedPayload) + { + // Arrange — the saved prefix is fingerprintable until its tool result is changed in place. + FunctionResultContent result = new("call-1", "Valid"); + List input = [new(ChatRole.Tool, [result])]; + CompactionMessageIndex index = CompactionMessageIndex.Create(input); + index.Groups[0].IsExcluded = true; + CompactionMessageGroup originalGroup = index.Groups[0]; + Assert.NotNull(index.InputPrefixFingerprint); + result.Result = nestedPayload ? new Dictionary { ["value"] = default(JsonElement) } : default(JsonElement); + + // Act + index.Update(input); + + // Assert — failure to fingerprint the changed prefix triggers the same safe rebuilding path. + Assert.Null(index.InputPrefixFingerprint); + Assert.NotSame(originalGroup, index.Groups[0]); + Assert.False(index.Groups[0].IsExcluded); + Assert.Same(input[0], index.Groups[0].Messages[0]); + } + + [Theory] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + [InlineData(float.NegativeInfinity)] + public void UpdateRebuildsForNonFiniteToolResult(object payload) + { + // Arrange — non-finite numbers are valid CLR values but cannot be written with the default JSON contract. + List input = [new(ChatRole.Tool, [new FunctionResultContent("call-1", payload)])]; + CompactionMessageIndex index = CompactionMessageIndex.Create(input); + index.Groups[0].IsExcluded = true; + CompactionMessageGroup originalGroup = index.Groups[0]; + input.Add(new(ChatRole.User, "Follow-up")); + + // Act + index.Update(input); + + // Assert — inability to fingerprint the payload must not prevent indexing or reuse stale exclusions. + Assert.Null(index.InputPrefixFingerprint); + Assert.Equal(2, index.Groups.Count); + Assert.NotSame(originalGroup, index.Groups[0]); + Assert.False(index.Groups[0].IsExcluded); + Assert.Same(input[0], index.Groups[0].Messages[0]); + } + + [Theory] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + [InlineData(float.NegativeInfinity)] + public void UpdateRebuildsWhenToolArgumentBecomesNonFinite(object payload) + { + // Arrange — mutate a previously fingerprintable argument inside the tool-call dictionary. + Dictionary arguments = new() { ["value"] = 1.25 }; + List input = + [ + new(ChatRole.Assistant, [new FunctionCallContent("call-1", "calculate", arguments)]), + new(ChatRole.Tool, [new FunctionResultContent("call-1", "Result")]), + ]; + CompactionMessageIndex index = CompactionMessageIndex.Create(input); + index.Groups[0].IsExcluded = true; + CompactionMessageGroup originalGroup = index.Groups[0]; + Assert.NotNull(index.InputPrefixFingerprint); + arguments["value"] = payload; + + // Act + index.Update(input); + + // Assert + Assert.Null(index.InputPrefixFingerprint); + Assert.NotSame(originalGroup, index.Groups[0]); + Assert.False(index.Groups[0].IsExcluded); + Assert.Equal(input, index.Groups[0].Messages); + } + + [Fact] + public void UpdatePreservesExclusionForEquivalentJsonToolPayloads() + { + // Arrange + ChatMessage toolCall = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "lookup", new Dictionary + { + ["query"] = ParseJson("{\"items\":[1,2],\"label\":\"text\"}"), + })]); + ChatMessage toolResult = new(ChatRole.Tool, [new FunctionResultContent("call-1", ParseJson("{\"found\":true}"))]); + CompactionMessageIndex index = CompactionMessageIndex.Create([toolCall, toolResult]); + index.Groups[0].IsExcluded = true; + List input = + [ + new(ChatRole.Assistant, [new FunctionCallContent("call-1", "lookup", new Dictionary + { + ["query"] = ParseJson("{\"label\":\"te\\u0078t\",\"items\":[1.0,2e0]}"), + })]), + new(ChatRole.Tool, [new FunctionResultContent("call-1", ParseJson("{\"found\":true}"))]), + new(ChatRole.User, "Follow-up"), + ]; + + // Act + index.Update(input); + + // Assert + Assert.Equal(2, index.Groups.Count); + Assert.True(index.Groups[0].IsExcluded); + Assert.Same(toolCall, index.Groups[0].Messages[0]); + Assert.Same(input[2], index.Groups[1].Messages[0]); + } + + [Fact] + public void UpdatePreservesExclusionWhenToolPayloadBecomesJsonAfterSerialization() + { + // Arrange + ChatMessage toolCall = new(ChatRole.Assistant, [new FunctionCallContent("call-1", "lookup", new Dictionary { ["query"] = "Seattle" })]); + ChatMessage toolResult = new(ChatRole.Tool, [new FunctionResultContent("call-1", 123)]); + CompactionMessageIndex index = CompactionMessageIndex.Create([toolCall, toolResult]); + index.Groups[0].IsExcluded = true; + List input = + [ + new(ChatRole.Assistant, [new FunctionCallContent("call-1", "lookup", new Dictionary { ["query"] = ParseJson("\"Seattle\"") })]), + new(ChatRole.Tool, [new FunctionResultContent("call-1", ParseJson("123"))]), + new(ChatRole.User, "Follow-up"), + ]; + + // Act + index.Update(input); + + // Assert + Assert.Equal(2, index.Groups.Count); + Assert.True(index.Groups[0].IsExcluded); + Assert.Same(toolCall, index.Groups[0].Messages[0]); + Assert.Same(input[2], index.Groups[1].Messages[0]); + } + + [Fact] + public void UpdatePreservesExclusionForClrAndJsonNullPayloads() + { + // Arrange — serializer omission of a CLR null must not change the meaning of the payload. + ChatMessage original = new(ChatRole.Tool, [new FunctionResultContent("call-1", null)]); + CompactionMessageIndex index = CompactionMessageIndex.Create([original]); + index.Groups[0].IsExcluded = true; + + // Act + index.Update([new(ChatRole.Tool, [new FunctionResultContent("call-1", ParseJson("null"))]), new(ChatRole.User, "Follow-up")]); + + // Assert + Assert.True(index.Groups[0].IsExcluded); + Assert.Same(original, index.Groups[0].Messages[0]); + } + + [Fact] + public void UpdateRebuildsWhenNestedJsonNullIsRemoved() + { + // Arrange — an explicit null inside the payload remains different from a missing property. + ChatMessage original = new(ChatRole.Tool, [new FunctionResultContent("call-1", ParseJson("{\"result\":null}"))]); + CompactionMessageIndex index = CompactionMessageIndex.Create([original]); + index.Groups[0].IsExcluded = true; + ChatMessage replacement = new(ChatRole.Tool, [new FunctionResultContent("call-1", ParseJson("{}"))]); + + // Act + index.Update([replacement]); + + // Assert + Assert.Same(replacement, index.Groups[0].Messages[0]); + Assert.False(index.Groups[0].IsExcluded); + } + + [Fact] + public void UpdateRebuildsWhenJsonNumberDiffersBeyondDecimalPrecision() + { + // Arrange — rounding both values through decimal would hide this change. + ChatMessage original = new(ChatRole.Tool, [new FunctionResultContent("call-1", ParseJson("0.1234567890123456789012345678901"))]); + CompactionMessageIndex index = CompactionMessageIndex.Create([original]); + index.Groups[0].IsExcluded = true; + ChatMessage replacement = new(ChatRole.Tool, [new FunctionResultContent("call-1", ParseJson("0.1234567890123456789012345678902"))]); + + // Act + index.Update([replacement]); + + // Assert + Assert.Same(replacement, index.Groups[0].Messages[0]); + Assert.False(index.Groups[0].IsExcluded); + } + + [Fact] + public void RestoreSkipsGeneratedSummaryButMatchesInputSummary() + { + // Arrange — a strategy inserts a generated summary ahead of an input summary. + ChatMessage inputSummary = new(ChatRole.Assistant, "Input summary"); + (inputSummary.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true; + List messages = [inputSummary, new ChatMessage(ChatRole.User, "Question")]; + CompactionMessageIndex original = CompactionMessageIndex.Create(messages); + original.InsertGroup(0, CompactionGroupKind.Summary, [new ChatMessage(ChatRole.Assistant, "Generated summary")]); + original.Groups[2].IsExcluded = true; + Assert.Equal([1], original.InputSummaryGroupIndices); + CompactionMessageIndex restored = CompactionMessageIndex.Restore([.. original.Groups], original.ProcessedInputMessageCount, original.InputSummaryGroupIndices, original.InputPrefixFingerprint); + + // Act + messages.Add(new ChatMessage(ChatRole.User, "Follow-up")); + restored.Update(messages); + + // Assert — matching input keeps both summaries and the existing exclusion. + Assert.Equal(4, restored.Groups.Count); + Assert.Equal("Generated summary", restored.Groups[0].Messages[0].Text); + Assert.Equal("Input summary", restored.Groups[1].Messages[0].Text); + Assert.True(restored.Groups[2].IsExcluded); + Assert.Same(messages[2], restored.Groups[3].Messages[0]); + Assert.Equal([1], restored.InputSummaryGroupIndices); + } + + [Fact] + public void ReplaceWithReducedMessagesRetainsInputSummaryInSavedPrefix() + { + // Arrange — a generated summary must not be mistaken for an input summary when the reducer replaces groups. + ChatMessage inputSummary = new(ChatRole.Assistant, "Input summary"); + (inputSummary.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true; + List messages = [inputSummary, new(ChatRole.User, "Old"), new(ChatRole.User, "Keep")]; + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); + ChatMessage generatedSummary = new(ChatRole.Assistant, "Generated summary"); + (generatedSummary.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true; + index.InsertGroup(1, CompactionGroupKind.Summary, [generatedSummary]); + + // Act + index.ReplaceWithReducedMessages([messages[2]]); + CompactionMessageIndex restored = CompactionMessageIndex.Restore([.. index.Groups], index.ProcessedInputMessageCount, index.InputSummaryGroupIndices, index.InputPrefixFingerprint); + ChatMessage restoredSummary = new(ChatRole.Assistant, "Input summary"); + (restoredSummary.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true; + List appended = [restoredSummary, new(ChatRole.User, "Old"), new(ChatRole.User, "Keep"), new(ChatRole.User, "New")]; + restored.Update(appended); + + // Assert — the old reduced group survives and only the new input is appended. + Assert.Equal(2, restored.Groups.Count); + Assert.Equal("Keep", restored.Groups[0].Messages[0].Text); + Assert.Same(appended[3], restored.Groups[1].Messages[0]); + Assert.NotNull(restored.InputPrefixFingerprint); + Assert.Equal(4, restored.ProcessedInputMessageCount); + Assert.Empty(restored.InputSummaryGroupIndices); + } + + [Fact] + public void RestoreRebuildsOlderReducedStateWithoutInputFingerprint() + { + // Arrange — state written before the input snapshot cannot validate the removed messages. + List messages = [new(ChatRole.User, "A"), new(ChatRole.Assistant, "B"), new(ChatRole.User, "C")]; + CompactionMessageIndex original = CompactionMessageIndex.Create(messages); + original.ReplaceWithReducedMessages([messages[2]]); + CompactionMessageIndex restored = CompactionMessageIndex.Restore([.. original.Groups], original.ProcessedInputMessageCount, original.InputSummaryGroupIndices); + List appended = [.. messages, new(ChatRole.Assistant, "D")]; + + // Act + restored.Update(appended); + + // Assert — the old reduction is discarded once, without dropping or duplicating input. + Assert.Equal(4, restored.Groups.Count); + Assert.Equal(4, restored.ProcessedInputMessageCount); + Assert.Same(appended[0], restored.Groups[0].Messages[0]); + Assert.Same(appended[3], restored.Groups[3].Messages[0]); + } + + [Fact] + public void ReplaceWithReducedMessagesContinuesTurnIndicesFromReducedGroups() + { + // Arrange + List messages = [new(ChatRole.User, "A"), new(ChatRole.User, "B"), new(ChatRole.User, "C")]; + CompactionMessageIndex index = CompactionMessageIndex.Create(messages); + + // Act + index.ReplaceWithReducedMessages([messages[2]]); + index.Update([.. messages, new(ChatRole.User, "D")]); + + // Assert + Assert.Equal(2, index.Groups.Count); + Assert.Equal(1, index.Groups[0].TurnIndex); + Assert.Equal(2, index.Groups[1].TurnIndex); + } + + [Fact] + public void RestoreRebuildsWhenGeneratedSummaryMatchesChangedInputSummary() + { + // Arrange — the new input summary matches the generated group, not the old input group. + ChatMessage inputSummary = new(ChatRole.Assistant, "S1"); + (inputSummary.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true; + CompactionMessageIndex original = CompactionMessageIndex.Create([inputSummary, new ChatMessage(ChatRole.User, "U")]); + ChatMessage generatedSummary = new(ChatRole.Assistant, "X"); + (generatedSummary.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true; + original.InsertGroup(0, CompactionGroupKind.Summary, [generatedSummary]); + original.Groups[1].IsExcluded = true; + CompactionMessageIndex restored = CompactionMessageIndex.Restore([.. original.Groups], original.ProcessedInputMessageCount, original.InputSummaryGroupIndices, original.InputPrefixFingerprint); + ChatMessage replacementSummary = new(ChatRole.Assistant, "X"); + (replacementSummary.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true; + List replacement = [replacementSummary, new ChatMessage(ChatRole.User, "U")]; + + // Act + restored.Update(replacement); + + // Assert — the stale S1 group and its exclusion are discarded. + Assert.Equal(2, restored.Groups.Count); + Assert.Same(replacement[0], restored.Groups[0].Messages[0]); + Assert.Same(replacement[1], restored.Groups[1].Messages[0]); + Assert.All(restored.Groups, group => Assert.False(group.IsExcluded)); + Assert.Equal([0], restored.InputSummaryGroupIndices); + } + + [Fact] + public void RestoreRebuildsSummaryStateWithoutProvenance() + { + // Arrange — persisted state from before summary provenance was recorded. + ChatMessage inputSummary = new(ChatRole.Assistant, "Input summary"); + (inputSummary.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true; + List messages = [inputSummary, new ChatMessage(ChatRole.User, "Question")]; + CompactionMessageIndex original = CompactionMessageIndex.Create(messages); + original.InsertGroup(0, CompactionGroupKind.Summary, [new ChatMessage(ChatRole.Assistant, "Generated summary")]); + original.Groups[2].IsExcluded = true; + CompactionMessageIndex restored = CompactionMessageIndex.Restore([.. original.Groups], original.ProcessedInputMessageCount, inputSummaryGroupIndices: null); + + // Act + messages.Add(new ChatMessage(ChatRole.User, "Follow-up")); + restored.Update(messages); + + // Assert — ambiguous legacy state is rebuilt rather than retaining an incorrect group. + Assert.Equal(3, restored.Groups.Count); + Assert.Same(messages[0], restored.Groups[0].Messages[0]); + Assert.Same(messages[2], restored.Groups[2].Messages[0]); + Assert.All(restored.Groups, group => Assert.False(group.IsExcluded)); + Assert.Equal([0], restored.InputSummaryGroupIndices); + } + + [Fact] + public void UpdateRebuildsLegacySummaryStateWithoutInputCount() + { + // Arrange — older persisted states did not record how many summaries came from input. + const string TodoList = "### Current todo list\n- none yet"; + ChatMessage summary = new(ChatRole.Assistant, "Earlier conversation"); + (summary.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true; + List messages = [summary, new ChatMessage(ChatRole.User, TodoList)]; + CompactionMessageIndex original = CompactionMessageIndex.Create(messages); + CompactionMessageIndex restored = new([.. original.Groups]); + + // Act + messages.Add(new ChatMessage(ChatRole.User, "What is the weather today?")); + messages.Add(new ChatMessage(ChatRole.User, TodoList)); + restored.Update(messages); + + // Assert + Assert.Equal(4, restored.Groups.Count); + Assert.Same(messages[2], restored.Groups[2].Messages[0]); + } + [Fact] public void UpdateNoOpWhenNoNewMessages() { @@ -1505,4 +2195,12 @@ public void SummaryPropertyKeyIsRespected(object? summaryPropertyValue, Compacti Assert.Single(index.Groups); Assert.Equal(expectedCompactionGroupKind, index.Groups[0].Kind); } + + private static JsonElement ParseJson(string json) + { + using JsonDocument document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } + + private sealed class UnserializableContent : AIContent; } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs index e4d4fa23780..4e0d795a526 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; @@ -236,6 +237,405 @@ public async Task InvokingAsyncWithExistingIndexUpdatesAsync() Assert.NotNull(result.Messages); } + [Fact] + public async Task InvokingAsyncIncludesNewUserMessageBeforeRepeatedTodoListAsync() + { + // Arrange — the todo list provider emits the same empty list after each user turn. + const string TodoList = "### Current todo list\n- none yet"; + CompactionProvider provider = new(new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(100000))); + Mock mockAgent = new() { CallBase = true }; + TestAgentSession session = new(); + List messages = + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.User, TodoList), + ]; + + await provider.InvokingAsync(new(mockAgent.Object, session, new AIContext { Messages = messages })); + + // Act + messages.Add(new ChatMessage(ChatRole.User, "What is the weather today?")); + messages.Add(new ChatMessage(ChatRole.User, TodoList)); + AIContext result = await provider.InvokingAsync(new(mockAgent.Object, session, new AIContext { Messages = messages })); + + // Assert + Assert.NotNull(result.Messages); + List resultMessages = [.. result.Messages]; + Assert.Equal(messages.Count, resultMessages.Count); + Assert.Contains(resultMessages, message => message.Text == "What is the weather today?"); + } + + [Fact] + public async Task InvokingAsyncIncludesNewUserMessageAfterInputSummaryAsync() + { + // Arrange — a summary already present in the input must count toward the saved append boundary. + const string TodoList = "### Current todo list\n- none yet"; + CompactionProvider provider = new(new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(100000))); + Mock mockAgent = new() { CallBase = true }; + TestAgentSession session = new(); + ChatMessage summary = new(ChatRole.Assistant, "Earlier conversation"); + (summary.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true; + List messages = [summary, new ChatMessage(ChatRole.User, TodoList)]; + await provider.InvokingAsync(new(mockAgent.Object, session, new AIContext { Messages = messages })); + var serializedState = session.StateBag.Serialize(); + Assert.Equal(2, serializedState.GetProperty(provider.StateKeys[0]).GetProperty("processedinputmessagecount").GetInt32()); + Assert.Equal(0, serializedState.GetProperty(provider.StateKeys[0]).GetProperty("inputsummarygroupindices")[0].GetInt32()); + TestAgentSession restoredSession = new(AgentSessionStateBag.Deserialize(serializedState)); + + // Act + messages.Add(new ChatMessage(ChatRole.User, "What is the weather today?")); + messages.Add(new ChatMessage(ChatRole.User, TodoList)); + AIContext result = await provider.InvokingAsync(new(mockAgent.Object, restoredSession, new AIContext { Messages = messages })); + + // Assert + Assert.NotNull(result.Messages); + List resultMessages = [.. result.Messages]; + Assert.Equal(messages.Count, resultMessages.Count); + Assert.Contains(resultMessages, message => message.Text == "What is the weather today?"); + } + + [Fact] + public async Task InvokingAsyncRebuildsReplacedHistoryWithRepeatedBoundaryAsync() + { + // Arrange — restore session state with a saved todo boundary and replace the earlier input history. + const string TodoList = "### Current todo list\n- none yet"; + CompactionProvider provider = new(new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(100000))); + Mock mockAgent = new() { CallBase = true }; + TestAgentSession session = new(); + List originalMessages = + [ + new ChatMessage(ChatRole.User, "Old question"), + new ChatMessage(ChatRole.User, TodoList), + ]; + await provider.InvokingAsync(new(mockAgent.Object, session, new AIContext { Messages = originalMessages })); + TestAgentSession restoredSession = new(AgentSessionStateBag.Deserialize(session.StateBag.Serialize())); + List replacement = + [ + new ChatMessage(ChatRole.User, "New question"), + new ChatMessage(ChatRole.User, TodoList), + new ChatMessage(ChatRole.Assistant, "New answer"), + ]; + + // Act + AIContext result = await provider.InvokingAsync(new(mockAgent.Object, restoredSession, new AIContext { Messages = replacement })); + + // Assert + Assert.NotNull(result.Messages); + List resultMessages = [.. result.Messages]; + Assert.Equal(replacement.Count, resultMessages.Count); + Assert.Equal("New question", resultMessages[0].Text); + Assert.Equal("New answer", resultMessages[2].Text); + } + + [Fact] + public async Task InvokingAsyncUsesSummaryProvenanceAfterSerializationAsync() + { + // Arrange — persist both summary origins, with the generated group preceding the input group. + CompactionProvider provider = new(new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(100000))); + Mock mockAgent = new() { CallBase = true }; + TestAgentSession session = new(); + ChatMessage inputSummary = new(ChatRole.Assistant, "S1"); + (inputSummary.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true; + CompactionMessageIndex index = CompactionMessageIndex.Create([inputSummary, new ChatMessage(ChatRole.User, "U")]); + ChatMessage generatedSummary = new(ChatRole.Assistant, "X"); + (generatedSummary.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true; + index.InsertGroup(0, CompactionGroupKind.Summary, [generatedSummary]); + CompactionProvider.State state = new() + { + MessageGroups = [.. index.Groups], + ProcessedInputMessageCount = index.ProcessedInputMessageCount, + InputSummaryGroupIndices = index.InputSummaryGroupIndices, + InputPrefixFingerprint = index.InputPrefixFingerprint, + }; + session.StateBag.SetValue(provider.StateKeys[0], state, AgentJsonUtilities.DefaultOptions); + var serializedState = session.StateBag.Serialize(); + TestAgentSession unchangedSession = new(AgentSessionStateBag.Deserialize(serializedState)); + List appended = [inputSummary, new ChatMessage(ChatRole.User, "U"), new ChatMessage(ChatRole.User, "Follow-up")]; + ChatMessage replacementSummary = new(ChatRole.Assistant, "X"); + (replacementSummary.AdditionalProperties ??= [])[CompactionMessageGroup.SummaryPropertyKey] = true; + List replacement = [replacementSummary, new ChatMessage(ChatRole.User, "U")]; + + // Act — unchanged input preserves the generated summary after session serialization. + AIContext appendedResult = await provider.InvokingAsync(new(mockAgent.Object, unchangedSession, new AIContext { Messages = appended })); + + // Assert + Assert.NotNull(appendedResult.Messages); + List appendedResultMessages = [.. appendedResult.Messages]; + Assert.Equal(4, appendedResultMessages.Count); + Assert.Equal("X", appendedResultMessages[0].Text); + Assert.Equal("S1", appendedResultMessages[1].Text); + Assert.Equal("Follow-up", appendedResultMessages[3].Text); + + // Act — changed input matches the generated summary but must discard the stale input summary. + TestAgentSession restoredSession = new(AgentSessionStateBag.Deserialize(serializedState)); + AIContext result = await provider.InvokingAsync(new(mockAgent.Object, restoredSession, new AIContext { Messages = replacement })); + + // Assert + Assert.NotNull(result.Messages); + List resultMessages = [.. result.Messages]; + Assert.Equal(2, resultMessages.Count); + Assert.Equal("X", resultMessages[0].Text); + Assert.Equal("U", resultMessages[1].Text); + } + + [Fact] + public async Task InvokingAsyncKeepsReducedHistoryWhenInputIsAppendedAfterSerializationAsync() + { + // Arrange — reduction leaves only C, while the fingerprint still validates the full original input. + RecordingChatReducer reducer = new(); + CompactionProvider provider = new(new ChatReducerCompactionStrategy(reducer, CompactionTriggers.Always)); + Mock mockAgent = new() { CallBase = true }; + TestAgentSession session = new(); + List firstInput = + [ + new(ChatRole.User, "A"), + new(ChatRole.Assistant, "B"), + new(ChatRole.User, "C"), + ]; + AIContext firstResult = await provider.InvokingAsync(new(mockAgent.Object, session, new AIContext { Messages = firstInput })); + var serializedState = session.StateBag.Serialize(); + Assert.Equal(3, serializedState.GetProperty(provider.StateKeys[0]).GetProperty("processedinputmessagecount").GetInt32()); + TestAgentSession restoredSession = new(AgentSessionStateBag.Deserialize(serializedState)); + List appended = + [ + new(ChatRole.User, "A"), + new(ChatRole.Assistant, "B"), + new(ChatRole.User, "C"), + new(ChatRole.Assistant, "D"), + ]; + + // Act + AIContext secondResult = await provider.InvokingAsync(new(mockAgent.Object, restoredSession, new AIContext { Messages = appended })); + + // Assert — the reducer sees the retained C plus D, not the full input again. + Assert.Equal(["C"], firstResult.Messages!.Select(message => message.Text)); + Assert.Equal(["D"], secondResult.Messages!.Select(message => message.Text)); + Assert.Equal(2, reducer.Inputs.Count); + Assert.Equal(["A", "B", "C"], reducer.Inputs[0].Select(message => message.Text)); + Assert.Equal(["C", "D"], reducer.Inputs[1].Select(message => message.Text)); + Assert.Equal(4, restoredSession.StateBag.Serialize().GetProperty(provider.StateKeys[0]).GetProperty("processedinputmessagecount").GetInt32()); + + // Act & Assert — another serialized turn retains the new reduced boundary. + TestAgentSession thirdSession = new(AgentSessionStateBag.Deserialize(restoredSession.StateBag.Serialize())); + List thirdInput = [.. appended, new(ChatRole.User, "E")]; + AIContext thirdResult = await provider.InvokingAsync(new(mockAgent.Object, thirdSession, new AIContext { Messages = thirdInput })); + Assert.Equal(["E"], thirdResult.Messages!.Select(message => message.Text)); + Assert.Equal(["D", "E"], reducer.Inputs[2].Select(message => message.Text)); + } + + [Fact] + public async Task InvokingAsyncRebuildsReducedHistoryWhenInputPrefixChangesAsync() + { + // Arrange — the saved boundary C remains, but the earlier input is edited with the same MessageId. + RecordingChatReducer reducer = new(); + CompactionProvider provider = new(new ChatReducerCompactionStrategy(reducer, CompactionTriggers.Always)); + Mock mockAgent = new() { CallBase = true }; + TestAgentSession session = new(); + List firstInput = + [ + new(ChatRole.User, "A") { MessageId = "message-1" }, + new(ChatRole.Assistant, "B"), + new(ChatRole.User, "C"), + ]; + await provider.InvokingAsync(new(mockAgent.Object, session, new AIContext { Messages = firstInput })); + TestAgentSession restoredSession = new(AgentSessionStateBag.Deserialize(session.StateBag.Serialize())); + List replacement = + [ + new(ChatRole.User, "X") { MessageId = "message-1" }, + new(ChatRole.Assistant, "B"), + new(ChatRole.User, "C"), + new(ChatRole.Assistant, "D"), + ]; + + // Act + AIContext result = await provider.InvokingAsync(new(mockAgent.Object, restoredSession, new AIContext { Messages = replacement })); + + // Assert — rebuilding is necessary because X changed the saved prefix. + Assert.Equal(["D"], result.Messages!.Select(message => message.Text)); + Assert.Equal(2, reducer.Inputs.Count); + Assert.Equal(["X", "B", "C", "D"], reducer.Inputs[1].Select(message => message.Text)); + Assert.Equal(4, restoredSession.StateBag.Serialize().GetProperty(provider.StateKeys[0]).GetProperty("processedinputmessagecount").GetInt32()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task InvokingAsyncRebuildsReducedHistoryWhenMessageIdChangesAsync(bool serializeSession) + { + // Arrange + RecordingChatReducer reducer = new(); + CompactionProvider provider = new(new ChatReducerCompactionStrategy(reducer, CompactionTriggers.Always)); + Mock mockAgent = new() { CallBase = true }; + TestAgentSession session = new(); + ChatMessage retained = new(ChatRole.User, "Keep"); + List firstInput = [new(ChatRole.User, "Old") { MessageId = "old-id" }, retained]; + await provider.InvokingAsync(new(mockAgent.Object, session, new AIContext { Messages = firstInput })); + if (serializeSession) + { + session = new(AgentSessionStateBag.Deserialize(session.StateBag.Serialize())); + } + + ChatMessage replacement = new(ChatRole.User, "Old") { MessageId = "new-id" }; + List input = [replacement, retained, new(ChatRole.User, "New")]; + + // Act + await provider.InvokingAsync(new(mockAgent.Object, session, new AIContext { Messages = input })); + + // Assert — an identity change invalidates the saved reduction even when content is unchanged. + Assert.Equal(2, reducer.Inputs.Count); + Assert.Equal(["Old", "Keep", "New"], reducer.Inputs[1].Select(message => message.Text)); + Assert.Same(replacement, reducer.Inputs[1][0]); + Assert.Equal("new-id", reducer.Inputs[1][0].MessageId); + } + + [Fact] + public async Task InvokingAsyncPreservesReducedHistoryWithUnchangedMessageIdsAfterSerializationAsync() + { + // Arrange — unchanged IDs in newly materialized messages must preserve a serialized reduction. + RecordingChatReducer reducer = new(); + CompactionProvider provider = new(new ChatReducerCompactionStrategy(reducer, CompactionTriggers.Always)); + Mock mockAgent = new() { CallBase = true }; + TestAgentSession session = new(); + List firstInput = + [ + new(ChatRole.User, "Old") { MessageId = "message-1" }, + new(ChatRole.User, "Keep") { MessageId = "message-2" }, + ]; + await provider.InvokingAsync(new(mockAgent.Object, session, new AIContext { Messages = firstInput })); + TestAgentSession restoredSession = new(AgentSessionStateBag.Deserialize(session.StateBag.Serialize())); + List input = + [ + new(ChatRole.User, "Old") { MessageId = "message-1" }, + new(ChatRole.User, "Keep") { MessageId = "message-2" }, + new(ChatRole.User, "New") { MessageId = "message-3" }, + ]; + + // Act + await provider.InvokingAsync(new(mockAgent.Object, restoredSession, new AIContext { Messages = input })); + + // Assert — the reducer receives the retained subset and new message, without the discarded history. + Assert.Equal(2, reducer.Inputs.Count); + Assert.Equal(["Keep", "New"], reducer.Inputs[1].Select(message => message.Text)); + Assert.Equal(["message-2", "message-3"], reducer.Inputs[1].Select(message => message.MessageId)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task InvokingAsyncRebuildsReducedHistoryWhenInputIsMutatedAsync(bool serializeSession) + { + // Arrange + RecordingChatReducer reducer = new(); + CompactionProvider provider = new(new ChatReducerCompactionStrategy(reducer, CompactionTriggers.Always)); + Mock mockAgent = new() { CallBase = true }; + TestAgentSession session = new(); + List input = [new(ChatRole.User, "Old"), new(ChatRole.User, "Keep")]; + await provider.InvokingAsync(new(mockAgent.Object, session, new AIContext { Messages = input })); + if (serializeSession) + { + session = new(AgentSessionStateBag.Deserialize(session.StateBag.Serialize())); + } + + ((TextContent)input[0].Contents[0]).Text = "Edited"; + input.Add(new(ChatRole.User, "New")); + + // Act + await provider.InvokingAsync(new(mockAgent.Object, session, new AIContext { Messages = input })); + + // Assert — the reducer sees the changed original input, rather than the stale retained subset. + Assert.Equal(["Edited", "Keep", "New"], reducer.Inputs[1].Select(message => message.Text)); + } + + [Fact] + public async Task InvokingAsyncStoresFixedSizeFingerprintAfterReductionAsync() + { + // Arrange — discarded content must not be retained solely to validate future input. + CompactionProvider provider = new(new ChatReducerCompactionStrategy(new RecordingChatReducer(), CompactionTriggers.Always)); + Mock mockAgent = new() { CallBase = true }; + TestAgentSession session = new(); + string discarded = new('x', 65536); + List input = [new(ChatRole.User, discarded), new(ChatRole.User, "Keep")]; + + // Act + await provider.InvokingAsync(new(mockAgent.Object, session, new AIContext { Messages = input })); + var serialized = session.StateBag.Serialize(); + var state = serialized.GetProperty(provider.StateKeys[0]); + + // Assert + Assert.Equal(44, state.GetProperty("inputprefixfingerprint").GetString()!.Length); + Assert.False(state.TryGetProperty("reducedinputprefix", out _)); + Assert.DoesNotContain(discarded, serialized.GetRawText()); + Assert.True(serialized.GetRawText().Length < 2048); + } + + [Fact] + public async Task InvokingAsyncPreservesToolExclusionAfterSessionSerializationAsync() + { + // Arrange — serialized tool payload values may deserialize as JsonElement instead of their original CLR types. + CompactionProvider provider = new(new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(100000))); + Mock mockAgent = new() { CallBase = true }; + TestAgentSession session = new(); + List firstInput = + [ + new(ChatRole.Assistant, [new FunctionCallContent("call-1", "lookup", new Dictionary { ["query"] = "Seattle" })]), + new(ChatRole.Tool, [new FunctionResultContent("call-1", 123)]), + new(ChatRole.User, "Question"), + ]; + CompactionMessageIndex index = CompactionMessageIndex.Create(firstInput); + index.Groups[0].IsExcluded = true; + CompactionProvider.State state = new() + { + MessageGroups = [.. index.Groups], + ProcessedInputMessageCount = index.ProcessedInputMessageCount, + InputSummaryGroupIndices = index.InputSummaryGroupIndices, + InputPrefixFingerprint = index.InputPrefixFingerprint, + }; + session.StateBag.SetValue(provider.StateKeys[0], state, AgentJsonUtilities.DefaultOptions); + TestAgentSession restoredSession = new(AgentSessionStateBag.Deserialize(session.StateBag.Serialize())); + List appended = [.. firstInput, new(ChatRole.User, "Follow-up")]; + + // Act + AIContext result = await provider.InvokingAsync(new(mockAgent.Object, restoredSession, new AIContext { Messages = appended })); + + // Assert — unchanged tool history remains excluded and only the new user message is appended. + Assert.Equal(["Question", "Follow-up"], result.Messages!.Select(message => message.Text)); + } + + [Fact] + public async Task InvokingAsyncKeepsEmptyReducedHistoryWhenInputIsAppendedAsync() + { + // Arrange — an empty reduction still needs its saved input boundary on the next turn. + RecordingChatReducer reducer = new(keepLastMessage: false); + CompactionProvider provider = new(new ChatReducerCompactionStrategy(reducer, CompactionTriggers.Always)); + Mock mockAgent = new() { CallBase = true }; + TestAgentSession session = new(); + List firstInput = [new(ChatRole.User, "A"), new(ChatRole.Assistant, "B")]; + await provider.InvokingAsync(new(mockAgent.Object, session, new AIContext { Messages = firstInput })); + var serializedState = session.StateBag.Serialize(); + Assert.Equal(0, serializedState.GetProperty(provider.StateKeys[0]).GetProperty("messagegroups").GetArrayLength()); + Assert.Equal(2, serializedState.GetProperty(provider.StateKeys[0]).GetProperty("processedinputmessagecount").GetInt32()); + TestAgentSession unchangedSession = new(AgentSessionStateBag.Deserialize(serializedState)); + TestAgentSession restoredSession = new(AgentSessionStateBag.Deserialize(serializedState)); + List appended = [new(ChatRole.User, "A"), new(ChatRole.Assistant, "B"), new(ChatRole.User, "C")]; + + // Act + AIContext unchangedResult = await provider.InvokingAsync(new(mockAgent.Object, unchangedSession, new AIContext { Messages = firstInput })); + AIContext result = await provider.InvokingAsync(new(mockAgent.Object, restoredSession, new AIContext { Messages = appended })); + + // Assert — only C is indexed; a single non-system group does not trigger reduction. + Assert.Empty(unchangedResult.Messages!); + Assert.Equal(["C"], result.Messages!.Select(message => message.Text)); + Assert.Single(reducer.Inputs); + Assert.Equal(3, restoredSession.StateBag.Serialize().GetProperty(provider.StateKeys[0]).GetProperty("processedinputmessagecount").GetInt32()); + + // Act & Assert — after another append, the reducer sees only the retained C and new D. + TestAgentSession thirdSession = new(AgentSessionStateBag.Deserialize(restoredSession.StateBag.Serialize())); + List thirdInput = [.. appended, new(ChatRole.Assistant, "D")]; + AIContext thirdResult = await provider.InvokingAsync(new(mockAgent.Object, thirdSession, new AIContext { Messages = thirdInput })); + Assert.Empty(thirdResult.Messages!); + Assert.Equal(["C", "D"], reducer.Inputs[1].Select(message => message.Text)); + } + [Fact] public async Task InvokingAsyncWithNonListEnumerableCreatesListCopyAsync() { @@ -443,5 +843,34 @@ public async Task InvokingAsyncMarksOnlyPreviouslySeenMessagesAsChatHistoryAsync Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, resultList3[6].GetAgentRequestMessageSourceType()); } - private sealed class TestAgentSession : AgentSession; + private sealed class RecordingChatReducer : IChatReducer + { + private readonly bool _keepLastMessage; + + public RecordingChatReducer(bool keepLastMessage = true) + { + this._keepLastMessage = keepLastMessage; + } + + public List> Inputs { get; } = []; + + public Task> ReduceAsync(IEnumerable messages, System.Threading.CancellationToken cancellationToken = default) + { + List input = [.. messages]; + this.Inputs.Add(input); + return Task.FromResult>(this._keepLastMessage ? [input[^1]] : []); + } + } + + private sealed class TestAgentSession : AgentSession + { + public TestAgentSession() + { + } + + public TestAgentSession(AgentSessionStateBag stateBag) + : base(stateBag) + { + } + } }