Files
gadfly/cmd/gadfly/engine_test.go
T
steveandClaude Opus 4.8 c342bdb905
Build & push image / build-and-push (push) Successful in 15s
feat: add claude-code/opus reviewer + max-thinking spec support (#5)
Adds claude-code/opus to gadfly's dogfood swarm (both sonnet and opus run
end-to-end), bumps the image pin to :sha-80d8f53 so the clean-lens
telemetry fix is live, and adds engine support for a
"claude-code/<model>:max" extended-thinking spec (MAX_THINKING_TOKENS,
best-effort). Validated: only 13 findings on this clean PR vs 43 on the
comparable #4 — the telemetry fix works.

Folded in the swarm's two real findings: a runPass env-injection test and
keeping MAX_THINKING_TOKENS in claudeEnv. Follow-up enables
claude-code/opus:max once this image builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: Steve Dudenhoeffer <[email protected]>
Co-committed-by: Steve Dudenhoeffer <[email protected]>
2026-06-27 22:39:14 +00:00

282 lines
9.9 KiB
Go

package main
import (
"context"
"os"
"slices"
"strings"
"testing"
"unicode/utf8"
)
func TestIsClaudeCodeSpec(t *testing.T) {
cases := map[string]bool{
"claude-code": true,
"claude-code/sonnet": true,
"claude-code/opus": true,
"claude-code/claude-opus-4-8": true,
" claude-code ": true, // trimmed
"qwen3-coder:480b-cloud": false,
"claude-code-extra": false, // not the bare id, not a "/" form
"sonnet": false,
"": false,
}
for spec, want := range cases {
if got := isClaudeCodeSpec(spec); got != want {
t.Errorf("isClaudeCodeSpec(%q) = %v, want %v", spec, got, want)
}
}
}
func TestNewClaudeCodeEngineModel(t *testing.T) {
// model derived from the spec's "/<model>" suffix
t.Setenv("GADFLY_CLAUDE_MODEL", "")
if e := newClaudeCodeEngine("claude-code/sonnet", "/repo"); e.model != "sonnet" {
t.Errorf("model = %q, want sonnet", e.model)
}
// bare spec → CLI default (no --model)
if e := newClaudeCodeEngine("claude-code", "/repo"); e.model != "" {
t.Errorf("model = %q, want empty for bare spec", e.model)
}
// GADFLY_CLAUDE_MODEL overrides the spec suffix
t.Setenv("GADFLY_CLAUDE_MODEL", "opus")
if e := newClaudeCodeEngine("claude-code/sonnet", "/repo"); e.model != "opus" {
t.Errorf("model = %q, want opus (env override)", e.model)
}
}
func TestClaudeCodeEngineDefaults(t *testing.T) {
t.Setenv("GADFLY_CLAUDE_BIN", "")
t.Setenv("GADFLY_CLAUDE_PERMISSION_MODE", "")
t.Setenv("GADFLY_CLAUDE_ALLOWED_TOOLS", "")
t.Setenv("GADFLY_CLAUDE_EXTRA_ARGS", "")
e := newClaudeCodeEngine("claude-code", "/repo")
if e.bin != "claude" {
t.Errorf("bin = %q, want claude", e.bin)
}
if e.permissionMode != "plan" {
t.Errorf("permissionMode = %q, want plan", e.permissionMode)
}
if e.repoDir != "/repo" {
t.Errorf("repoDir = %q, want /repo", e.repoDir)
}
}
// argAfter returns the value following flag in args, or "" if absent.
func argAfter(args []string, flag string) string {
if i := slices.Index(args, flag); i >= 0 && i+1 < len(args) {
return args[i+1]
}
return ""
}
func TestClaudeCodeArgs(t *testing.T) {
t.Setenv("GADFLY_CLAUDE_MODEL", "")
t.Setenv("GADFLY_CLAUDE_PERMISSION_MODE", "")
t.Setenv("GADFLY_CLAUDE_ALLOWED_TOOLS", "Read,Grep,Glob")
t.Setenv("GADFLY_CLAUDE_EXTRA_ARGS", "--max-turns 30")
e := newClaudeCodeEngine("claude-code/sonnet", "/repo")
args := e.args("SYS-PROMPT", "TASK-PROMPT")
// task is the -p value; json output; system appended; model + policy present.
if argAfter(args, "-p") != "TASK-PROMPT" {
t.Errorf("-p = %q, want TASK-PROMPT", argAfter(args, "-p"))
}
if argAfter(args, "--output-format") != "json" {
t.Errorf("--output-format = %q, want json", argAfter(args, "--output-format"))
}
if argAfter(args, "--append-system-prompt") != "SYS-PROMPT" {
t.Errorf("--append-system-prompt = %q, want SYS-PROMPT", argAfter(args, "--append-system-prompt"))
}
if argAfter(args, "--model") != "sonnet" {
t.Errorf("--model = %q, want sonnet", argAfter(args, "--model"))
}
if argAfter(args, "--permission-mode") != "plan" {
t.Errorf("--permission-mode = %q, want plan", argAfter(args, "--permission-mode"))
}
if argAfter(args, "--allowedTools") != "Read,Grep,Glob" {
t.Errorf("--allowedTools = %q, want Read,Grep,Glob", argAfter(args, "--allowedTools"))
}
// extra args appended verbatim (split on whitespace)
if !strings.Contains(strings.Join(args, " "), "--max-turns 30") {
t.Errorf("extra args not appended: %v", args)
}
}
func TestClaudeCodeArgsBareModelOmitsFlag(t *testing.T) {
t.Setenv("GADFLY_CLAUDE_MODEL", "")
t.Setenv("GADFLY_CLAUDE_ALLOWED_TOOLS", "") // omit when blank
t.Setenv("GADFLY_CLAUDE_EXTRA_ARGS", "")
e := newClaudeCodeEngine("claude-code", "/repo")
args := e.args("s", "t")
if slices.Contains(args, "--model") {
t.Errorf("--model should be omitted for a bare claude-code spec: %v", args)
}
if slices.Contains(args, "--allowedTools") {
t.Errorf("--allowedTools should be omitted when blank: %v", args)
}
}
func TestClaudeEnvFilters(t *testing.T) {
t.Setenv("GITEA_TOKEN", "secret-gitea")
t.Setenv("OLLAMA_API_KEY", "secret-ollama")
t.Setenv("GADFLY_API_KEY", "secret-gadfly")
t.Setenv("GADFLY_FINDINGS_TOKEN", "secret-findings")
t.Setenv("CLAUDE_CODE_OAUTH_TOKEN", "keep-claude")
t.Setenv("ANTHROPIC_API_KEY", "keep-anthropic")
t.Setenv("GADFLY_CLAUDE_MODEL", "keep-knob")
env := claudeEnv()
has := func(k string) bool {
for _, kv := range env {
if strings.HasPrefix(kv, k+"=") {
return true
}
}
return false
}
// kept: the CLI's auth + its own knobs + PATH
for _, k := range []string{"CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "GADFLY_CLAUDE_MODEL", "PATH"} {
if !has(k) {
t.Errorf("claudeEnv dropped %s, but it should be kept", k)
}
}
// dropped: the runner's secrets the CLI doesn't need
for _, k := range []string{"GITEA_TOKEN", "OLLAMA_API_KEY", "GADFLY_API_KEY", "GADFLY_FINDINGS_TOKEN"} {
if has(k) {
t.Errorf("claudeEnv leaked %s into the subprocess env", k)
}
}
}
func TestTruncateForErrRuneSafe(t *testing.T) {
// 900 multibyte runes (3 bytes each) -> well over the 800-byte cap; the cut
// must land on a rune boundary so the result stays valid UTF-8.
s := strings.Repeat("€", 900)
got := truncateForErr(s)
if !utf8.ValidString(got) {
t.Fatalf("truncateForErr produced invalid UTF-8")
}
if !strings.HasSuffix(got, "…") {
t.Fatalf("truncateForErr should append an ellipsis when truncating")
}
// short strings pass through untouched
if truncateForErr(" hi ") != "hi" {
t.Fatalf("truncateForErr should trim and pass short strings through")
}
}
// stubClaude writes an executable shell stub that prints body and exits code,
// and returns an engine pointed at it.
func stubClaude(t *testing.T, body string, code int) *claudeCodeEngine {
t.Helper()
dir := t.TempDir()
path := dir + "/claude-stub.sh"
script := "#!/bin/sh\nprintf '%s' " + shSingleQuote(body) + "\nexit " + itoa(code) + "\n"
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
return &claudeCodeEngine{bin: path, repoDir: dir}
}
func shSingleQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }
func itoa(i int) string { return string(rune('0' + i)) } // single-digit exit codes only
func TestRunPassCleanResult(t *testing.T) {
e := stubClaude(t, `{"result":"REVIEW TEXT","is_error":false}`, 0)
out, err := e.runPass(context.Background(), "sys", "task", 0)
if err != nil || out != "REVIEW TEXT" {
t.Fatalf("clean result: got (%q, %v), want (REVIEW TEXT, nil)", out, err)
}
}
func TestRunPassEmptyResultIsError(t *testing.T) {
// JSON parses, exit 0, but result empty: must NOT return the raw JSON blob.
e := stubClaude(t, `{"result":"","is_error":false}`, 0)
out, err := e.runPass(context.Background(), "sys", "task", 0)
if err == nil {
t.Fatalf("empty result should be an error, got out=%q", out)
}
if strings.Contains(out, "{") {
t.Fatalf("empty result must not leak raw JSON, got %q", out)
}
}
func TestRunPassIsErrorFlag(t *testing.T) {
e := stubClaude(t, `{"result":"boom","is_error":true,"subtype":"error_max_turns"}`, 0)
_, err := e.runPass(context.Background(), "sys", "task", 0)
if err == nil || !strings.Contains(err.Error(), "claude reported error") {
t.Fatalf("is_error should surface as an error, got %v", err)
}
}
func TestRunPassNonZeroNoJSON(t *testing.T) {
e := stubClaude(t, "fatal: auth failed", 1)
_, err := e.runPass(context.Background(), "sys", "task", 0)
if err == nil || !strings.Contains(err.Error(), "claude -p failed") {
t.Fatalf("non-zero exit should error with detail, got %v", err)
}
}
func TestClaudeCodeThinking(t *testing.T) {
t.Setenv("GADFLY_CLAUDE_MODEL", "")
cases := []struct {
spec string
wantModel string
wantThink int
}{
{"claude-code/opus", "opus", 0},
{"claude-code/opus:max", "opus", maxThinkingTokens},
{"claude-code/sonnet:20000", "sonnet", 20000},
{"claude-code/opus:bogus", "opus", 0}, // unrecognized suffix -> off
{"claude-code", "", 0},
}
for _, c := range cases {
e := newClaudeCodeEngine(c.spec, "/repo")
if e.model != c.wantModel || e.thinkingTokens != c.wantThink {
t.Errorf("newClaudeCodeEngine(%q) = (model %q, think %d), want (%q, %d)",
c.spec, e.model, e.thinkingTokens, c.wantModel, c.wantThink)
}
}
}
func TestClaudeCodeThinkingEnvOverrideKeepsSuffix(t *testing.T) {
// GADFLY_CLAUDE_MODEL overrides the model id, but the :max thinking from the
// spec still applies.
t.Setenv("GADFLY_CLAUDE_MODEL", "claude-opus-4-8")
e := newClaudeCodeEngine("claude-code/opus:max", "/repo")
if e.model != "claude-opus-4-8" {
t.Errorf("model = %q, want claude-opus-4-8 (env override)", e.model)
}
if e.thinkingTokens != maxThinkingTokens {
t.Errorf("thinkingTokens = %d, want %d (suffix still applies)", e.thinkingTokens, maxThinkingTokens)
}
}
// TestRunPassInjectsThinkingTokens verifies the engine actually puts
// MAX_THINKING_TOKENS into the subprocess env for a ":max" spec (and not for a
// plain spec). The stub echoes the value it received back as the result.
func TestRunPassInjectsThinkingTokens(t *testing.T) {
t.Setenv("GADFLY_CLAUDE_MODEL", "")
t.Setenv("MAX_THINKING_TOKENS", "") // not in the test's own env
dir := t.TempDir()
stub := dir + "/claude-stub.sh"
// Report the env var the CLI was launched with.
script := "#!/bin/sh\nprintf '{\"result\":\"MTT=%s\",\"is_error\":false}' \"${MAX_THINKING_TOKENS:-unset}\"\n"
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
maxEng := newClaudeCodeEngine("claude-code/opus:max", dir)
maxEng.bin = stub
if out, err := maxEng.runPass(context.Background(), "s", "t", 0); err != nil || out != "MTT=31999" {
t.Fatalf(":max run: got (%q, %v), want (MTT=31999, nil)", out, err)
}
plainEng := newClaudeCodeEngine("claude-code/opus", dir)
plainEng.bin = stub
if out, err := plainEng.runPass(context.Background(), "s", "t", 0); err != nil || out != "MTT=unset" {
t.Fatalf("plain run: got (%q, %v), want (MTT=unset, nil)", out, err)
}
}