| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "reasonix/internal/event" |
| 10 | "reasonix/internal/provider" |
| 11 | "reasonix/internal/tool" |
| 12 | ) |
| 13 | |
| 14 | // A conversation rewind that only removes tail messages must keep the fold |
| 15 | // usable: the covered prefix is byte-identical, so the projection stays valid |
| 16 | // for the shorter transcript and the model-visible view keeps the compacted |
| 17 | // size instead of ballooning back to the pre-compaction transcript and |
| 18 | // re-paying a full summary. |
| 19 | func TestTailOnlyRewindKeepsCompactedView(t *testing.T) { |
| 20 | const window = 10_000 |
| 21 | big := strings.Repeat("line\n", 200) |
| 22 | msgs := []provider.Message{ |
| 23 | {Role: provider.RoleSystem, Content: "system"}, |
| 24 | {Role: provider.RoleUser, Content: "first task"}, |
| 25 | } |
| 26 | for i := range 40 { |
| 27 | id := fmt.Sprintf("old-%d", i) |
| 28 | msgs = append(msgs, |
| 29 | provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: "{}"}}}, |
| 30 | provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: big}, |
| 31 | ) |
| 32 | } |
| 33 | msgs = append(msgs, |
| 34 | provider.Message{Role: provider.RoleUser, Content: "recent question"}, |
| 35 | provider.Message{Role: provider.RoleAssistant, Content: "recent answer"}, |
| 36 | ) |
| 37 | sess := &Session{Messages: msgs} |
| 38 | a := New(&fakeProvider{reply: "structured digest"}, tool.NewRegistry(), sess, Options{ |
| 39 | ContextWindow: window, |
| 40 | CompactRatio: 0.80, |
| 41 | RecentKeep: 2, |
| 42 | ArchiveDir: t.TempDir(), |
| 43 | }, event.Discard) |
| 44 | |
| 45 | fold := a.compactTrigger() |
| 46 | before := estimateMessagesTokens(provider.ModelMessages(sess.Messages)) |
| 47 | if before < fold { |
| 48 | t.Fatalf("fixture estimates %d tokens, below the fold trigger %d", before, fold) |
| 49 | } |
| 50 | if err := a.CompactNow(context.Background(), ""); err != nil { |
| 51 | t.Fatalf("compact: %v", err) |
| 52 | } |
| 53 | if after := projectionTokens(a); after >= fold { |
| 54 | t.Fatalf("compacted view %d tokens still at or above fold %d", after, fold) |
| 55 | } |
| 56 | |
| 57 | a.sess.compactionMu.Lock() |
| 58 | saved := a.sess.compactionState |
| 59 | a.sess.compactionMu.Unlock() |
| 60 | canonical, _ := a.sess.conversation.snapshotMessagesVersion() |
| 61 | |
| 62 | // Rewind past the final exchange — a tail-only truncation. |
| 63 | boundary := len(canonical) - 2 |
| 64 | sess.Rewrite(canonical[:boundary], "rewind_truncate") |
| 65 | truncated, _ := a.sess.conversation.snapshotMessagesVersion() |
| 66 | |
| 67 | if !projectionContentValid(saved, truncated) { |
| 68 | t.Fatal("tail-only truncation must keep the projection valid") |
| 69 | } |
| 70 | view := provider.ModelMessages(modelVisibleFromProjection(saved.Projection, truncated)) |
| 71 | if after := estimateMessagesTokens(view); after >= fold { |
| 72 | t.Fatalf("spliced view %d tokens ballooned past the fold trigger %d", after, fold) |
| 73 | } |
| 74 | for _, m := range view { |
| 75 | if m.Content == "recent answer" || m.Content == "recent question" { |
| 76 | t.Fatal("rewound message still visible in the spliced view") |
| 77 | } |
| 78 | } |
| 79 | } |
| 80 |