Build & push image / build-and-push (push) Successful in 15s
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]>
282 lines
9.9 KiB
Go
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)
|
|
}
|
|
}
|