package agent import ( "bytes" "context" "encoding/json" "os" "path/filepath" "strings" "testing" "harness/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") } } } } func TestCompactionSplitLandsOnAnAssistantMessage(t *testing.T) { mk := func(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 } tests := []struct { name string roles string want int }{ {name: "too short to compact", roles: "uaua", want: -1}, {name: "cuts at the first assistant past the midpoint", roles: "uauauauauaua", 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", want: 5}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := compactionSplit(mk(tc.roles)) if got != tc.want { t.Fatalf("compactionSplit = %d, want %d", got, tc.want) } if got >= 0 && mk(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) } } } } 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) } }