From 2f08694cd9c284d0ba8a323cba140847dbd0cbf0 Mon Sep 17 00:00:00 2001 From: PratikDhanave Date: Fri, 17 Jul 2026 23:45:09 +0530 Subject: [PATCH] Locate compaction prefix boundary positionally to avoid dropping messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MessageIndex.Update located the end of the already-processed prefix by searching backward for the last message whose CONTENT equals lastProcessedMessage. When a message repeats (e.g. a user re-sending "continue", or any empty-content message), that latched the boundary onto a later duplicate, so the turns appended since the previous update were silently dropped from the index. Because the index is persisted across turns (state.MessageGroups), the loss compounds. Check the expected position (processedMessageCount-1) first, where lastProcessedMessage sits when nothing earlier was trimmed, and fall back to the backward search only when the prefix was actually trimmed — preserving the existing trim-tolerance / rebuild behavior. Adds a black-box test (repeated "continue") that drops messages before this change and passes after. --- agent/compaction/index.go | 23 +++++++++++++++-------- agent/compaction/index_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/agent/compaction/index.go b/agent/compaction/index.go index 2ef46162..b14169b1 100644 --- a/agent/compaction/index.go +++ b/agent/compaction/index.go @@ -78,16 +78,23 @@ func (index *MessageIndex) Update(messages []*message.Message) { return } processedMessageCount := index.includedRawMessageCount() - if index.lastProcessedMessage != nil && len(messages) >= processedMessageCount && messageContentEqual(messages[len(messages)-1], index.lastProcessedMessage) { - return - } - + // Locate where the already-processed prefix ends. lastProcessedMessage sits + // at index processedMessageCount-1 when nothing earlier was trimmed, so check + // that expected position first. Matching only the last occurrence of its + // content would latch the boundary onto a later duplicate when a message is + // repeated (e.g. a user re-sending "continue"), silently dropping the turns + // appended since. Fall back to a search only when the prefix was trimmed. foundIndex := -1 if index.lastProcessedMessage != nil { - for i := len(messages) - 1; i >= 0; i-- { - if messageContentEqual(messages[i], index.lastProcessedMessage) { - foundIndex = i - break + if expected := processedMessageCount - 1; expected >= 0 && expected < len(messages) && + messageContentEqual(messages[expected], index.lastProcessedMessage) { + foundIndex = expected + } else { + for i := len(messages) - 1; i >= 0; i-- { + if messageContentEqual(messages[i], index.lastProcessedMessage) { + foundIndex = i + break + } } } } diff --git a/agent/compaction/index_test.go b/agent/compaction/index_test.go index 02345f7e..bb728cb0 100644 --- a/agent/compaction/index_test.go +++ b/agent/compaction/index_test.go @@ -343,3 +343,30 @@ func splitWords(text string) func(func(string) bool) { } } } + +// A user re-sending an identical short message (or any repeated content) must +// not cause Update to drop the turns appended since the previous update. The +// processed-prefix boundary must be located positionally, not by matching the +// last occurrence of the previous message's content. +func TestMessageIndex_Update_RepeatedContentDoesNotDropMessages(t *testing.T) { + turn1 := []*message.Message{ + textMessage(message.RoleUser, "hi"), + textMessage(message.RoleAssistant, "hello"), + textMessage(message.RoleUser, "continue"), + } + index := &compaction.MessageIndex{} + index.Update(turn1) + if got, want := messageTexts(index.AllMessages()), []string{"hi", "hello", "continue"}; !slices.Equal(got, want) { + t.Fatalf("after turn 1: got %v, want %v", got, want) + } + + // Turn 2: the assistant replies, then the user repeats "continue" verbatim. + turn2 := append(slices.Clone(turn1), + textMessage(message.RoleAssistant, "sure"), + textMessage(message.RoleUser, "continue"), + ) + index.Update(turn2) + if got, want := messageTexts(index.AllMessages()), []string{"hi", "hello", "continue", "sure", "continue"}; !slices.Equal(got, want) { + t.Fatalf("after turn 2 (repeated \"continue\"): got %v, want %v — appended turns were dropped", got, want) + } +}