Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions agent/compaction/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines 80 to +84

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct that the positional fast-path (processedMessageCount-1) won't match once a summary group is inserted, since includedRawMessageCount() excludes GroupKindSummary. That case is handled: when the expected position doesn't match, the code falls back to the backward content search — i.e. the original pre-fix behavior — so correctness is preserved; the positional check is purely a fast-path/robustness improvement for the common (untrimmed, unsummarized) case that also fixes the repeated-content boundary bug. Making the fast-path summary-aware would be a further optimization but isn't needed for correctness.

// 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
}
}
}
}
Expand Down
27 changes: 27 additions & 0 deletions agent/compaction/index_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}