feat(engine): add opencode CLI review engine
Gadfly review (reusable) / review (pull_request) Successful in 5s
Adversarial Review (Gadfly) / review (pull_request) Successful in 5s
Build & push image / build-and-push (pull_request) Successful in 2m43s

Add a third review harness alongside the in-process majordomo loop and the
claude-code CLI shell-out: the OpenCode CLI (opencode.ai) driving an ollama-cloud
model, selected by an "opencode/<model>" spec. The goal is to benchmark gadfly's
boutique executus harness against a freely-available agentic harness on the SAME
model (e.g. "ollama-cloud/glm-5.2" vs "opencode/glm-5.2").

OpenCode has no --append-system-prompt flag, so the lens system prompt and the
read-only discipline are delivered through a generated config injected via
OPENCODE_CONFIG_CONTENT: a "gadfly" agent whose prompt is the system prompt with
edit/bash denied at both the global and agent level, plus a "gadfly" ollama-cloud
provider. That env var is the highest-precedence config source in the container,
so a reviewed repo's own opencode.json can't re-enable edits on the reviewer.

Spec forms: "opencode/<model>" (wrapped in the generated provider), the
"open-code/" alias, "opencode/<provider>/<model>" pass-through to OpenCode's own
registry, and bare "opencode". Model ids are taken verbatim so colon-bearing
ollama ids (qwen3-coder:480b-cloud) survive. Auth reuses OLLAMA_CLOUD_API_KEY
(mapped to OLLAMA_API_KEY, referenced as {env:OLLAMA_API_KEY} in config, never a
literal secret). Knobs mirror GADFLY_CLAUDE_*: GADFLY_OPENCODE_BIN/MODEL/BASE_URL/
EXTRA_ARGS. openCodeEnv() forwards OLLAMA_API_KEY (the inverse of claudeEnv) but
still withholds the Gitea/findings/Anthropic secrets.

main.go engine selection is now a switch (claude-code / opencode / majordomo), and
the auto-select path uses a type-check instead of a boolean so a shell-out engine
can never hit the *majordomoEngine assertion. auto-select and delegate_investigation
stay majordomo-only and are skipped for opencode (the CLI does its own legwork).

Dockerfile bundles opencode-ai (npm auto-selects its musl build on alpine) with a
best-effort version check + provider pre-warm that never fails the shared image
build. README/examples/CLAUDE.md/scripts updated per the maintenance rules.

