package agent import ( "bytes" "context" "encoding/json" "errors" "os" "path/filepath" "strings" "testing" "github.com/johannSo/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) } }