From 0f1b425dbeddecbe99691ac6848003872095490d Mon Sep 17 00:00:00 2001 From: Punisheroot <44579963+Punisheroot@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:10:17 +0200 Subject: [PATCH 1/6] Fix compaction updates for repeated trailing messages --- .../Compaction/CompactionMessageIndex.cs | 18 +++--- .../Compaction/CompactionMessageIndexTests.cs | 57 +++++++++++++++++++ .../Compaction/CompactionProviderTests.cs | 28 +++++++++ 3 files changed, 96 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs index 3edfb4591aa..e300a7fe18f 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs @@ -105,16 +105,15 @@ 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. + /// Uses the position of the last processed message to detect appended messages. Only the messages after that + /// position are processed and appended as new groups. Existing groups and their compaction state (exclusions) are preserved. /// /// /// 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 last message in matches the last - /// processed message, no work is performed. + /// If no messages have been appended, no new groups are created. /// /// internal void Update(IList allMessages) @@ -127,11 +126,16 @@ internal void Update(IList allMessages) return; } - // If the last message is unchanged and the list hasn't shrunk, there is nothing new to process. + int processedMessageCount = this.RawMessageCount; + + // The last message may have the same content on every turn (for example, TodoProvider's empty list). + // Use its original position, not the last content-equivalent occurrence in the new list. if (this._lastProcessedMessage is not null && - allMessages.Count >= this.RawMessageCount && - allMessages[allMessages.Count - 1].ContentEquals(this._lastProcessedMessage)) + processedMessageCount > 0 && + allMessages.Count >= processedMessageCount && + allMessages[processedMessageCount - 1].ContentEquals(this._lastProcessedMessage)) { + this.AppendFromMessages(allMessages, processedMessageCount); return; } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs index 4613479d2a5..5b0193871f9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs @@ -571,6 +571,63 @@ 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 UpdateNoOpWhenNoNewMessages() { diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs index e4d4fa23780..022f4211c9c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs @@ -236,6 +236,34 @@ 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 InvokingAsyncWithNonListEnumerableCreatesListCopyAsync() { From 42424257fb5a7a6c356bca2d050b2685671da935 Mon Sep 17 00:00:00 2001 From: Punisheroot <44579963+Punisheroot@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:38:16 +0200 Subject: [PATCH 2/6] Track consumed input boundary across compaction sessions --- .../Microsoft.Agents.AI/AgentJsonUtilities.cs | 4 + .../Compaction/CompactionMessageIndex.cs | 75 +++++++++++++++++-- .../Compaction/CompactionProvider.cs | 9 ++- .../Compaction/CompactionMessageIndexTests.cs | 64 ++++++++++++++++ .../Compaction/CompactionProviderTests.cs | 40 +++++++++- 5 files changed, 183 insertions(+), 9 deletions(-) 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/CompactionMessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs index e300a7fe18f..d49331f7760 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs @@ -27,6 +27,7 @@ public sealed class CompactionMessageIndex { private int _currentTurn; private ChatMessage? _lastProcessedMessage; + private int? _processedInputMessageCount; /// /// Gets the list of message groups in this collection. @@ -96,6 +97,15 @@ internal static CompactionMessageIndex Create(IList messages, Token return instance; } + internal static CompactionMessageIndex Restore(IList groups, int? processedInputMessageCount) + { + CompactionMessageIndex instance = new(groups); + instance._processedInputMessageCount = processedInputMessageCount; + return instance; + } + + internal int? ProcessedInputMessageCount => this._processedInputMessageCount; + /// /// Incrementally updates the groups with new messages from the conversation. /// @@ -123,10 +133,11 @@ internal void Update(IList allMessages) this.Groups.Clear(); this._currentTurn = 0; this._lastProcessedMessage = null; + this._processedInputMessageCount = 0; return; } - int processedMessageCount = this.RawMessageCount; + int processedMessageCount = this._processedInputMessageCount ?? this.RawMessageCount; // The last message may have the same content on every turn (for example, TodoProvider's empty list). // Use its original position, not the last content-equivalent occurrence in the new list. @@ -139,6 +150,24 @@ internal void Update(IList allMessages) return; } + // An input summary can be the last processed message even though the restored index + // normally skips summaries when finding that message. Match the represented prefix, + // skipping summary groups that are absent from the input history. + if (this._processedInputMessageCount.HasValue && this.MatchesInputPrefix(allMessages, processedMessageCount)) + { + this.AppendFromMessages(allMessages, processedMessageCount); + return; + } + + // The persisted input count is authoritative. A mismatch means the input history changed; + // searching by content could mistake a later duplicate for the old boundary. Legacy state + // with summaries has no reliable input count, so rebuild it once instead. + if (this._processedInputMessageCount.HasValue || this.Groups.Any(group => group.Kind == CompactionGroupKind.Summary)) + { + this.RebuildFromMessages(allMessages); + return; + } + // Walk backwards to locate where we left off. int foundIndex = -1; if (this._lastProcessedMessage is not null) @@ -156,9 +185,7 @@ internal void Update(IList allMessages) if (foundIndex < 0) { // Last processed message not found — total rebuild. - this.Groups.Clear(); - this._currentTurn = 0; - this.AppendFromMessages(allMessages, 0); + this.RebuildFromMessages(allMessages); return; } @@ -168,9 +195,7 @@ internal void Update(IList allMessages) if (foundIndex + 1 < this.RawMessageCount) { // Front of the message list was trimmed — rebuild. - this.Groups.Clear(); - this._currentTurn = 0; - this.AppendFromMessages(allMessages, 0); + this.RebuildFromMessages(allMessages); return; } @@ -178,6 +203,40 @@ internal void Update(IList allMessages) this.AppendFromMessages(allMessages, foundIndex + 1); } + private void RebuildFromMessages(IList messages) + { + this.Groups.Clear(); + this._currentTurn = 0; + this._lastProcessedMessage = null; + this.AppendFromMessages(messages, 0); + } + + private bool MatchesInputPrefix(IList messages, int processedMessageCount) + { + if (messages.Count < processedMessageCount) + { + return false; + } + + int inputIndex = 0; + foreach (CompactionMessageGroup group in this.Groups) + { + foreach (ChatMessage message in group.Messages) + { + if (inputIndex < processedMessageCount && messages[inputIndex].ContentEquals(message)) + { + inputIndex++; + } + else if (group.Kind != CompactionGroupKind.Summary) + { + return false; + } + } + } + + return inputIndex == processedMessageCount; + } + private void AppendFromMessages(IList messages, int startIndex) { int index = startIndex; @@ -271,6 +330,8 @@ private void AppendFromMessages(IList messages, int startIndex) { this._lastProcessedMessage = messages[^1]; } + + this._processedInputMessageCount = messages.Count; } /// diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs index 2d919f709cf..1c90901a9fb 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs @@ -145,7 +145,7 @@ protected override async ValueTask InvokingCoreAsync(InvokingContext CompactionMessageIndex messageIndex; if (state.MessageGroups.Count > 0) { - messageIndex = new([.. state.MessageGroups]); + messageIndex = CompactionMessageIndex.Restore([.. state.MessageGroups], state.ProcessedInputMessageCount); // Treat all messages already in the index as chat history. foreach (var message in messageIndex.Groups.SelectMany(x => x.Messages)) @@ -183,6 +183,7 @@ await this._compactionStrategy.CompactAsync( // Persist the index state.MessageGroups.Clear(); state.MessageGroups.AddRange(messageIndex.Groups); + state.ProcessedInputMessageCount = messageIndex.ProcessedInputMessageCount; // 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 +222,11 @@ 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; } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs index 5b0193871f9..9ea5f6c8034 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs @@ -628,6 +628,70 @@ public void UpdateAppendsAfterGeneratedSummaryAndDuplicateTrailingMessage() 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); + + // 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 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() { diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs index 022f4211c9c..9b07cc7383c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs @@ -264,6 +264,34 @@ public async Task InvokingAsyncIncludesNewUserMessageBeforeRepeatedTodoListAsync 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()); + 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 InvokingAsyncWithNonListEnumerableCreatesListCopyAsync() { @@ -471,5 +499,15 @@ public async Task InvokingAsyncMarksOnlyPreviouslySeenMessagesAsChatHistoryAsync Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, resultList3[6].GetAgentRequestMessageSourceType()); } - private sealed class TestAgentSession : AgentSession; + private sealed class TestAgentSession : AgentSession + { + public TestAgentSession() + { + } + + public TestAgentSession(AgentSessionStateBag stateBag) + : base(stateBag) + { + } + } } From 3178c03dd30988cca55e5b24cb84cc1e789a0050 Mon Sep 17 00:00:00 2001 From: Punisheroot <44579963+Punisheroot@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:36:48 +0200 Subject: [PATCH 3/6] Validate compaction input prefix before incremental append --- .../Compaction/CompactionMessageIndex.cs | 105 ++++-------------- .../Compaction/CompactionMessageIndexTests.cs | 61 ++++++++++ .../Compaction/CompactionProviderTests.cs | 33 ++++++ 3 files changed, 114 insertions(+), 85 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs index d49331f7760..ee370168ac2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs @@ -26,8 +26,6 @@ namespace Microsoft.Agents.AI.Compaction; public sealed class CompactionMessageIndex { private int _currentTurn; - private ChatMessage? _lastProcessedMessage; - private int? _processedInputMessageCount; /// /// Gets the list of message groups in this collection. @@ -49,24 +47,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; } } } @@ -100,11 +87,11 @@ internal static CompactionMessageIndex Create(IList messages, Token internal static CompactionMessageIndex Restore(IList groups, int? processedInputMessageCount) { CompactionMessageIndex instance = new(groups); - instance._processedInputMessageCount = processedInputMessageCount; + instance.ProcessedInputMessageCount = processedInputMessageCount; return instance; } - internal int? ProcessedInputMessageCount => this._processedInputMessageCount; + internal int? ProcessedInputMessageCount { get; private set; } /// /// Incrementally updates the groups with new messages from the conversation. @@ -115,15 +102,15 @@ internal static CompactionMessageIndex Restore(IList gro /// /// /// - /// Uses the position of the last processed message to detect appended messages. 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 no messages have been appended, no new groups are created. + /// If the prefix is unchanged and no messages have been appended, no new groups are created. /// /// internal void Update(IList allMessages) @@ -132,82 +119,35 @@ internal void Update(IList allMessages) { this.Groups.Clear(); this._currentTurn = 0; - this._lastProcessedMessage = null; - this._processedInputMessageCount = 0; + this.ProcessedInputMessageCount = 0; return; } - int processedMessageCount = this._processedInputMessageCount ?? this.RawMessageCount; + int processedMessageCount = this.ProcessedInputMessageCount ?? this.RawMessageCount; - // The last message may have the same content on every turn (for example, TodoProvider's empty list). - // Use its original position, not the last content-equivalent occurrence in the new list. - if (this._lastProcessedMessage is not null && - processedMessageCount > 0 && - allMessages.Count >= processedMessageCount && - allMessages[processedMessageCount - 1].ContentEquals(this._lastProcessedMessage)) - { - this.AppendFromMessages(allMessages, processedMessageCount); - return; - } - - // An input summary can be the last processed message even though the restored index - // normally skips summaries when finding that message. Match the represented prefix, - // skipping summary groups that are absent from the input history. - if (this._processedInputMessageCount.HasValue && this.MatchesInputPrefix(allMessages, processedMessageCount)) - { - this.AppendFromMessages(allMessages, processedMessageCount); - return; - } - - // The persisted input count is authoritative. A mismatch means the input history changed; - // searching by content could mistake a later duplicate for the old boundary. Legacy state - // with summaries has no reliable input count, so rebuild it once instead. - if (this._processedInputMessageCount.HasValue || this.Groups.Any(group => group.Kind == CompactionGroupKind.Summary)) + // Older state with summaries has no reliable input count because RawMessageCount excludes + // input summaries. Rebuild it once to establish a known boundary. + if (!this.ProcessedInputMessageCount.HasValue && this.Groups.Any(group => group.Kind == CompactionGroupKind.Summary)) { this.RebuildFromMessages(allMessages); return; } - // Walk backwards to locate where we left off. - int foundIndex = -1; - if (this._lastProcessedMessage is not null) + // Without a history version, the represented input prefix must match before preserving groups: + // a repeated message at the saved boundary can hide replaced or shifted history. + if (this.MatchesInputPrefix(allMessages, processedMessageCount)) { - for (int i = allMessages.Count - 1; i >= 0; --i) - { - if (allMessages[i].ContentEquals(this._lastProcessedMessage)) - { - foundIndex = i; - break; - } - } - } - - if (foundIndex < 0) - { - // Last processed message not found — total rebuild. - this.RebuildFromMessages(allMessages); - 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) - { - // Front of the message list was trimmed — rebuild. - this.RebuildFromMessages(allMessages); + this.AppendFromMessages(allMessages, processedMessageCount); return; } - // Process only the delta messages. - this.AppendFromMessages(allMessages, foundIndex + 1); + this.RebuildFromMessages(allMessages); } private void RebuildFromMessages(IList messages) { this.Groups.Clear(); this._currentTurn = 0; - this._lastProcessedMessage = null; this.AppendFromMessages(messages, 0); } @@ -326,12 +266,7 @@ private void AppendFromMessages(IList messages, int startIndex) } } - if (messages.Count > 0) - { - this._lastProcessedMessage = messages[^1]; - } - - this._processedInputMessageCount = messages.Count; + this.ProcessedInputMessageCount = messages.Count; } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs index 9ea5f6c8034..74c9ad3abea 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs @@ -671,6 +671,67 @@ public void RestorePreservesExclusionsWhenLastInputIsSummary() 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); + 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 UpdateRebuildsLegacySummaryStateWithoutInputCount() { diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs index 9b07cc7383c..7479e5757ca 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs @@ -292,6 +292,39 @@ public async Task InvokingAsyncIncludesNewUserMessageAfterInputSummaryAsync() 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 InvokingAsyncWithNonListEnumerableCreatesListCopyAsync() { From 3728049f23f049a269a12e8ac8af117e71dc8791 Mon Sep 17 00:00:00 2001 From: Punisheroot <44579963+Punisheroot@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:58:33 +0200 Subject: [PATCH 4/6] Track input summary provenance in compaction state --- .../Compaction/CompactionMessageIndex.cs | 74 ++++++++++++++--- .../Compaction/CompactionProvider.cs | 10 ++- .../Compaction/CompactionMessageIndexTests.cs | 81 ++++++++++++++++++- .../Compaction/CompactionProviderTests.cs | 51 ++++++++++++ 4 files changed, 203 insertions(+), 13 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs index ee370168ac2..2a067a3bdba 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs @@ -26,6 +26,8 @@ namespace Microsoft.Agents.AI.Compaction; public sealed class CompactionMessageIndex { private int _currentTurn; + private HashSet? _inputSummaryGroups; + private bool _hasSummaryProvenance = true; /// /// Gets the list of message groups in this collection. @@ -84,15 +86,55 @@ internal static CompactionMessageIndex Create(IList messages, Token return instance; } - internal static CompactionMessageIndex Restore(IList groups, int? processedInputMessageCount) + internal static CompactionMessageIndex Restore(IList groups, int? processedInputMessageCount, IReadOnlyList? inputSummaryGroupIndices) { CompactionMessageIndex instance = new(groups); instance.ProcessedInputMessageCount = processedInputMessageCount; + + if (inputSummaryGroupIndices is null) + { + instance._hasSummaryProvenance = !groups.Any(group => group.Kind == CompactionGroupKind.Summary); + } + else + { + foreach (int index in inputSummaryGroupIndices) + { + if (index < 0 || index >= groups.Count || groups[index].Kind != CompactionGroupKind.Summary || + !(instance._inputSummaryGroups ??= []).Add(groups[index])) + { + instance._inputSummaryGroups?.Clear(); + instance._hasSummaryProvenance = false; + break; + } + } + } + return instance; } internal int? ProcessedInputMessageCount { 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. /// @@ -119,15 +161,18 @@ internal void Update(IList allMessages) { this.Groups.Clear(); this._currentTurn = 0; + this._inputSummaryGroups?.Clear(); + this._hasSummaryProvenance = true; this.ProcessedInputMessageCount = 0; return; } int processedMessageCount = this.ProcessedInputMessageCount ?? this.RawMessageCount; - // Older state with summaries has no reliable input count because RawMessageCount excludes - // input summaries. Rebuild it once to establish a known boundary. - if (!this.ProcessedInputMessageCount.HasValue && this.Groups.Any(group => group.Kind == CompactionGroupKind.Summary)) + // Older state with summaries may lack the input count or summary provenance. + // Rebuild it once rather than guessing which summary groups came from the input. + if ((!this.ProcessedInputMessageCount.HasValue || !this._hasSummaryProvenance) && + this.Groups.Any(group => group.Kind == CompactionGroupKind.Summary)) { this.RebuildFromMessages(allMessages); return; @@ -148,6 +193,8 @@ private void RebuildFromMessages(IList messages) { this.Groups.Clear(); this._currentTurn = 0; + this._inputSummaryGroups?.Clear(); + this._hasSummaryProvenance = true; this.AppendFromMessages(messages, 0); } @@ -161,16 +208,21 @@ private bool MatchesInputPrefix(IList messages, int processedMessag int inputIndex = 0; foreach (CompactionMessageGroup group in this.Groups) { + if (group.Kind == CompactionGroupKind.Summary && this._inputSummaryGroups?.Contains(group) is not true) + { + continue; + } + foreach (ChatMessage message in group.Messages) { - if (inputIndex < processedMessageCount && messages[inputIndex].ContentEquals(message)) - { - inputIndex++; - } - else if (group.Kind != CompactionGroupKind.Summary) + if (inputIndex >= processedMessageCount || + !messages[inputIndex].ContentEquals(message) || + IsSummaryMessage(messages[inputIndex]) != IsSummaryMessage(message)) { return false; } + + inputIndex++; } } @@ -215,7 +267,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)) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs index 1c90901a9fb..535acd3561f 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionProvider.cs @@ -145,7 +145,7 @@ protected override async ValueTask InvokingCoreAsync(InvokingContext CompactionMessageIndex messageIndex; if (state.MessageGroups.Count > 0) { - messageIndex = CompactionMessageIndex.Restore([.. state.MessageGroups], state.ProcessedInputMessageCount); + messageIndex = CompactionMessageIndex.Restore([.. state.MessageGroups], state.ProcessedInputMessageCount, state.InputSummaryGroupIndices); // Treat all messages already in the index as chat history. foreach (var message in messageIndex.Groups.SelectMany(x => x.Messages)) @@ -184,6 +184,7 @@ await this._compactionStrategy.CompactAsync( state.MessageGroups.Clear(); state.MessageGroups.AddRange(messageIndex.Groups); state.ProcessedInputMessageCount = messageIndex.ProcessedInputMessageCount; + state.InputSummaryGroupIndices = messageIndex.InputSummaryGroupIndices; // 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 @@ -228,5 +229,12 @@ internal sealed class State /// [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; } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs index 74c9ad3abea..2d93c628bd9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs @@ -659,7 +659,7 @@ public void RestorePreservesExclusionsWhenLastInputIsSummary() 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); + CompactionMessageIndex restored = CompactionMessageIndex.Restore([.. original.Groups], original.ProcessedInputMessageCount, original.InputSummaryGroupIndices); // Act messages.Add(new ChatMessage(ChatRole.User, "What is the weather today?")); @@ -683,7 +683,7 @@ public void UpdateRebuildsRestoredIndexWhenPrefixChangesButBoundaryMatches() ]; CompactionMessageIndex original = CompactionMessageIndex.Create(originalMessages); original.Groups[0].IsExcluded = true; - CompactionMessageIndex restored = CompactionMessageIndex.Restore([.. original.Groups], original.ProcessedInputMessageCount); + CompactionMessageIndex restored = CompactionMessageIndex.Restore([.. original.Groups], original.ProcessedInputMessageCount, original.InputSummaryGroupIndices); List replacement = [ new ChatMessage(ChatRole.User, "New question"), @@ -732,6 +732,83 @@ public void UpdateRebuildsWhenHistoryShiftsPastRepeatedBoundary() Assert.All(restored.Groups, group => Assert.False(group.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); + + // 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 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); + 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() { diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs index 7479e5757ca..79a0129f288 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs @@ -278,6 +278,7 @@ public async Task InvokingAsyncIncludesNewUserMessageAfterInputSummaryAsync() 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 @@ -325,6 +326,56 @@ public async Task InvokingAsyncRebuildsReplacedHistoryWithRepeatedBoundaryAsync( 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, + }; + 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 InvokingAsyncWithNonListEnumerableCreatesListCopyAsync() { From a067304cf48e8a57100f78d81584ba6e87a0eedc Mon Sep 17 00:00:00 2001 From: Punisheroot <44579963+Punisheroot@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:24:19 +0200 Subject: [PATCH 5/6] Preserve compaction input with immutable fingerprints --- .../ChatReducerCompactionStrategy.cs | 8 +- .../Compaction/CompactionInputFingerprint.cs | 138 +++++++++ .../Compaction/CompactionMessageIndex.cs | 79 ++--- .../Compaction/CompactionProvider.cs | 12 +- .../Compaction/CompactionMessageIndexTests.cs | 269 +++++++++++++++++- .../Compaction/CompactionProviderTests.cs | 217 ++++++++++++++ 6 files changed, 656 insertions(+), 67 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI/Compaction/CompactionInputFingerprint.cs 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..9ea612c67ba --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionInputFingerprint.cs @@ -0,0 +1,138 @@ +// 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 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.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 = JsonSerializer.SerializeToElement(content, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIContent))); + 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 2a067a3bdba..6e8d17587d5 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionMessageIndex.cs @@ -27,7 +27,6 @@ public sealed class CompactionMessageIndex { private int _currentTurn; private HashSet? _inputSummaryGroups; - private bool _hasSummaryProvenance = true; /// /// Gets the list of message groups in this collection. @@ -86,16 +85,13 @@ internal static CompactionMessageIndex Create(IList messages, Token return instance; } - internal static CompactionMessageIndex Restore(IList groups, int? processedInputMessageCount, IReadOnlyList? inputSummaryGroupIndices) + 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 null) - { - instance._hasSummaryProvenance = !groups.Any(group => group.Kind == CompactionGroupKind.Summary); - } - else + if (inputSummaryGroupIndices is not null) { foreach (int index in inputSummaryGroupIndices) { @@ -103,7 +99,6 @@ internal static CompactionMessageIndex Restore(IList gro !(instance._inputSummaryGroups ??= []).Add(groups[index])) { instance._inputSummaryGroups?.Clear(); - instance._hasSummaryProvenance = false; break; } } @@ -114,6 +109,8 @@ internal static CompactionMessageIndex Restore(IList gro internal int? ProcessedInputMessageCount { get; private set; } + internal string? InputPrefixFingerprint { get; private set; } + internal List InputSummaryGroupIndices { get @@ -158,31 +155,23 @@ internal List InputSummaryGroupIndices internal void Update(IList allMessages) { if (allMessages.Count == 0) - { - this.Groups.Clear(); - this._currentTurn = 0; - this._inputSummaryGroups?.Clear(); - this._hasSummaryProvenance = true; - this.ProcessedInputMessageCount = 0; - return; - } - - int processedMessageCount = this.ProcessedInputMessageCount ?? this.RawMessageCount; - - // Older state with summaries may lack the input count or summary provenance. - // Rebuild it once rather than guessing which summary groups came from the input. - if ((!this.ProcessedInputMessageCount.HasValue || !this._hasSummaryProvenance) && - this.Groups.Any(group => group.Kind == CompactionGroupKind.Summary)) { this.RebuildFromMessages(allMessages); return; } - // Without a history version, the represented input prefix must match before preserving groups: - // a repeated message at the saved boundary can hide replaced or shifted history. - if (this.MatchesInputPrefix(allMessages, processedMessageCount)) + // 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)) { - this.AppendFromMessages(allMessages, processedMessageCount); + if (allMessages.Count > processedMessageCount) + { + this.AppendFromMessages(allMessages, processedMessageCount); + } + return; } @@ -194,39 +183,20 @@ private void RebuildFromMessages(IList messages) this.Groups.Clear(); this._currentTurn = 0; this._inputSummaryGroups?.Clear(); - this._hasSummaryProvenance = true; this.AppendFromMessages(messages, 0); } - private bool MatchesInputPrefix(IList messages, int processedMessageCount) + internal void ReplaceWithReducedMessages(IList reducedMessages) { - if (messages.Count < processedMessageCount) - { - return false; - } - - int inputIndex = 0; - foreach (CompactionMessageGroup group in this.Groups) + CompactionMessageIndex rebuilt = Create(reducedMessages, this.Tokenizer); + this.Groups.Clear(); + foreach (CompactionMessageGroup group in rebuilt.Groups) { - if (group.Kind == CompactionGroupKind.Summary && this._inputSummaryGroups?.Contains(group) is not true) - { - continue; - } - - foreach (ChatMessage message in group.Messages) - { - if (inputIndex >= processedMessageCount || - !messages[inputIndex].ContentEquals(message) || - IsSummaryMessage(messages[inputIndex]) != IsSummaryMessage(message)) - { - return false; - } - - inputIndex++; - } + this.Groups.Add(group); } - return inputIndex == processedMessageCount; + this._currentTurn = rebuilt._currentTurn; + this._inputSummaryGroups?.Clear(); } private void AppendFromMessages(IList messages, int startIndex) @@ -320,6 +290,7 @@ private void AppendFromMessages(IList messages, int startIndex) } } + this.InputPrefixFingerprint = CompactionInputFingerprint.Compute(messages, messages.Count); this.ProcessedInputMessageCount = messages.Count; } @@ -578,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 535acd3561f..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 = CompactionMessageIndex.Restore([.. state.MessageGroups], state.ProcessedInputMessageCount, state.InputSummaryGroupIndices); + 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)) @@ -185,6 +185,7 @@ await this._compactionStrategy.CompactAsync( 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 @@ -236,5 +237,12 @@ internal sealed class State /// [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 2d93c628bd9..859e4cf9138 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs @@ -659,7 +659,7 @@ public void RestorePreservesExclusionsWhenLastInputIsSummary() 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); + CompactionMessageIndex restored = CompactionMessageIndex.Restore([.. original.Groups], original.ProcessedInputMessageCount, original.InputSummaryGroupIndices, original.InputPrefixFingerprint); // Act messages.Add(new ChatMessage(ChatRole.User, "What is the weather today?")); @@ -683,7 +683,7 @@ public void UpdateRebuildsRestoredIndexWhenPrefixChangesButBoundaryMatches() ]; CompactionMessageIndex original = CompactionMessageIndex.Create(originalMessages); original.Groups[0].IsExcluded = true; - CompactionMessageIndex restored = CompactionMessageIndex.Restore([.. original.Groups], original.ProcessedInputMessageCount, original.InputSummaryGroupIndices); + CompactionMessageIndex restored = CompactionMessageIndex.Restore([.. original.Groups], original.ProcessedInputMessageCount, original.InputSummaryGroupIndices, original.InputPrefixFingerprint); List replacement = [ new ChatMessage(ChatRole.User, "New question"), @@ -732,6 +732,193 @@ public void UpdateRebuildsWhenHistoryShiftsPastRepeatedBoundary() 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 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); + } + + [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() { @@ -743,7 +930,7 @@ public void RestoreSkipsGeneratedSummaryButMatchesInputSummary() 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); + CompactionMessageIndex restored = CompactionMessageIndex.Restore([.. original.Groups], original.ProcessedInputMessageCount, original.InputSummaryGroupIndices, original.InputPrefixFingerprint); // Act messages.Add(new ChatMessage(ChatRole.User, "Follow-up")); @@ -758,6 +945,72 @@ public void RestoreSkipsGeneratedSummaryButMatchesInputSummary() 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() { @@ -769,7 +1022,7 @@ public void RestoreRebuildsWhenGeneratedSummaryMatchesChangedInputSummary() (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); + 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")]; @@ -1764,4 +2017,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 79a0129f288..dfda663e83f 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; @@ -344,6 +345,7 @@ public async Task InvokingAsyncUsesSummaryProvenanceAfterSerializationAsync() 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(); @@ -376,6 +378,202 @@ public async Task InvokingAsyncUsesSummaryProvenanceAfterSerializationAsync() 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 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() { @@ -583,6 +781,25 @@ public async Task InvokingAsyncMarksOnlyPreviouslySeenMessagesAsChatHistoryAsync Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, resultList3[6].GetAgentRequestMessageSourceType()); } + 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() From 950be6dcf3ceaadbf5d511195df389e4b46d776a Mon Sep 17 00:00:00 2001 From: Punisheroot <44579963+Punisheroot@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:17:29 +0200 Subject: [PATCH 6/6] Validate compaction message identity and unsupported payloads --- .../Compaction/CompactionInputFingerprint.cs | 15 +- .../Compaction/CompactionMessageIndexTests.cs | 178 ++++++++++++++++++ .../Compaction/CompactionProviderTests.cs | 62 ++++++ 3 files changed, 253 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionInputFingerprint.cs b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionInputFingerprint.cs index 9ea612c67ba..417446a1eb0 100644 --- a/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionInputFingerprint.cs +++ b/dotnet/src/Microsoft.Agents.AI/Compaction/CompactionInputFingerprint.cs @@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Compaction; /// -/// Captures input content independently of mutable message objects without retaining the original history. +/// Captures input identity and content independently of mutable message objects without retaining the original history. /// internal static class CompactionInputFingerprint { @@ -29,6 +29,7 @@ internal static class CompactionInputFingerprint { ChatMessage message = messages[i]; writer.WriteStartArray(); + writer.WriteStringValue(message.MessageId); writer.WriteStringValue(message.Role.Value); writer.WriteStringValue(message.AuthorName); writer.WriteBooleanValue(CompactionMessageIndex.IsSummaryMessage(message)); @@ -45,7 +46,17 @@ internal static class CompactionInputFingerprint continue; } - JsonElement serialized = JsonSerializer.SerializeToElement(content, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(AIContent))); + 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); } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs index 859e4cf9138..7571cd412dc 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionMessageIndexTests.cs @@ -751,6 +751,77 @@ public void UpdateRebuildsWhenMessageContentChangesWithSameMessageId() 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)] @@ -813,6 +884,113 @@ public void UpdateRebuildsForContentWithoutSerializationContract() 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() { diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs index dfda663e83f..4e0d795a526 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Compaction/CompactionProviderTests.cs @@ -457,6 +457,68 @@ public async Task InvokingAsyncRebuildsReducedHistoryWhenInputPrefixChangesAsync 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)]