.NET: Fix compaction chat history duplication bug (#5149)

* Fix chat history duplication bug

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
westey
2026-04-07 16:16:28 +00:00
committed by GitHub
co-authored by Copilot
parent e224f06e60
commit 942cb04ccb
2 changed files with 106 additions and 1 deletions
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
@@ -129,8 +130,17 @@ public sealed class CompactionProvider : AIContextProvider
CompactionMessageIndex messageIndex;
if (state.MessageGroups.Count > 0)
{
// Update existing index with any new messages appended since the last call.
messageIndex = new([.. state.MessageGroups]);
// Treat all messages already in the index as chat history.
foreach (var message in messageIndex.Groups.SelectMany(x => x.Messages))
{
message.AdditionalProperties ??= new AdditionalPropertiesDictionary();
message.AdditionalProperties[AgentRequestMessageSourceAttribution.AdditionalPropertiesKey] =
new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, this.GetType().FullName!);
}
// Update existing index with any new messages appended since the last call.
messageIndex.Update(messageList);
}
else
@@ -159,6 +169,20 @@ public sealed class CompactionProvider : AIContextProvider
state.MessageGroups.Clear();
state.MessageGroups.AddRange(messageIndex.Groups);
// 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
// since they may be summaries of previous messages that are already in chat history.
foreach (var message in messageIndex.Groups.SelectMany(x => x.Messages))
{
// Only consider messages that aren't already marked as ChatHistory and messages that weren't passed into the provider.
if (message.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory && !messageList.Any(x => x.ContentEquals(message)))
{
message.AdditionalProperties ??= new AdditionalPropertiesDictionary();
message.AdditionalProperties[AgentRequestMessageSourceAttribution.AdditionalPropertiesKey] =
new AgentRequestMessageSourceAttribution(AgentRequestMessageSourceType.ChatHistory, this.GetType().FullName!);
}
}
return new AIContext
{
Instructions = context.AIContext.Instructions,
@@ -362,5 +362,86 @@ public sealed class CompactionProviderTests
Assert.Single(state.MessageGroups);
}
[Fact]
public async Task InvokingAsyncMarksOnlyPreviouslySeenMessagesAsChatHistoryAsync()
{
// Arrange — no-compaction strategy so we can observe marking behavior only
TruncationCompactionStrategy strategy = new(CompactionTriggers.TokensExceed(100000));
CompactionProvider provider = new(strategy);
Mock<AIAgent> mockAgent = new() { CallBase = true };
TestAgentSession session = new();
// --- First invocation: [Q1, A1, Q2] ---
ChatMessage q1 = new(ChatRole.User, "Q1");
ChatMessage a1 = new(ChatRole.Assistant, "A1");
ChatMessage q2 = new(ChatRole.User, "Q2");
AIContextProvider.InvokingContext context1 = new(
mockAgent.Object,
session,
new AIContext { Messages = new List<ChatMessage> { q1, a1, q2 } });
AIContext result1 = await provider.InvokingAsync(context1);
// Assert — on first invocation, no messages should be marked as ChatHistory
List<ChatMessage> resultList1 = [.. result1.Messages!];
Assert.Equal(3, resultList1.Count);
foreach (ChatMessage message in resultList1)
{
Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, message.GetAgentRequestMessageSourceType());
}
// --- Second invocation: [Q1, A1, Q2, A2, Q3] ---
ChatMessage a2 = new(ChatRole.Assistant, "A2");
ChatMessage q3 = new(ChatRole.User, "Q3");
AIContextProvider.InvokingContext context2 = new(
mockAgent.Object,
session,
new AIContext { Messages = new List<ChatMessage> { q1, a1, q2, a2, q3 } });
AIContext result2 = await provider.InvokingAsync(context2);
// Assert — messages from the first invocation should be marked as ChatHistory,
// while new messages should not.
List<ChatMessage> resultList2 = [.. result2.Messages!];
Assert.Equal(5, resultList2.Count);
// Q1, A1, Q2 were already in the provider state — they should be ChatHistory
Assert.Equal(AgentRequestMessageSourceType.ChatHistory, resultList2[0].GetAgentRequestMessageSourceType());
Assert.Equal(AgentRequestMessageSourceType.ChatHistory, resultList2[1].GetAgentRequestMessageSourceType());
Assert.Equal(AgentRequestMessageSourceType.ChatHistory, resultList2[2].GetAgentRequestMessageSourceType());
// A2, Q3 are new to the provider — they should NOT be ChatHistory
Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, resultList2[3].GetAgentRequestMessageSourceType());
Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, resultList2[4].GetAgentRequestMessageSourceType());
// --- Third invocation: [Q1, A1, Q2, A2, Q3, A3, Q4] ---
ChatMessage a3 = new(ChatRole.Assistant, "A3");
ChatMessage q4 = new(ChatRole.User, "Q4");
AIContextProvider.InvokingContext context3 = new(
mockAgent.Object,
session,
new AIContext { Messages = new List<ChatMessage> { q1, a1, q2, a2, q3, a3, q4 } });
AIContext result3 = await provider.InvokingAsync(context3);
// Assert — all previously seen messages should be ChatHistory, only brand-new ones should not
List<ChatMessage> resultList3 = [.. result3.Messages!];
Assert.Equal(7, resultList3.Count);
// Q1, A1, Q2, A2, Q3 were already in the provider state — they should be ChatHistory
for (int i = 0; i < 5; i++)
{
Assert.Equal(AgentRequestMessageSourceType.ChatHistory, resultList3[i].GetAgentRequestMessageSourceType());
}
// A3, Q4 are new — they should NOT be ChatHistory
Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, resultList3[5].GetAgentRequestMessageSourceType());
Assert.NotEqual(AgentRequestMessageSourceType.ChatHistory, resultList3[6].GetAgentRequestMessageSourceType());
}
private sealed class TestAgentSession : AgentSession;
}