Vectrum
Sign In
620 lines · 21.4 KB · Go
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)
	}
}