Files
kit/internal/session/compaction_test.go
Ed Zynda f561f4cfd9 fix(session): order kept messages before post-compact branch in BuildContext
After /compact, BuildContext emitted [summary, post-compact, kept]
which placed an older kept user/assistant turn after the latest
post-compaction turn. This broke user/assistant alternation and caused
the model to respond as if the post-compaction turn never happened on
the next user message.

- Emit kept messages chronologically before post-compaction messages
- Mirror the same order in GetContextEntryIDs so cut-point to entry-ID
  mapping stays aligned across repeat compactions
- Update TestCompactionWithNewMessagesAfterCompaction to assert the
  correct chronological order
2026-05-14 20:42:20 +03:00

327 lines
12 KiB
Go

package session
import (
"slices"
"testing"
"charm.land/fantasy"
"github.com/mark3labs/kit/internal/message"
)
// TestCompactionCreatesNewLeaf verifies that after compaction, the compaction
// entry has no parent (creating a new root), and BuildContext returns only
// the summary and kept messages, not the old compacted messages.
func TestCompactionCreatesNewLeaf(t *testing.T) {
tm := InMemoryTreeSession("/test")
// Add some messages: M1, M2 (old, will be compacted), M3, M4 (kept)
msg1 := message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "Message 1 - old"}}}
msg2 := message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "Message 2 - old"}}}
msg3 := message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "Message 3 - kept"}}}
msg4 := message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "Message 4 - kept"}}}
_, _ = tm.AppendMessage(msg1)
_, _ = tm.AppendMessage(msg2)
id3, _ := tm.AppendMessage(msg3)
id4, _ := tm.AppendMessage(msg4)
// Verify initial state - all messages should be in context
messages, _, _ := tm.BuildContext()
if len(messages) != 4 {
t.Fatalf("expected 4 messages before compaction, got %d", len(messages))
}
// Verify entry IDs
entryIDs := tm.GetContextEntryIDs()
if len(entryIDs) != 4 {
t.Fatalf("expected 4 entry IDs before compaction, got %d", len(entryIDs))
}
// Now add a compaction entry, simulating that M3 is the first kept entry
summary := "Summary of old messages"
compactionID, err := tm.AppendCompaction(summary, id3, 1000, 500, 2, []string{}, []string{})
if err != nil {
t.Fatalf("failed to append compaction: %v", err)
}
// Verify the compaction entry has no parent (empty ParentID)
compactionEntry := tm.GetEntry(compactionID).(*CompactionEntry)
if compactionEntry.ParentID != "" {
t.Errorf("compaction entry should have no parent, got %q", compactionEntry.ParentID)
}
// Verify the leaf is now the compaction entry
if tm.GetLeafID() != compactionID {
t.Errorf("leaf should be compaction entry %q, got %q", compactionID, tm.GetLeafID())
}
// Now BuildContext should return: [summary] + [M3, M4]
messages, _, _ = tm.BuildContext()
if len(messages) != 3 {
t.Fatalf("expected 3 messages after compaction (summary + 2 kept), got %d", len(messages))
}
// First message should be the summary
if messages[0].Role != fantasy.MessageRoleSystem {
t.Errorf("first message should be system summary, got %s", messages[0].Role)
}
summaryText := messages[0].Content[0].(fantasy.TextPart).Text
if summaryText != "[Conversation summary — earlier messages were compacted]\n\n"+summary {
t.Errorf("unexpected summary text: %s", summaryText)
}
// Second message should be M3 (kept)
if messages[1].Role != fantasy.MessageRoleUser {
t.Errorf("second message should be user (M3), got %s", messages[1].Role)
}
m3Text := messages[1].Content[0].(fantasy.TextPart).Text
if m3Text != "Message 3 - kept" {
t.Errorf("unexpected M3 text: %s", m3Text)
}
// Third message should be M4 (kept)
if messages[2].Role != fantasy.MessageRoleAssistant {
t.Errorf("third message should be assistant (M4), got %s", messages[2].Role)
}
m4Text := messages[2].Content[0].(fantasy.TextPart).Text
if m4Text != "Message 4 - kept" {
t.Errorf("unexpected M4 text: %s", m4Text)
}
// Verify GetContextEntryIDs returns correct IDs
entryIDs = tm.GetContextEntryIDs()
if len(entryIDs) != 3 {
t.Fatalf("expected 3 entry IDs after compaction (empty for summary + 2 kept), got %d: %v", len(entryIDs), entryIDs)
}
// First entry ID should be empty (summary has no entry)
if entryIDs[0] != "" {
t.Errorf("first entry ID should be empty (summary), got %q", entryIDs[0])
}
// Second and third should be id3 and id4 (the kept messages)
if entryIDs[1] != id3 {
t.Errorf("second entry ID should be %q (M3), got %q", id3, entryIDs[1])
}
if entryIDs[2] != id4 {
t.Errorf("third entry ID should be %q (M4), got %q", id4, entryIDs[2])
}
}
// TestCompactionWithNewMessagesAfterCompaction verifies that messages appended
// after compaction are correctly included in the context.
func TestCompactionWithNewMessagesAfterCompaction(t *testing.T) {
tm := InMemoryTreeSession("/test")
// Add initial messages
msg1 := message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "Message 1"}}}
msg2 := message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "Message 2"}}}
msg3 := message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "Message 3 - kept"}}}
_, _ = tm.AppendMessage(msg1)
_, _ = tm.AppendMessage(msg2)
id3, _ := tm.AppendMessage(msg3)
// Compact, keeping only M3
_, _ = tm.AppendCompaction("Summary", id3, 1000, 500, 2, []string{}, []string{})
// Add a new message after compaction
msg4 := message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "Message 4 - after compaction"}}}
_, _ = tm.AppendMessage(msg4)
// BuildContext should return: [summary] + [M3 (kept)] + [M4 (new after compaction)]
// Kept messages must appear BEFORE post-compaction messages so the LLM
// sees the conversation in chronological order. Otherwise the latest
// post-compaction user message would be followed by an older kept user
// message, breaking user/assistant alternation and causing the model to
// respond as if the post-compaction turn never happened.
messages, _, _ := tm.BuildContext()
if len(messages) != 3 {
t.Fatalf("expected 3 messages (summary + M3 + M4), got %d: %+v", len(messages), messages)
}
// Verify order: summary, M3 (kept), M4 (new)
if messages[0].Role != fantasy.MessageRoleSystem {
t.Errorf("first message should be summary, got %s", messages[0].Role)
}
if messages[1].Role != fantasy.MessageRoleUser {
t.Errorf("second message should be user (M3 kept), got %s", messages[1].Role)
}
m3Text := messages[1].Content[0].(fantasy.TextPart).Text
if m3Text != "Message 3 - kept" {
t.Errorf("unexpected M3 text: %s", m3Text)
}
if messages[2].Role != fantasy.MessageRoleAssistant {
t.Errorf("third message should be assistant (M4 post-compact), got %s", messages[2].Role)
}
m4Text := messages[2].Content[0].(fantasy.TextPart).Text
if m4Text != "Message 4 - after compaction" {
t.Errorf("unexpected M4 text: %s", m4Text)
}
// Verify that M1 is NOT in the context
for i, msg := range messages {
if msg.Role == fantasy.MessageRoleUser {
text := msg.Content[0].(fantasy.TextPart).Text
if text == "Message 1" {
t.Errorf("Message 1 (compacted) should not be in context at index %d", i)
}
}
}
}
// TestCompactionWithNoKeptMessages verifies compaction when all messages are compacted.
func TestCompactionWithNoKeptMessages(t *testing.T) {
tm := InMemoryTreeSession("/test")
// Add messages that will all be compacted
msg1 := message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "Message 1"}}}
msg2 := message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "Message 2"}}}
if _, err := tm.AppendMessage(msg1); err != nil {
t.Fatalf("failed to append message: %v", err)
}
if _, err := tm.AppendMessage(msg2); err != nil {
t.Fatalf("failed to append message: %v", err)
}
// Compact with no kept messages (empty firstKeptEntryID)
summary := "All messages summarized"
compactionID, _ := tm.AppendCompaction(summary, "", 1000, 100, 2, []string{}, []string{})
// Verify the compaction entry has no parent
compactionEntry := tm.GetEntry(compactionID).(*CompactionEntry)
if compactionEntry.ParentID != "" {
t.Errorf("compaction entry should have no parent, got %q", compactionEntry.ParentID)
}
// BuildContext should return only the summary
messages, _, _ := tm.BuildContext()
if len(messages) != 1 {
t.Fatalf("expected 1 message (summary only), got %d: %+v", len(messages), messages)
}
if messages[0].Role != fantasy.MessageRoleSystem {
t.Errorf("message should be system summary, got %s", messages[0].Role)
}
}
// TestMultipleCompactions verifies that multiple compactions work correctly.
func TestMultipleCompactions(t *testing.T) {
tm := InMemoryTreeSession("/test")
// First batch of messages
msg1 := message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "Batch 1 - User"}}}
msg2 := message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "Batch 1 - Assistant"}}}
id1, _ := tm.AppendMessage(msg1)
id2, _ := tm.AppendMessage(msg2)
// First compaction
_, _ = tm.AppendCompaction("Summary 1", id1, 1000, 500, 1, []string{}, []string{})
// Second batch
msg3 := message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "Batch 2 - User"}}}
msg4 := message.Message{Role: message.RoleAssistant, Parts: []message.ContentPart{message.TextContent{Text: "Batch 2 - Assistant"}}}
id3, _ := tm.AppendMessage(msg3)
id4, _ := tm.AppendMessage(msg4)
// Second compaction (compacting the first compaction + batch 2)
// Note: id3 is the first kept entry, so id3 and id4 should be preserved
compactionID2, _ := tm.AppendCompaction("Summary 2", id3, 1000, 500, 3, []string{}, []string{})
// Verify second compaction has no parent
compactionEntry2 := tm.GetEntry(compactionID2).(*CompactionEntry)
if compactionEntry2.ParentID != "" {
t.Errorf("second compaction entry should have no parent, got %q", compactionEntry2.ParentID)
}
// Add final message
msg5 := message.Message{Role: message.RoleUser, Parts: []message.ContentPart{message.TextContent{Text: "Final message"}}}
id5, _ := tm.AppendMessage(msg5)
// BuildContext should include:
// - Summary 2 (from second compaction)
// - msg5 (final message)
// - msg3, msg4 (kept from second compaction)
// But NOT Summary 1 or msg1, msg2 (they're before the first kept entry of compaction 2)
messages, _, _ := tm.BuildContext()
// Should have: Summary 2 + msg5 + msg3 + msg4 = 4 messages
if len(messages) != 4 {
t.Fatalf("expected 4 messages (Summary 2 + msg5 + msg3 + msg4), got %d: %+v", len(messages), messages)
}
// First should be Summary 2
if messages[0].Role != fantasy.MessageRoleSystem {
t.Errorf("first message should be system (Summary 2), got %s", messages[0].Role)
}
summaryText := messages[0].Content[0].(fantasy.TextPart).Text
if summaryText != "[Conversation summary — earlier messages were compacted]\n\nSummary 2" {
t.Errorf("unexpected summary: %s", summaryText)
}
// Verify msg5 is included
foundFinal := false
for _, msg := range messages {
if msg.Role == fantasy.MessageRoleUser {
text := msg.Content[0].(fantasy.TextPart).Text
if text == "Final message" {
foundFinal = true
break
}
}
}
if !foundFinal {
t.Error("Final message (msg5) should be in context")
}
// Verify msg1, msg2 are NOT included (compacted by first compaction, then second)
for _, msg := range messages {
if msg.Role == fantasy.MessageRoleUser || msg.Role == fantasy.MessageRoleAssistant {
text := msg.Content[0].(fantasy.TextPart).Text
if text == "Batch 1 - User" || text == "Batch 1 - Assistant" {
t.Errorf("Batch 1 messages should not be in context, found: %s", text)
}
}
}
// Verify entry IDs
entryIDs := tm.GetContextEntryIDs()
if len(entryIDs) != 4 {
t.Fatalf("expected 4 entry IDs, got %d: %v", len(entryIDs), entryIDs)
}
// First should be empty (summary)
if entryIDs[0] != "" {
t.Errorf("first entry ID should be empty (summary), got %q", entryIDs[0])
}
// Check that id5 is in the list
if !slices.Contains(entryIDs, id5) {
t.Errorf("id5 (final message) should be in entry IDs, got %v", entryIDs)
}
// Verify id3 and id4 ARE in the list (they were kept)
foundID3, foundID4 := false, false
for _, id := range entryIDs {
if id == id3 {
foundID3 = true
}
if id == id4 {
foundID4 = true
}
}
if !foundID3 {
t.Errorf("id3 (kept message) should be in entry IDs, got %v", entryIDs)
}
if !foundID4 {
t.Errorf("id4 (kept message) should be in entry IDs, got %v", entryIDs)
}
// Verify id1 and id2 are NOT in the list (they were compacted away)
for _, id := range entryIDs {
if id == id1 || id == id2 {
t.Errorf("id1 or id2 (compacted) should not be in entry IDs, found %q in %v", id, entryIDs)
}
}
}