Tests: new opencode_test.go mirrors engine_test.go (spec/model/args/config/env-
filter + stub-CLI runtime tests). Verified end-to-end with a fake opencode CLI:
correct argv, injected config, and consolidated markdown output.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-18 01:00:54 -04:00
co-authored by Claude Opus 4.8
parent f468fe6245
commit 5ab4074e9c
12 changed files with 753 additions and 16 deletions
+5 -2
View File
@@ -19,16 +19,19 @@ import (
// the model's text answer. It is the one primitive both review passes use — the
// draft review and the adversarial recheck — so the rest of the pipeline
// (specialist composition, recheck orchestration, consolidation, emit) is
// engine-agnostic. Two implementations:
// engine-agnostic. Three implementations:
//
// - majordomoEngine: the original path — a majordomo tool-using agent loop
// (read_file/grep/… over a sandboxed repoFS).
// - claudeCodeEngine: shells out to the `claude` CLI in print mode, which
// brings its OWN repo tools; gadfly just feeds it the prompt and reads back
// the final text.
// - openCodeEngine (opencode.go): shells out to the `opencode` CLI likewise,
// but driving an ollama-cloud model — for benchmarking the two harnesses on
// the same model.
//
// maxSteps is the tool-step budget for engines that have one (majordomo); the
// claude-code engine manages its own loop and ignores it.
// shell-out engines manage their own loop and ignore it.
type reviewEngine interface {
runPass(ctx context.Context, system, task string, maxSteps int) (string, error)
}
+22 -13
View File
@@ -22,7 +22,11 @@
//
// GADFLY_MODEL model id, or a full "provider/model" spec / majordomo
// alias / failover chain (required). A bare id is
// prefixed with GADFLY_PROVIDER.
// prefixed with GADFLY_PROVIDER. Two prefixes select a
// shell-out CLI engine instead of the in-process loop:
// "claude-code/<model>" (Claude Code, see engine.go) and
// "opencode/<model>" (OpenCode over ollama-cloud, see
// opencode.go) — both bring their own repo tools.
// GADFLY_PROVIDER provider for bare model ids (default "ollama-cloud";
// e.g. "ollama" for a local daemon, "openai", …).
// GADFLY_BASE_URL override the backend endpoint (OpenAI/Ollama-compatible
@@ -147,15 +151,18 @@ func run() error {
return err
}
// Resolve the review engine. The claude-code engine shells out to the
// `claude` CLI (its own repo tools); every other spec is a majordomo model.
// auto-selection and the delegate worker are majordomo-only — with
// claude-code they're skipped (Claude Code does its own legwork).
ccSpec := isClaudeCodeSpec(os.Getenv("GADFLY_MODEL"))
// Resolve the review engine. The shell-out engines (claude-code, opencode)
// bring their OWN repo tools; every other spec is an in-process majordomo
// model. auto-selection and the delegate worker are majordomo-only — with a
// shell-out engine they're skipped (the CLI does its own legwork).
spec := os.Getenv("GADFLY_MODEL")
var eng reviewEngine
if ccSpec {
eng = newClaudeCodeEngine(os.Getenv("GADFLY_MODEL"), fsTools.root)
} else {
switch {
case isClaudeCodeSpec(spec):
eng = newClaudeCodeEngine(spec, fsTools.root)
case isOpenCodeSpec(spec):
eng = newOpenCodeEngine(spec, fsTools.root)
default:
mdl, merr := resolveModel()
if merr != nil {
return fmt.Errorf("resolve model: %w", merr)
@@ -187,13 +194,15 @@ func run() error {
}
// Dynamic selection: a (cheap) model picks the lenses this diff needs.
// Majordomo-only — the selector is an llm.Model.
// Majordomo-only — the selector is an llm.Model, so a shell-out engine
// (claude-code, opencode) can't provide one; fall back to the default suite.
if auto {
if ccSpec {
fmt.Fprintln(os.Stderr, "gadfly: auto-select is not supported with the claude-code engine; using the default suite")
md, ok := eng.(*majordomoEngine)
if !ok {
fmt.Fprintln(os.Stderr, "gadfly: auto-select requires an in-process model engine; using the default suite")
specialists = suiteFromRegistry(registry, defaultSuite)
} else {
selector, serr := resolveSelectorModel(eng.(*majordomoEngine).mdl)
selector, serr := resolveSelectorModel(md.mdl)
if serr != nil {
return fmt.Errorf("resolve selector model: %w", serr)
}
+265
View File
@@ -0,0 +1,265 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"syscall"
)
// openCodeEngine reviews by shelling out to the `opencode` CLI (opencode.ai) in
// non-interactive `run` mode. Like the claude-code engine it is a pure shell-out
// — OpenCode brings its OWN read tools (read/grep/glob/list) and reads the
// checked-out tree, so findings are verified against real code — but it drives an
// ollama-cloud model instead of a Claude subscription. The point is to benchmark
// gadfly's boutique executus harness against a freely-available agentic harness
// on the SAME models (e.g. "ollama-cloud/glm-5.2" vs "opencode/glm-5.2").
//
// OpenCode has no --append-system-prompt flag, so the lens system prompt AND the
// read-only discipline are delivered through a generated config injected via the
// OPENCODE_CONFIG_CONTENT env var (see config): a custom "gadfly" agent whose
// prompt is the system prompt with edit/bash denied, plus a "gadfly" provider
// pointing at ollama-cloud. OPENCODE_CONFIG_CONTENT is the highest-precedence
// config source that matters in the container — it outranks any opencode.json a
// reviewed repo might ship — so a repo can't re-enable edits on us.
type openCodeEngine struct {
bin string // CLI binary (GADFLY_OPENCODE_BIN, default "opencode")
providerModel string // ollama model id for the generated "gadfly" provider ("" = none)
modelRef string // --model value ("gadfly/<id>", a pass-through "<prov>/<id>", or "" = CLI default)
baseURL string // ollama-cloud base URL (GADFLY_OPENCODE_BASE_URL)
repoDir string // cwd for the CLI, so its tools read the checked-out tree
extraArgs []string // appended verbatim (GADFLY_OPENCODE_EXTRA_ARGS)
}
const (
// openCodeProviderName / openCodeAgentName are the internal names of the
// provider and agent gadfly generates in the injected config. "gadfly" won't
// collide with OpenCode's models.dev provider registry.
openCodeProviderName = "gadfly"
openCodeAgentName = "gadfly"
// defaultOpenCodeBaseURL is ollama-cloud's OpenAI-compatible endpoint.
defaultOpenCodeBaseURL = "https://ollama.com/v1"
)
// isOpenCodeSpec reports whether a GADFLY_MODEL spec selects the opencode engine:
// the bare id "opencode"/"open-code" or an "opencode/<model>" form (both
// spellings accepted; "opencode" is canonical).
func isOpenCodeSpec(model string) bool {
m := strings.TrimSpace(model)
for _, p := range []string{"opencode", "open-code"} {
if m == p || strings.HasPrefix(m, p+"/") {
return true
}
}
return false
}
// newOpenCodeEngine builds the engine from the GADFLY_MODEL spec and the optional
// GADFLY_OPENCODE_* overrides. The part after the FIRST slash is the model, taken
// verbatim — no ":"-suffix parsing, because ollama model ids legitimately contain
// colons (e.g. "qwen3-coder:480b-cloud"). Three spec forms:
//
// opencode → bare: no --model, no generated provider (CLI default model)
// opencode/<model> → wrap <model> in the generated ollama-cloud "gadfly" provider
// opencode/<provider>/<model> → pass-through: --model <provider>/<model>, using OpenCode's
// own provider registry/auth (escape hatch, no generated provider)
//
// GADFLY_OPENCODE_MODEL overrides the model taken from the spec (and is itself run
// through the same slash logic). It does not verify the CLI is installed — a
// missing binary surfaces as a normal pass error (advisory, never fatal).
func newOpenCodeEngine(spec, repoDir string) *openCodeEngine {
e := &openCodeEngine{
bin: envOr("GADFLY_OPENCODE_BIN", "opencode"),
baseURL: envOr("GADFLY_OPENCODE_BASE_URL", defaultOpenCodeBaseURL),
repoDir: repoDir,
extraArgs: strings.Fields(os.Getenv("GADFLY_OPENCODE_EXTRA_ARGS")),
}
var after string
if _, a, ok := strings.Cut(strings.TrimSpace(spec), "/"); ok {
after = strings.TrimSpace(a)
}
if env := strings.TrimSpace(os.Getenv("GADFLY_OPENCODE_MODEL")); env != "" {
after = env
}
switch {
case after == "":
// bare spec: let OpenCode's configured default model apply.
case strings.Contains(after, "/"):
// "<provider>/<model>" pass-through to an OpenCode built-in provider.
e.modelRef = after
default:
// A bare model id → serve it via the generated ollama-cloud provider.
e.providerModel = after
e.modelRef = openCodeProviderName + "/" + after
}
return e
}
// args assembles the `opencode` argv for one pass. Factored out (and pure) so it
// can be unit-tested without invoking the CLI. The task is the positional message
// and MUST come last; it never begins with '-' (buildTask output starts with "PR
// title:"/"Review …"), so no "--" terminator is needed. Note: in `opencode run`,
// -p is basic-auth password, NOT the prompt — the message is positional.
func (e *openCodeEngine) args(task string) []string {
a := []string{"run", "--agent", openCodeAgentName}
if e.modelRef != "" {
a = append(a, "--model", e.modelRef)
}
a = append(a, e.extraArgs...)
return append(a, task)
}
// openCode config structs — the minimal shape gadfly generates. Marshaled to JSON
// and handed to the CLI via OPENCODE_CONFIG_CONTENT.
type openCodeConfig struct {
Schema string `json:"$schema"`
Permission openCodePermission `json:"permission"`
Provider map[string]openCodeProvider `json:"provider,omitempty"`
Agent map[string]openCodeAgent `json:"agent"`
}
type openCodePermission struct {
Edit string `json:"edit"`
Bash string `json:"bash"`
}
type openCodeProvider struct {
NPM string `json:"npm"`
Name string `json:"name"`
Options openCodeProviderOptions `json:"options"`
Models map[string]struct{} `json:"models"`
}
type openCodeProviderOptions struct {
BaseURL string `json:"baseURL"`
APIKey string `json:"apiKey"`
}
type openCodeAgent struct {
Description string `json:"description"`
Mode string `json:"mode"`
Prompt string `json:"prompt"`
Permission openCodePermission `json:"permission"`
}
// config builds the OpenCode config JSON for one pass. The system prompt becomes
// the "gadfly" agent's prompt; edit/bash are denied at BOTH the global and agent
// level (defense in depth — OpenCode's read-only tools stay available). For a
// bare or pass-through spec the generated ollama-cloud provider block is omitted.
// The API key is always the "{env:OLLAMA_API_KEY}" reference, never a literal
// secret baked into the config.
func (e *openCodeEngine) config(system string) ([]byte, error) {
deny := openCodePermission{Edit: "deny", Bash: "deny"}
cfg := openCodeConfig{
Schema: "https://opencode.ai/config.json",
Permission: deny,
Agent: map[string]openCodeAgent{
openCodeAgentName: {
Description: "Gadfly adversarial code-review lens (read-only).",
Mode: "primary",
Prompt: system,
Permission: deny,
},
},
}
if e.providerModel != "" {
cfg.Provider = map[string]openCodeProvider{
openCodeProviderName: {
NPM: "@ai-sdk/openai-compatible",
Name: openCodeProviderName,
Options: openCodeProviderOptions{
BaseURL: e.baseURL,
APIKey: "{env:OLLAMA_API_KEY}",
},
Models: map[string]struct{}{e.providerModel: {}},
},
}
}
return json.Marshal(cfg)
}
func (e *openCodeEngine) runPass(ctx context.Context, system, task string, _ int) (string, error) {
cfg, err := e.config(system)
if err != nil {
return "", fmt.Errorf("opencode config: %w", err)
}
cmd := exec.CommandContext(ctx, e.bin, e.args(task)...)
cmd.Dir = e.repoDir
// Inject the review config (system prompt as the agent prompt + read-only
// permissions + the ollama-cloud provider) via OPENCODE_CONFIG_CONTENT, which
// outranks any opencode.json the reviewed repo itself ships. NO_COLOR keeps the
// captured stdout free of ANSI decoration.
cmd.Env = append(openCodeEnv(), "OPENCODE_CONFIG_CONTENT="+string(cfg), "NO_COLOR=1")
// Put the CLI and the Node children it spawns in their own process group and
// kill the WHOLE group on context cancel, so a timed-out lens can't leave
// orphaned opencode/node processes behind in the container.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
// A cancelled/timed-out run must surface as an error, never as whatever partial
// bytes the CLI flushed before it was killed.
if ctx.Err() != nil {
return "", fmt.Errorf("opencode run %v", ctx.Err())
}
if runErr != nil {
detail := truncateForErr(stderr.String())
if detail == "" {
detail = truncateForErr(stdout.String())
}
if detail != "" {
return "", fmt.Errorf("opencode run failed: %v: %s", runErr, detail)
}
return "", fmt.Errorf("opencode run failed: %v", runErr)
}
// OpenCode's default (non-JSON) format prints the assistant's final text; trust
// it as the review. An empty result on a clean exit is an error, never "".
if out := strings.TrimSpace(stdout.String()); out != "" {
return out, nil
}
return "", fmt.Errorf("opencode run returned no output")
}
// openCodeEnv builds a minimal environment for the `opencode` subprocess: only
// what the CLI needs (PATH/HOME, locale, Node/XDG/OPENCODE_*/GADFLY_OPENCODE_*
// knobs) plus OLLAMA_API_KEY — which the generated provider references and which
// claudeEnv deliberately DROPS. The runner's other secrets (GITEA_TOKEN,
// GADFLY_FINDINGS_TOKEN, Anthropic/Claude keys) are withheld — the CLI has no need
// for them. OPENCODE_CONFIG_CONTENT is never inherited: runPass sets it, and a
// duplicate key would be ambiguous (getenv returns the first occurrence).
func openCodeEnv() []string {
keep := func(k string) bool {
if k == "OPENCODE_CONFIG_CONTENT" {
return false // set explicitly by runPass; never inherit a competing value
}
switch k {
case "PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "LANG", "TERM", "SHELL", "OLLAMA_API_KEY":
return true
}
return strings.HasPrefix(k, "LC_") ||
strings.HasPrefix(k, "OPENCODE_") ||
strings.HasPrefix(k, "GADFLY_OPENCODE_") ||
strings.HasPrefix(k, "NODE_") ||
strings.HasPrefix(k, "XDG_")
}
var env []string
for _, kv := range os.Environ() {
if k, _, ok := strings.Cut(kv, "="); ok && keep(k) {
env = append(env, kv)
}
}
return env
}
+297
View File
@@ -0,0 +1,297 @@
package main
import (
"context"
"encoding/json"
"os"
"slices"
"strings"
"testing"
)
func TestIsOpenCodeSpec(t *testing.T) {
cases := map[string]bool{
"opencode": true,
"opencode/glm-5.2": true,
"open-code/glm-5.2": true, // accepted alias spelling
"opencode/qwen3-coder:480b-cloud": true, // colon-bearing model id
"opencode/anthropic/claude": true, // pass-through form
" opencode ": true, // trimmed
"opencode-extra": false, // not the bare id, not a "/" form
"qwen3-coder:480b-cloud": false,
"claude-code/opus": false,
"": false,
}
for spec, want := range cases {
if got := isOpenCodeSpec(spec); got != want {
t.Errorf("isOpenCodeSpec(%q) = %v, want %v", spec, got, want)
}
}
}
func TestNewOpenCodeEngineModel(t *testing.T) {
t.Setenv("GADFLY_OPENCODE_MODEL", "")
// "opencode/<model>" → wrapped in the generated "gadfly" provider.
if e := newOpenCodeEngine("opencode/glm-5.2", "/repo"); e.providerModel != "glm-5.2" || e.modelRef != "gadfly/glm-5.2" {
t.Errorf("glm-5.2: providerModel=%q modelRef=%q, want glm-5.2 / gadfly/glm-5.2", e.providerModel, e.modelRef)
}
// Colon-bearing ollama id is preserved verbatim — NOT split on ":".
if e := newOpenCodeEngine("opencode/qwen3-coder:480b-cloud", "/repo"); e.providerModel != "qwen3-coder:480b-cloud" {
t.Errorf("colon id: providerModel=%q, want qwen3-coder:480b-cloud (no split)", e.providerModel)
}
// "open-code/" spelling behaves identically.
if e := newOpenCodeEngine("open-code/glm-5.2", "/repo"); e.modelRef != "gadfly/glm-5.2" {
t.Errorf("open-code alias: modelRef=%q, want gadfly/glm-5.2", e.modelRef)
}
// Pass-through "opencode/<provider>/<model>" → no generated provider.
if e := newOpenCodeEngine("opencode/anthropic/claude-sonnet-4-6", "/repo"); e.providerModel != "" || e.modelRef != "anthropic/claude-sonnet-4-6" {
t.Errorf("pass-through: providerModel=%q modelRef=%q, want '' / anthropic/claude-sonnet-4-6", e.providerModel, e.modelRef)
}
// Bare spec → no model, no provider (CLI default applies).
if e := newOpenCodeEngine("opencode", "/repo"); e.providerModel != "" || e.modelRef != "" {
t.Errorf("bare: providerModel=%q modelRef=%q, want both empty", e.providerModel, e.modelRef)
}
// GADFLY_OPENCODE_MODEL overrides the spec suffix.
t.Setenv("GADFLY_OPENCODE_MODEL", "deepseek-v3")
if e := newOpenCodeEngine("opencode/glm-5.2", "/repo"); e.providerModel != "deepseek-v3" || e.modelRef != "gadfly/deepseek-v3" {
t.Errorf("env override: providerModel=%q modelRef=%q, want deepseek-v3 / gadfly/deepseek-v3", e.providerModel, e.modelRef)
}
}
func TestOpenCodeEngineDefaults(t *testing.T) {
t.Setenv("GADFLY_OPENCODE_BIN", "")
t.Setenv("GADFLY_OPENCODE_BASE_URL", "")
t.Setenv("GADFLY_OPENCODE_EXTRA_ARGS", "")
e := newOpenCodeEngine("opencode/glm-5.2", "/repo")
if e.bin != "opencode" {
t.Errorf("bin = %q, want opencode", e.bin)
}
if e.baseURL != defaultOpenCodeBaseURL {
t.Errorf("baseURL = %q, want %q", e.baseURL, defaultOpenCodeBaseURL)
}
if e.repoDir != "/repo" {
t.Errorf("repoDir = %q, want /repo", e.repoDir)
}
}
func TestOpenCodeArgs(t *testing.T) {
t.Setenv("GADFLY_OPENCODE_MODEL", "")
t.Setenv("GADFLY_OPENCODE_EXTRA_ARGS", "--variant reasoning")
e := newOpenCodeEngine("opencode/glm-5.2", "/repo")
args := e.args("TASK-PROMPT")
// "run" is the subcommand and must be first.
if len(args) == 0 || args[0] != "run" {
t.Fatalf("args[0] = %q, want run (args=%v)", args, args)
}
if argAfter(args, "--agent") != openCodeAgentName {
t.Errorf("--agent = %q, want %q", argAfter(args, "--agent"), openCodeAgentName)
}
if argAfter(args, "--model") != "gadfly/glm-5.2" {
t.Errorf("--model = %q, want gadfly/glm-5.2", argAfter(args, "--model"))
}
// extra args appended verbatim (split on whitespace).
if !strings.Contains(strings.Join(args, " "), "--variant reasoning") {
t.Errorf("extra args not appended: %v", args)
}
// task is the positional message and must be LAST.
if args[len(args)-1] != "TASK-PROMPT" {
t.Errorf("last arg = %q, want TASK-PROMPT (args=%v)", args[len(args)-1], args)
}
}
func TestOpenCodeArgsBareModelOmitsFlag(t *testing.T) {
t.Setenv("GADFLY_OPENCODE_MODEL", "")
t.Setenv("GADFLY_OPENCODE_EXTRA_ARGS", "")
e := newOpenCodeEngine("opencode", "/repo")
args := e.args("t")
if slices.Contains(args, "--model") {
t.Errorf("--model should be omitted for a bare opencode spec: %v", args)
}
if args[len(args)-1] != "t" {
t.Errorf("last arg = %q, want t", args[len(args)-1])
}
}
func TestOpenCodeConfig(t *testing.T) {
t.Setenv("GADFLY_OPENCODE_MODEL", "")
t.Setenv("GADFLY_OPENCODE_BASE_URL", "")
// Round-trip a system prompt containing quotes and newlines.
sys := "Line one with \"quotes\".\nLine two."
e := newOpenCodeEngine("opencode/glm-5.2", "/repo")
raw, err := e.config(sys)
if err != nil {
t.Fatalf("config: %v", err)
}
var cfg openCodeConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
t.Fatalf("generated config is not valid JSON: %v\n%s", err, raw)
}
// Agent carries the system prompt verbatim and denies edit+bash.
ag, ok := cfg.Agent[openCodeAgentName]
if !ok {
t.Fatalf("agent %q missing from config", openCodeAgentName)
}
if ag.Prompt != sys {
t.Errorf("agent prompt = %q, want it to round-trip the system prompt", ag.Prompt)
}
if ag.Permission.Edit != "deny" || ag.Permission.Bash != "deny" {
t.Errorf("agent permission = %+v, want edit/bash deny", ag.Permission)
}
// Global permission also denies (defense in depth).
if cfg.Permission.Edit != "deny" || cfg.Permission.Bash != "deny" {
t.Errorf("global permission = %+v, want edit/bash deny", cfg.Permission)
}
// Provider block: correct npm, default baseURL, env-ref apiKey, model in map.
prov, ok := cfg.Provider[openCodeProviderName]
if !ok {
t.Fatalf("provider %q missing from config", openCodeProviderName)
}
if prov.NPM != "@ai-sdk/openai-compatible" {
t.Errorf("provider npm = %q, want @ai-sdk/openai-compatible", prov.NPM)
}
if prov.Options.BaseURL != defaultOpenCodeBaseURL {
t.Errorf("provider baseURL = %q, want %q", prov.Options.BaseURL, defaultOpenCodeBaseURL)
}
if prov.Options.APIKey != "{env:OLLAMA_API_KEY}" {
t.Errorf("provider apiKey = %q, want {env:OLLAMA_API_KEY} (never a literal secret)", prov.Options.APIKey)
}
if _, ok := prov.Models["glm-5.2"]; !ok {
t.Errorf("provider models = %v, want it to contain glm-5.2", prov.Models)
}
// GADFLY_OPENCODE_BASE_URL override reaches the provider.
t.Setenv("GADFLY_OPENCODE_BASE_URL", "http://localhost:11434/v1")
e2 := newOpenCodeEngine("opencode/glm-5.2", "/repo")
raw2, err := e2.config(sys)
if err != nil {
t.Fatalf("config override: %v", err)
}
var cfg2 openCodeConfig
if err := json.Unmarshal(raw2, &cfg2); err != nil {
t.Fatalf("override config invalid JSON: %v", err)
}
if got := cfg2.Provider[openCodeProviderName].Options.BaseURL; got != "http://localhost:11434/v1" {
t.Errorf("override baseURL = %q, want http://localhost:11434/v1", got)
}
}
func TestOpenCodeConfigNoProviderForPassThroughAndBare(t *testing.T) {
t.Setenv("GADFLY_OPENCODE_MODEL", "")
for _, spec := range []string{"opencode", "opencode/anthropic/claude-sonnet-4-6"} {
e := newOpenCodeEngine(spec, "/repo")
raw, err := e.config("sys")
if err != nil {
t.Fatalf("config(%q): %v", spec, err)
}
if strings.Contains(string(raw), "\"provider\"") {
t.Errorf("spec %q: config should omit the provider block, got %s", spec, raw)
}
}
}
func TestOpenCodeEnvFilters(t *testing.T) {
t.Setenv("GITEA_TOKEN", "secret-gitea")
t.Setenv("OLLAMA_API_KEY", "keep-ollama")
t.Setenv("GADFLY_API_KEY", "secret-gadfly")
t.Setenv("GADFLY_FINDINGS_TOKEN", "secret-findings")
t.Setenv("ANTHROPIC_API_KEY", "secret-anthropic")
t.Setenv("CLAUDE_CODE_OAUTH_TOKEN", "secret-claude")
t.Setenv("GADFLY_OPENCODE_MODEL", "keep-knob")
t.Setenv("OPENCODE_CONFIG_CONTENT", "should-not-inherit")
env := openCodeEnv()
has := func(k string) bool {
for _, kv := range env {
if strings.HasPrefix(kv, k+"=") {
return true
}
}
return false
}
// kept: the ollama key the provider references + opencode knobs + PATH
for _, k := range []string{"OLLAMA_API_KEY", "GADFLY_OPENCODE_MODEL", "PATH"} {
if !has(k) {
t.Errorf("openCodeEnv dropped %s, but it should be kept", k)
}
}
// dropped: the runner's other secrets + the claude engine's auth
for _, k := range []string{"GITEA_TOKEN", "GADFLY_API_KEY", "GADFLY_FINDINGS_TOKEN", "ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"} {
if has(k) {
t.Errorf("openCodeEnv leaked %s into the subprocess env", k)
}
}
// OPENCODE_CONFIG_CONTENT must NOT be inherited — runPass sets it, and a
// duplicate key would be ambiguous.
if has("OPENCODE_CONFIG_CONTENT") {
t.Errorf("openCodeEnv inherited OPENCODE_CONFIG_CONTENT; runPass sets it explicitly")
}
}
// stubOpenCode writes an executable shell stub that prints body and exits code,
// and returns an engine pointed at it.
func stubOpenCode(t *testing.T, body string, code int) *openCodeEngine {
t.Helper()
dir := t.TempDir()
path := dir + "/opencode-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 &openCodeEngine{bin: path, repoDir: dir}
}
func TestOpenCodeRunPassCleanResult(t *testing.T) {
e := stubOpenCode(t, " REVIEW TEXT ", 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 TestOpenCodeRunPassEmptyIsError(t *testing.T) {
e := stubOpenCode(t, " ", 0)
out, err := e.runPass(context.Background(), "sys", "task", 0)
if err == nil {
t.Fatalf("empty output should be an error, got out=%q", out)
}
}
func TestOpenCodeRunPassNonZero(t *testing.T) {
e := stubOpenCode(t, "fatal: provider auth failed", 1)
_, err := e.runPass(context.Background(), "sys", "task", 0)
if err == nil || !strings.Contains(err.Error(), "opencode run failed") {
t.Fatalf("non-zero exit should error with detail, got %v", err)
}
}
// TestOpenCodeRunPassInjectsConfig proves the end-to-end env plumbing: the stub
// echoes OPENCODE_CONFIG_CONTENT back, and the emitted JSON must carry the exact
// system prompt as the gadfly agent's prompt.
func TestOpenCodeRunPassInjectsConfig(t *testing.T) {
dir := t.TempDir()
stub := dir + "/opencode-stub.sh"
script := "#!/bin/sh\nprintf '%s' \"$OPENCODE_CONFIG_CONTENT\"\n"
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
e := newOpenCodeEngine("opencode/glm-5.2", dir)
e.bin = stub
sys := "SYSTEM-PROMPT-SENTINEL\nwith a second line"
out, err := e.runPass(context.Background(), sys, "task", 0)
if err != nil {
t.Fatalf("runPass: %v", err)
}
var cfg openCodeConfig
if err := json.Unmarshal([]byte(out), &cfg); err != nil {
t.Fatalf("injected config is not valid JSON: %v\n%s", err, out)
}
if got := cfg.Agent[openCodeAgentName].Prompt; got != sys {
t.Errorf("injected agent prompt = %q, want the system prompt", got)
}
}