package agent
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"codey/internal/llm"
)
func TestCacheBreakpoints(t *testing.T) {
f := newFakeAPI(t, msg("end_turn", textBlock("hi")))
a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
if _, err := a.Run(context.Background(), "hello"); err != nil {
t.Fatalf("Run: %v", err)
}
req := f.requests[0]
if req.System[0].CacheControl == nil {
t.Error("the system prompt should carry a breakpoint: it and the tools never change")
}
last := req.Messages[len(req.Messages)-1]
if last.Content[len(last.Content)-1].CacheControl == nil {
t.Error("the end of the conversation should carry a rolling breakpoint")
}
raw := f.rawRequest(0)
if n := strings.Count(raw, `"cache_control"`); n != 2 {
t.Errorf("%d breakpoints in the body, want 2 (the limit is 4):\n%s", n, raw)
}
}
func TestNoCacheOption(t *testing.T) {
f := newFakeAPI(t, msg("end_turn", textBlock("hi")))
a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
a.opts.NoCache = true
if _, err := a.Run(context.Background(), "hello"); err != nil {
t.Fatalf("Run: %v", err)
}
if strings.Contains(f.rawRequest(0), "cache_control") {
t.Error("NoCache should send no breakpoints")
}
}
// Breakpoints must not mutate the stored history, or every turn would
// accumulate another one and blow past the four-breakpoint limit.
func TestBreakpointsDoNotLeakIntoHistory(t *testing.T) {
f := newFakeAPI(t,
msg("tool_use", toolUseBlock("tu_1", "boom", `{}`)),
msg("tool_use", toolUseBlock("tu_2", "boom", `{}`)),
msg("end_turn", textBlock("done")),
)
a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
if _, err := a.Run(context.Background(), "go"); err != nil {
t.Fatalf("Run: %v", err)
}
for i := range f.requests {
if n := strings.Count(f.rawRequest(i), `"cache_control"`); n != 2 {
t.Errorf("request %d has %d breakpoints, want 2", i, n)
}
}
for _, m := range a.State().History() {
for _, b := range m.Content {
if b.CacheControl != nil {
t.Error("stored history should stay free of breakpoints")
}
}
}
}
// mkHistory turns "uaua" into alternating user/assistant messages, so a test
// can say what shape of conversation it means in one string.
func mkHistory(roles string) []llm.Message {
var out []llm.Message
for _, r := range roles {
role := llm.RoleUser
if r == 'a' {
role = llm.RoleAssistant
}
out = append(out, llm.Message{Role: role, Content: []llm.ContentBlock{llm.TextBlock("x")}})
}
return out
}
func TestCompactionSplitLandsOnAnAssistantMessage(t *testing.T) {
tests := []struct {
name string
roles string
keep int
want int
}{
{name: "too short to compact", roles: "uaua", keep: keepRecentMessages, want: -1},
{name: "cuts at the first assistant past the midpoint", roles: "uauauauauaua", keep: keepRecentMessages, want: 7},
// The tail can end up one message shorter than the target when the
// boundary lands on a user message: correctness beats the constant.
{name: "skips forward past a user message", roles: "uauauauaua", keep: keepRecentMessages, want: 5},
// The smaller keep is what the floor uses: it folds conversations the
// normal split refuses to touch, because at 97% doing nothing is worse
// than keeping a short tail.
{name: "a short history the normal keep declines", roles: "uauaua", keep: keepUrgentMessages, want: 3},
{name: "urgently, four messages can still be cut", roles: "uaua", keep: keepUrgentMessages, want: 3},
{name: "nothing is foldable even urgently", roles: "ua", keep: keepUrgentMessages, want: -1},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := compactionSplit(mkHistory(tc.roles), tc.keep)
if got != tc.want {
t.Fatalf("compactionSplit = %d, want %d", got, tc.want)
}
if got >= 0 && mkHistory(tc.roles)[got].Role != llm.RoleAssistant {
t.Error("the kept tail must start with an assistant message, or its tool_results reference nothing")
}
})
}
}
func TestCompactionFoldsHistoryAndKeepsTheTask(t *testing.T) {
// Six tool-calling turns, then a compaction, then the answer.
var script []string
for i := 0; i < 6; i++ {
script = append(script, msgWithTokens("tool_use", 100, toolUseBlock("tu", "boom", `{}`)))
}
script = append(script, msgWithTokens("end_turn", 100, textBlock("all done")))
f := newFakeAPI(t, script...)
var logBuf bytes.Buffer
a := newTestAgent(t, f, t.TempDir(), &logBuf)
a.opts.ContextWindow = 1000 // 70% of 1000, and each fake turn reports 100
a.opts.CompactAt = 0.1
var compacted int
a.Observe = func(ev Event) {
if ev.Kind == EventCompact {
compacted++
}
}
if _, err := a.Run(context.Background(), "fix the failing test in loop.go"); err != nil {
t.Fatalf("Run: %v", err)
}
if compacted == 0 {
t.Fatal("expected a compaction")
}
history := a.State().History()
if history[0].Role != llm.RoleUser {
t.Fatal("history must start with a user message")
}
folded := history[0].Content[0].Text
for _, want := range []string{"[Earlier conversation, compacted]", "fix the failing test in loop.go", "the config loads from X not Y"} {
if !strings.Contains(folded, want) {
t.Errorf("folded message missing %q:\n%s", want, folded)
}
}
if history[1].Role != llm.RoleAssistant {
t.Errorf("message after the summary is %q, want assistant", history[1].Role)
}
if !strings.Contains(logBuf.String(), `"type":"compaction"`) {
t.Error("compaction should be logged; it is a cost and cache event")
}
// The summarization call must not be able to touch the repository.
for i, req := range f.requests {
if len(req.Tools) == 0 && len(req.Messages) > 0 {
if req.MaxTokens != 4096 {
t.Errorf("request %d looks like the compaction call but has max_tokens %d", i, req.MaxTokens)
}
}
}
}
// TestSummarizeCarriesSystemPrefixWhenSet guards the compaction bug: the
// summarization request is built by hand, outside (*Agent).request, so it is
// the one path that can silently omit the OAuth identity block. An omitted
// block does not 401 — it 429s, which the client retries three times before
// giving up, so a long OAuth session would die at the compaction threshold
// looking like a quota problem.
func TestSummarizeCarriesSystemPrefixWhenSet(t *testing.T) {
f := newFakeAPI(t)
a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
a.opts.SystemPrefix = ClaudeCodeIdentity
history := []llm.Message{{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock("hello")}}}
if _, err := a.summarize(context.Background(), history); err != nil {
t.Fatalf("summarize: %v", err)
}
if len(f.requests) != 1 {
t.Fatalf("expected 1 request, got %d", len(f.requests))
}
got := f.requests[0].System
if len(got) != 1 || got[0].Text != ClaudeCodeIdentity {
t.Errorf("System = %+v, want a single block with the Claude Code identity", got)
}
}
// TestSummarizeCarriesNoSystemWhenPrefixUnset is the other half: a session
// that never borrowed the Claude Code login must not gain a system block it
// never asked for.
func TestSummarizeCarriesNoSystemWhenPrefixUnset(t *testing.T) {
f := newFakeAPI(t)
a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
history := []llm.Message{{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock("hello")}}}
if _, err := a.summarize(context.Background(), history); err != nil {
t.Fatalf("summarize: %v", err)
}
if len(f.requests) != 1 {
t.Fatalf("expected 1 request, got %d", len(f.requests))
}
if got := f.requests[0].System; len(got) != 0 {
t.Errorf("System = %+v, want none when SystemPrefix is unset", got)
}
}
func TestStripTrailingToolUse(t *testing.T) {
msgs := []llm.Message{
{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock("go")}},
{Role: llm.RoleAssistant, Content: []llm.ContentBlock{{Type: llm.BlockToolUse, ID: "tu", Name: "x"}}},
}
if got := stripTrailingToolUse(msgs); len(got) != 1 {
t.Errorf("a dangling tool_use must be dropped, got %d messages", len(got))
}
keep := []llm.Message{
{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock("go")}},
{Role: llm.RoleAssistant, Content: []llm.ContentBlock{llm.TextBlock("done")}},
}
if got := stripTrailingToolUse(keep); len(got) != 2 {
t.Errorf("a text-only assistant turn should be kept, got %d", len(got))
}
}
func TestSupersedeStaleReads(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "a.go")
os.WriteFile(path, []byte("package a\n\nconst X = 1\n"), 0o644)
f := newFakeAPI(t,
msg("tool_use", toolUseBlock("tu_1", "read_file", `{"path":"a.go"}`)),
msg("tool_use", toolUseBlock("tu_2", "read_file", `{"path":"a.go"}`)),
msg("tool_use", toolUseBlock("tu_3", "edit_file", `{"path":"a.go","old_str":"const X = 1","new_str":"const X = 2"}`)),
msg("end_turn", textBlock("done")),
)
var logBuf bytes.Buffer
a := newTestAgent(t, f, root, &logBuf)
if _, err := a.Run(context.Background(), "bump X"); err != nil {
t.Fatalf("Run: %v", err)
}
var superseded, intact int
for _, m := range a.State().History() {
for _, b := range m.Content {
if b.Type != llm.BlockToolResult {
continue
}
switch {
case strings.HasPrefix(b.Content, "[superseded:"):
superseded++
// Match the numbered read format, so the edit's own diff — which
// quotes the old line — is not counted as a surviving read.
case strings.Contains(b.Content, "\tconst X = 1"):
intact++
}
}
}
if superseded != 1 {
t.Errorf("%d results superseded, want 1 (the earlier of two reads)", superseded)
}
if intact != 1 {
t.Errorf("%d intact reads, want 1 (the most recent read stays)", intact)
}
if !strings.Contains(logBuf.String(), `"type":"supersede_reads"`) {
t.Error("superseding should be logged")
}
}
func TestSupersedeLeavesASingleReadAlone(t *testing.T) {
root := t.TempDir()
os.WriteFile(filepath.Join(root, "a.go"), []byte("package a\n\nconst X = 1\n"), 0o644)
f := newFakeAPI(t,
msg("tool_use", toolUseBlock("tu_1", "read_file", `{"path":"a.go"}`)),
msg("tool_use", toolUseBlock("tu_2", "edit_file", `{"path":"a.go","old_str":"const X = 1","new_str":"const X = 2"}`)),
msg("end_turn", textBlock("done")),
)
a := newTestAgent(t, f, root, &bytes.Buffer{})
if _, err := a.Run(context.Background(), "bump X"); err != nil {
t.Fatalf("Run: %v", err)
}
for _, m := range a.State().History() {
for _, b := range m.Content {
if strings.HasPrefix(b.Content, "[superseded:") {
t.Error("the only read of a file is still the current one; it must not be blanked")
}
}
}
}
func TestReadPathParsing(t *testing.T) {
if got := readPath(json.RawMessage(`{"path":"x/y.go","offset":3}`)); got != "x/y.go" {
t.Errorf("readPath = %q", got)
}
if got := readPath(json.RawMessage(`not json`)); got != "" {
t.Errorf("readPath = %q, want empty on malformed input", got)
}
}
// TestCompactionFloor covers the case the 70% rule cannot reach: a history too
// short for the normal split — a handful of enormous tool results — that has
// nevertheless filled the window. Below the floor the normal split declines and
// nothing happens; at the floor the smaller keep folds it anyway.
func TestCompactionFloor(t *testing.T) {
tests := []struct {
name string
inputTokens int
want bool
}{
{name: "past CompactAt but under the floor, the normal split declines", inputTokens: 800, want: false},
{name: "at the floor the smaller keep folds it", inputTokens: 980, want: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
f := newFakeAPI(t)
a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
a.opts.ContextWindow = 1000
// Six messages: more than keepUrgentMessages+2, fewer than
// keepRecentMessages+2, so the two keeps disagree about it.
a.state.Replace(mkHistory("uauaua"))
got, err := a.maybeCompact(context.Background(), tc.inputTokens)
if err != nil {
t.Fatalf("maybeCompact: %v", err)
}
if got != tc.want {
t.Fatalf("maybeCompact = %v, want %v", got, tc.want)
}
if !tc.want && len(f.requests) != 0 {
t.Errorf("declining to compact should cost no model call, got %d", len(f.requests))
}
})
}
}
// TestCompactionFloorWarnsWhenNothingIsFoldable is the case where the window is
// nearly full and there is genuinely nothing to fold. Saying so beats letting
// the next request hit the wall and surface as a bare API error.
func TestCompactionFloorWarnsWhenNothingIsFoldable(t *testing.T) {
f := newFakeAPI(t)
a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
a.opts.ContextWindow = 1000
a.state.Replace(mkHistory("ua")) // nothing either keep will cut
var notices []string
a.Observe = func(ev Event) {
if ev.Kind == EventNotice {
notices = append(notices, ev.Text)
}
}
got, err := a.maybeCompact(context.Background(), 980)
if err != nil {
t.Fatalf("maybeCompact: %v", err)
}
if got {
t.Fatal("there was nothing to fold; maybeCompact should report false")
}
if len(notices) != 1 {
t.Fatalf("%d notices, want 1 warning that the window is nearly full", len(notices))
}
if !strings.Contains(notices[0], "97%") {
t.Errorf("the warning should name the threshold it crossed: %q", notices[0])
}
if len(f.requests) != 0 {
t.Errorf("nothing to fold should cost no model call, got %d", len(f.requests))
}
}
// TestCompactionFloorNeverSitsBelowCompactAt: -compact-at above the floor would
// otherwise make every ordinary compaction take the aggressive keep and throw
// away the recent tail the model is still working from.
func TestCompactionFloorNeverSitsBelowCompactAt(t *testing.T) {
f := newFakeAPI(t)
a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
a.opts.ContextWindow = 1000
a.opts.CompactAt = 0.99
a.opts.CompactFloor = 0.5
a.state.Replace(mkHistory("uauaua"))
if got, err := a.maybeCompact(context.Background(), 600); err != nil || got {
t.Fatalf("maybeCompact = %v, %v; 600/1000 is under CompactAt, so nothing should happen", got, err)
}
}
// TestCompactFoldsOnDemand is /compact: no threshold involved, and the recent
// tail is kept exactly as an automatic compaction would keep it.
func TestCompactFoldsOnDemand(t *testing.T) {
f := newFakeAPI(t)
a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
a.state.Replace(mkHistory("uauauauauaua")) // 12 messages
var compacted int
a.Observe = func(ev Event) {
if ev.Kind == EventCompact {
compacted++
}
}
got, err := a.Compact(context.Background())
if err != nil {
t.Fatalf("Compact: %v", err)
}
if !got {
t.Fatal("Compact should report that it folded the conversation")
}
if compacted != 1 {
t.Errorf("%d compaction events, want 1", compacted)
}
history := a.State().History()
if n := len(history); n != 6 {
t.Fatalf("history has %d messages, want 6: the summary plus the kept tail", n)
}
if !strings.Contains(history[0].Content[0].Text, "[Earlier conversation, compacted]") {
t.Errorf("history should open with the folded summary, got %q", history[0].Content[0].Text)
}
if history[1].Role != llm.RoleAssistant {
t.Errorf("the kept tail starts with %q, want assistant", history[1].Role)
}
}
// TestCompactOnDemandDeclinesAShortConversation: /compact on a conversation
// with nothing worth folding should say so rather than spend a model call
// summarizing three messages.
func TestCompactOnDemandDeclinesAShortConversation(t *testing.T) {
f := newFakeAPI(t)
a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
a.state.Replace(mkHistory("uaua"))
got, err := a.Compact(context.Background())
if err != nil {
t.Fatalf("Compact: %v", err)
}
if got {
t.Error("Compact should report false when there is nothing to fold")
}
if len(f.requests) != 0 {
t.Errorf("declining should cost no model call, got %d", len(f.requests))
}
}
// With auto-compaction off Codey must not fold anything on its own, even
// well past the ordinary threshold.
func TestAutoCompactOffNeverFolds(t *testing.T) {
f := newFakeAPI(t)
a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
a.opts.ContextWindow = 1000
a.opts.NoAutoCompact = true
a.state.Replace(mkHistory("uauauauauaua"))
did, err := a.maybeCompact(context.Background(), 900) // 90%: past CompactAt
if err != nil {
t.Fatalf("maybeCompact: %v", err)
}
if did {
t.Error("auto-compaction is off; nothing should have been folded")
}
if len(f.requests) != 0 {
t.Errorf("no model call should have been made, got %d", len(f.requests))
}
if n := a.state.Len(); n != 12 {
t.Errorf("history has %d messages, want the original 12", n)
}
}
// Off, the floor stops the turn instead of folding: sending a request that
// cannot fit only trades a clear message for an opaque API error.
func TestAutoCompactOffStopsAtTheFloor(t *testing.T) {
f := newFakeAPI(t)
a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
a.opts.ContextWindow = 1000
a.opts.NoAutoCompact = true
a.state.Replace(mkHistory("uauauauauaua"))
_, err := a.maybeCompact(context.Background(), 980)
var full *ContextFullError
if !errors.As(err, &full) {
t.Fatalf("maybeCompact error = %v, want a *ContextFullError", err)
}
if full.Used != 980 || full.Window != 1000 {
t.Errorf("error carries %d of %d, want 980 of 1000", full.Used, full.Window)
}
if !strings.Contains(full.Error(), "98%") {
t.Errorf("the message should name how full the window is: %q", full.Error())
}
}
// The prompt must not join the history on a turn that never runs, or compacting
// and retrying would send it twice.
func TestAutoCompactOffRefusesBeforeTheHistoryGrows(t *testing.T) {
f := newFakeAPI(t)
a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
a.opts.ContextWindow = 1000
a.opts.NoAutoCompact = true
a.state.Replace(mkHistory("uaua"))
a.lastInputTokens = 980
_, err := a.Run(context.Background(), "carry on")
var full *ContextFullError
if !errors.As(err, &full) {
t.Fatalf("Run error = %v, want a *ContextFullError", err)
}
if n := a.state.Len(); n != 4 {
t.Errorf("history has %d messages, want the original 4: the refused prompt must not be kept", n)
}
if len(f.requests) != 0 {
t.Errorf("a refused turn should reach the API not at all, got %d requests", len(f.requests))
}
}
// /compact is the way out of a full window, so the setting that stops the
// automatic fold must not disable the manual one too.
func TestCompactWorksWithAutoCompactOff(t *testing.T) {
f := newFakeAPI(t)
a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
a.opts.ContextWindow = 1000
a.opts.NoAutoCompact = true
a.lastInputTokens = 980
a.state.Replace(mkHistory("uauauauauaua"))
did, err := a.Compact(context.Background())
if err != nil {
t.Fatalf("Compact: %v", err)
}
if !did {
t.Error("/compact must still fold when auto-compaction is off")
}
}
// Under the floor with auto-compaction off, an ordinary turn is untouched.
func TestAutoCompactOffLetsAnOrdinaryTurnThrough(t *testing.T) {
f := newFakeAPI(t, msg("end_turn", textBlock("fine")))
a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
a.opts.ContextWindow = 1000
a.opts.NoAutoCompact = true
a.lastInputTokens = 500
if _, err := a.Run(context.Background(), "carry on"); err != nil {
t.Fatalf("Run: %v", err)
}
}
// The dead end found by running it: auto-compaction off, four enormous
// messages, window full. The turn is refused, and if /compact also declined
// there would be nothing left but /clear — so past the floor the manual fold
// takes the same smaller tail the automatic one would have taken.
func TestCompactFoldsAShortHistoryWhenTheWindowIsFull(t *testing.T) {
f := newFakeAPI(t)
a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
a.opts.ContextWindow = 1000
a.opts.NoAutoCompact = true
a.lastInputTokens = 980
a.state.Replace(mkHistory("uaua"))
did, err := a.Compact(context.Background())
if err != nil {
t.Fatalf("Compact: %v", err)
}
if !did {
t.Fatal("/compact must fold when it is the only way out of a full window")
}
history := a.State().History()
if len(history) >= 4 {
t.Errorf("history is still %d messages; nothing was folded away", len(history))
}
if history[len(history)-1].Role != llm.RoleAssistant {
t.Error("the kept tail must still start and end on a valid boundary")
}
}
// Below the floor the same short conversation is left alone: folding three
// messages costs a model call and buys nothing.
func TestCompactStillDeclinesAShortHistoryWithRoomLeft(t *testing.T) {
f := newFakeAPI(t)
a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
a.opts.ContextWindow = 1000
a.lastInputTokens = 100
a.state.Replace(mkHistory("uaua"))
did, err := a.Compact(context.Background())
if err != nil {
t.Fatalf("Compact: %v", err)
}
if did {
t.Error("with the window nearly empty there is nothing worth folding")
}
if len(f.requests) != 0 {
t.Errorf("declining should cost no model call, got %d", len(f.requests))
}
}
// The wedge found by running it: /compact folded the history, but the recorded
// input size still described the conversation as it was before, so the next
// turn was refused on a number that had stopped being true — and no amount of
// compacting could ever clear it.
func TestCompactingUnwedgesAFullWindow(t *testing.T) {
f := newFakeAPI(t, msg("end_turn", textBlock("working again")))
a := newTestAgent(t, f, t.TempDir(), &bytes.Buffer{})
a.opts.ContextWindow = 1000
a.opts.NoAutoCompact = true
a.lastInputTokens = 980
a.state.Replace(mkHistory("uauauauauaua"))
if _, err := a.Run(context.Background(), "refused"); err == nil {
t.Fatal("the turn should be refused while the window is full")
}
if did, err := a.Compact(context.Background()); err != nil || !did {
t.Fatalf("Compact = %v, %v; want it to fold", did, err)
}
if _, err := a.Run(context.Background(), "and now?"); err != nil {
t.Fatalf("the turn after a compaction must go through, got %v", err)
}
}