diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ee5953..e128868 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: src/go.mod + cache-dependency-path: src/go.sum - name: Build run: go build ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5678c8..d9ae315 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,6 +62,10 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | TAG="v${{ steps.bump.outputs.version }}" + if git ls-remote --tags origin "refs/tags/$TAG" | grep -q "$TAG"; then + echo "Tag $TAG already exists — skipping release" + exit 0 + fi git tag "$TAG" git push origin "$TAG" gh release create "$TAG" --title "$TAG" --notes "${{ steps.notes.outputs.notes }}" diff --git a/README.md b/README.md index 7908c08..bbbb247 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Works with just Claude. Optionally adds Codex and Gemini for multi-perspective a | `/devkit:refactor` | Full refactor lifecycle — analyze, plan, restructure, verify, compare | | `/devkit:research` | Deep research — clarify, parallel search, analyze sources, synthesize | | `/devkit:decompose` | Goal decomposition — break into task DAG, assign agents, execute in dependency order | +| `/devkit:scrape` | URL-to-Markdown conversion via Jina Reader / Firecrawl / WebFetch | | `/devkit:status` | Health check — installed CLIs, available agents, ready commands | ### Self-Improvement Loops (Claude-only) @@ -268,8 +269,17 @@ devkit/ │ └── tri-security.yml # Three-tier security audit ├── presets/ # Reusable prompt templates (planned) │ └── .gitkeep -└── src/ - └── TODO.md # Go harness roadmap +├── .github/workflows/ # CI/CD +│ ├── ci.yml # Build + test + vet on push/PR +│ └── release.yml # Auto-tag + release on version bump +└── src/ # Go CLI harness + ├── go.mod + ├── main.go + ├── Makefile + ├── cmd/ # Cobra commands + ├── lib/ # DB, git, metric, state, report + ├── loops/ # Improve, feature, bugfix, refactor, testgen, review, dispatch + └── runners/ # Claude, Codex, Gemini runner interfaces ``` --- @@ -288,23 +298,31 @@ Set automatically in each multi-agent command: ## Go CLI Harness -Deterministic orchestration binary — the machine controls the loop, Claude is the body. +Deterministic orchestration binary — the machine controls the loop, the agent is the body. ```bash -cd src && make build -./bin/devkit --help +cd src && make build && make link +devkit --help +``` + +Or install directly: + +```bash +cd src && make install ``` ### Commands +All loop commands support `--agent` to choose the AI agent (default: `claude`). + | Command | Description | |---|---| -| `devkit improve` | Metric-gated iteration loop — one Claude invocation per iteration | +| `devkit improve` | Metric-gated iteration loop — one agent invocation per iteration | | `devkit feature` | Plan, implement, test, lint — commits only after tests pass | | `devkit bugfix` | Diagnose, fix, verify — reverts if tests break | | `devkit refactor` | Analyze, transform, verify — reverts if behavior changes | | `devkit test-gen` | Generate tests, run, fix failures — iterates until green | -| `devkit review` | Parallel multi-agent code review (Claude + Codex + Gemini) | +| `devkit review` | Parallel multi-agent code review | | `devkit dispatch` | Send any task to multiple agents, compare outputs | | `devkit status` | Show all sessions, costs, iteration history | | `devkit resume` | Pick up a crashed or paused session | @@ -316,6 +334,7 @@ cd src && make build - **Hard budget caps** — stops spawning at your dollar limit - **CI/CD integration** — runs headless, no conversation needed - **True parallel dispatch** — goroutines, not sequential prompts +- **Multi-agent support** — `--agent claude`, `--agent codex`, or `--agent gemini` ### Examples @@ -323,6 +342,9 @@ cd src && make build # Run 50 improvement iterations overnight, stop at $20 devkit improve --metric "npm test" --iterations 50 --budget 20.00 +# Same thing with Codex instead of Claude +devkit improve --metric "npm test" --iterations 50 --agent codex + # Implement a feature with test verification devkit feature "add JWT auth" --target src/auth/ --test "npm test" @@ -332,6 +354,9 @@ devkit bugfix "login 500 on plus sign emails" --test "go test ./..." # Generate tests for a module devkit test-gen src/parser/ --test "go test ./..." +# Multi-agent review with all available agents +devkit review + # Resume a crashed session devkit resume abc123def456 @@ -339,6 +364,19 @@ devkit resume abc123def456 devkit status ``` +### Testing + +```bash +cd src && go test ./... -v +``` + +76+ tests across 4 packages (lib, runners, loops, cmd). Loop tests use mock runners — no API calls needed. + +### CI/CD + +- **CI pipeline** (`.github/workflows/ci.yml`): build + vet + test (with `-race`) + gofmt check on every push/PR to main +- **Auto-release** (`.github/workflows/release.yml`): auto-bumps version, tags, and creates GitHub release on merged PRs + --- ## Prerequisites @@ -404,7 +442,9 @@ Devkit's `tri:*` commands use `/gemini:rescue --background` for multi-agent disp See [ROADMAP.md](ROADMAP.md) for full details. -- [ ] Go CLI harness for deterministic loop control, process management, and unattended runs (see `src/TODO.md`) +- [x] Go CLI harness — 9 commands, SQLite state, crash recovery, budget enforcement, multi-agent support +- [x] CI/CD pipeline — build, vet, test, auto-release on version bump +- [x] Branch protection — PRs required for main - [ ] Cost event hooks — budget threshold events with auto-downgrade actions - [ ] Execution registry — centralized step tracking with timing and token usage - [ ] Preset library — curated prompt templates for common review/improvement scenarios diff --git a/src/cmd/bugfix.go b/src/cmd/bugfix.go index a1398b2..1adde4a 100644 --- a/src/cmd/bugfix.go +++ b/src/cmd/bugfix.go @@ -6,14 +6,13 @@ import ( "github.com/5uck1ess/devkit/lib" "github.com/5uck1ess/devkit/loops" - "github.com/5uck1ess/devkit/runners" "github.com/spf13/cobra" ) var bugfixCmd = &cobra.Command{ Use: "bugfix [description]", Short: "Full bugfix lifecycle: diagnose, fix, verify", - Long: "Spawns Claude for each step: diagnose root cause → apply fix → run tests to verify.", + Long: "Spawns an AI agent for each step: diagnose root cause → apply fix → run tests to verify.", Example: ` devkit bugfix "login returns 500 when email has a plus sign" devkit bugfix "race condition in cache invalidation" --test "go test ./..."`, Args: cobra.MinimumNArgs(1), @@ -29,10 +28,10 @@ var bugfixCmd = &cobra.Command{ return fmt.Errorf("working tree has uncommitted changes — commit or stash first") } - available := runners.DetectRunners() - runner := runners.FindRunner("claude", available) - if runner == nil { - return fmt.Errorf("claude CLI not found in PATH") + agentName, _ := cmd.Flags().GetString("agent") + runner, err := resolveRunner(agentName) + if err != nil { + return err } result, err := loops.RunBugfix(cmd.Context(), db, runner, &lib.Git{Dir: repoRoot}, loops.BugfixConfig{ diff --git a/src/cmd/feature.go b/src/cmd/feature.go index aaafe3b..213e72d 100644 --- a/src/cmd/feature.go +++ b/src/cmd/feature.go @@ -6,14 +6,13 @@ import ( "github.com/5uck1ess/devkit/lib" "github.com/5uck1ess/devkit/loops" - "github.com/5uck1ess/devkit/runners" "github.com/spf13/cobra" ) var featureCmd = &cobra.Command{ Use: "feature [description]", Short: "Full feature lifecycle: plan, implement, test, lint", - Long: "Spawns Claude for each step: plan → implement → test (loop until green) → lint.", + Long: "Spawns an AI agent for each step: plan → implement → test (loop until green) → lint.", Example: ` devkit feature "add JWT authentication" --target src/auth/ devkit feature "add search endpoint" --test "npm test" --lint "npm run lint"`, Args: cobra.MinimumNArgs(1), @@ -31,10 +30,10 @@ var featureCmd = &cobra.Command{ return fmt.Errorf("working tree has uncommitted changes — commit or stash first") } - available := runners.DetectRunners() - runner := runners.FindRunner("claude", available) - if runner == nil { - return fmt.Errorf("claude CLI not found in PATH") + agentName, _ := cmd.Flags().GetString("agent") + runner, err := resolveRunner(agentName) + if err != nil { + return err } result, err := loops.RunFeature(cmd.Context(), db, runner, &lib.Git{Dir: repoRoot}, loops.FeatureConfig{ diff --git a/src/cmd/improve.go b/src/cmd/improve.go index 5861cc5..c101c2e 100644 --- a/src/cmd/improve.go +++ b/src/cmd/improve.go @@ -5,14 +5,13 @@ import ( "github.com/5uck1ess/devkit/lib" "github.com/5uck1ess/devkit/loops" - "github.com/5uck1ess/devkit/runners" "github.com/spf13/cobra" ) var improveCmd = &cobra.Command{ Use: "improve", Short: "Run a metric-gated improvement loop", - Long: "Spawns Claude per iteration. Each iteration: propose change, run metric, keep if pass, revert if fail.", + Long: "Spawns an AI agent per iteration. Each iteration: propose change, run metric, keep if pass, revert if fail.", Example: ` devkit improve --target src/ --metric "npm test" --objective "0 failing tests" --iterations 20 devkit improve --metric "go test ./..." --iterations 10 --budget 5.00`, RunE: func(cmd *cobra.Command, args []string) error { @@ -43,10 +42,10 @@ var improveCmd = &cobra.Command{ return fmt.Errorf("working tree has uncommitted changes — commit or stash before running devkit improve") } - available := runners.DetectRunners() - runner := runners.FindRunner("claude", available) - if runner == nil { - return fmt.Errorf("claude CLI not found in PATH — install from https://docs.anthropic.com/en/docs/claude-code") + agentName, _ := cmd.Flags().GetString("agent") + runner, err := resolveRunner(agentName) + if err != nil { + return err } git := &lib.Git{Dir: repoRoot} diff --git a/src/cmd/refactor.go b/src/cmd/refactor.go index 330e98c..7edbfb6 100644 --- a/src/cmd/refactor.go +++ b/src/cmd/refactor.go @@ -6,14 +6,13 @@ import ( "github.com/5uck1ess/devkit/lib" "github.com/5uck1ess/devkit/loops" - "github.com/5uck1ess/devkit/runners" "github.com/spf13/cobra" ) var refactorCmd = &cobra.Command{ Use: "refactor [description]", Short: "Full refactor lifecycle: analyze, transform, verify", - Long: "Spawns Claude for each step: analyze code smells → apply transformations → verify tests still pass.", + Long: "Spawns an AI agent for each step: analyze code smells → apply transformations → verify tests still pass.", Example: ` devkit refactor "extract auth middleware into shared package" --target src/ devkit refactor "flatten nested callbacks to async/await" --test "npm test"`, Args: cobra.MinimumNArgs(1), @@ -30,10 +29,10 @@ var refactorCmd = &cobra.Command{ return fmt.Errorf("working tree has uncommitted changes — commit or stash first") } - available := runners.DetectRunners() - runner := runners.FindRunner("claude", available) - if runner == nil { - return fmt.Errorf("claude CLI not found in PATH") + agentName, _ := cmd.Flags().GetString("agent") + runner, err := resolveRunner(agentName) + if err != nil { + return err } result, err := loops.RunRefactor(cmd.Context(), db, runner, &lib.Git{Dir: repoRoot}, loops.RefactorConfig{ diff --git a/src/cmd/resume.go b/src/cmd/resume.go index 846c113..ad54203 100644 --- a/src/cmd/resume.go +++ b/src/cmd/resume.go @@ -7,7 +7,6 @@ import ( "github.com/5uck1ess/devkit/lib" "github.com/5uck1ess/devkit/loops" - "github.com/5uck1ess/devkit/runners" "github.com/spf13/cobra" ) @@ -40,10 +39,10 @@ var resumeCmd = &cobra.Command{ return fmt.Errorf("resume only supports improve sessions — session %s is %q", sessionID, session.Workflow) } - available := runners.DetectRunners() - runner := runners.FindRunner("claude", available) - if runner == nil { - return fmt.Errorf("claude CLI not found in PATH") + agentName, _ := cmd.Flags().GetString("agent") + runner, err := resolveRunner(agentName) + if err != nil { + return err } // Require clean worktree before resuming diff --git a/src/cmd/root.go b/src/cmd/root.go index 01c51ae..a7a18d7 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -6,9 +6,11 @@ import ( "os" "os/signal" "path/filepath" + "strings" "syscall" "github.com/5uck1ess/devkit/lib" + "github.com/5uck1ess/devkit/runners" "github.com/spf13/cobra" ) @@ -44,6 +46,10 @@ var rootCmd = &cobra.Command{ }, } +func init() { + rootCmd.PersistentFlags().String("agent", "claude", "AI agent to use (claude, codex, gemini)") +} + func Execute() error { rootCmd.Version = Version ctx, cancel := context.WithCancel(context.Background()) @@ -60,6 +66,26 @@ func Execute() error { return rootCmd.ExecuteContext(ctx) } +func resolveRunner(name string) (runners.Runner, error) { + return resolveRunnerFrom(name, runners.DetectRunners()) +} + +func resolveRunnerFrom(name string, available []runners.Runner) (runners.Runner, error) { + name = strings.ToLower(strings.TrimSpace(name)) + r := runners.FindRunner(name, available) + if r != nil { + return r, nil + } + var names []string + for _, a := range available { + names = append(names, a.Name()) + } + if len(names) == 0 { + return nil, fmt.Errorf("no AI agents found in PATH — install claude, codex, or gemini") + } + return nil, fmt.Errorf("agent %q not found — available: %s", name, strings.Join(names, ", ")) +} + func findRepoRoot() (string, error) { dir, err := os.Getwd() if err != nil { diff --git a/src/cmd/root_test.go b/src/cmd/root_test.go new file mode 100644 index 0000000..5b57905 --- /dev/null +++ b/src/cmd/root_test.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/5uck1ess/devkit/runners" +) + +type stubRunner struct { + name string +} + +func (s *stubRunner) Name() string { return s.name } +func (s *stubRunner) Available() bool { return true } +func (s *stubRunner) Run(_ context.Context, _ string, _ runners.RunOpts) (runners.RunResult, error) { + return runners.RunResult{}, nil +} + +func TestResolveRunnerFrom_Found(t *testing.T) { + available := []runners.Runner{&stubRunner{"claude"}, &stubRunner{"codex"}} + r, err := resolveRunnerFrom("claude", available) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if r.Name() != "claude" { + t.Errorf("got %s, want claude", r.Name()) + } +} + +func TestResolveRunnerFrom_CaseInsensitive(t *testing.T) { + available := []runners.Runner{&stubRunner{"claude"}, &stubRunner{"gemini"}} + r, err := resolveRunnerFrom("GEMINI", available) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if r.Name() != "gemini" { + t.Errorf("got %s, want gemini", r.Name()) + } +} + +func TestResolveRunnerFrom_Whitespace(t *testing.T) { + available := []runners.Runner{&stubRunner{"codex"}} + r, err := resolveRunnerFrom(" codex ", available) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if r.Name() != "codex" { + t.Errorf("got %s, want codex", r.Name()) + } +} + +func TestResolveRunnerFrom_NotFound(t *testing.T) { + available := []runners.Runner{&stubRunner{"claude"}} + _, err := resolveRunnerFrom("gpt", available) + if err == nil { + t.Fatal("expected error for unknown agent") + } + if got := err.Error(); got != `agent "gpt" not found — available: claude` { + t.Errorf("unexpected error: %s", got) + } +} + +func TestResolveRunnerFrom_NoAgents(t *testing.T) { + _, err := resolveRunnerFrom("claude", nil) + if err == nil { + t.Fatal("expected error when no agents available") + } + if got := err.Error(); got != "no AI agents found in PATH — install claude, codex, or gemini" { + t.Errorf("unexpected error: %s", got) + } +} diff --git a/src/cmd/testgen.go b/src/cmd/testgen.go index 02deed7..d746610 100644 --- a/src/cmd/testgen.go +++ b/src/cmd/testgen.go @@ -6,7 +6,6 @@ import ( "github.com/5uck1ess/devkit/lib" "github.com/5uck1ess/devkit/loops" - "github.com/5uck1ess/devkit/runners" "github.com/spf13/cobra" ) @@ -30,10 +29,10 @@ var testGenCmd = &cobra.Command{ return fmt.Errorf("working tree has uncommitted changes — commit or stash first") } - available := runners.DetectRunners() - runner := runners.FindRunner("claude", available) - if runner == nil { - return fmt.Errorf("claude CLI not found in PATH") + agentName, _ := cmd.Flags().GetString("agent") + runner, err := resolveRunner(agentName) + if err != nil { + return err } result, err := loops.RunTestGen(cmd.Context(), db, runner, &lib.Git{Dir: repoRoot}, loops.TestGenConfig{ diff --git a/src/loops/loops_test.go b/src/loops/loops_test.go new file mode 100644 index 0000000..4df1dd2 --- /dev/null +++ b/src/loops/loops_test.go @@ -0,0 +1,1408 @@ +package loops + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/5uck1ess/devkit/lib" + "github.com/5uck1ess/devkit/runners" +) + +// --------------------------------------------------------------------------- +// test helpers +// --------------------------------------------------------------------------- + +func tempDB(t *testing.T) *lib.DB { + t.Helper() + dir := t.TempDir() + db, err := lib.OpenDB(filepath.Join(dir, ".devkit", "devkit.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +// initGitRepo creates a temp dir with a git repo, an initial commit on main, +// and returns the repo root path and a *lib.Git. +func initGitRepo(t *testing.T) (string, *lib.Git) { + t.Helper() + dir := t.TempDir() + cmds := [][]string{ + {"git", "init", "-b", "main"}, + {"git", "config", "user.email", "test@test.com"}, + {"git", "config", "user.name", "Test"}, + } + for _, args := range cmds { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git setup %v: %s", args, out) + } + } + // write a file and commit so HEAD exists + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# test\n"), 0o644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"git", "add", "-A"}, + {"git", "commit", "-m", "initial"}, + } { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git setup %v: %s", args, out) + } + } + return dir, &lib.Git{Dir: dir} +} + +// mockRunner is a test-local mock implementing runners.Runner. +type mockRunnerT struct { + name string + responses []runners.RunResult + errors []error + callIdx int +} + +func mockRunner(name string, responses []runners.RunResult, errs []error) *mockRunnerT { + return &mockRunnerT{name: name, responses: responses, errors: errs} +} + +func (m *mockRunnerT) Name() string { return m.name } +func (m *mockRunnerT) Available() bool { return true } +func (m *mockRunnerT) CallCount() int { return m.callIdx } + +func (m *mockRunnerT) Run(ctx context.Context, prompt string, opts runners.RunOpts) (runners.RunResult, error) { + idx := m.callIdx + m.callIdx++ + if idx >= len(m.responses) { + return runners.RunResult{Output: "mock exhausted"}, nil + } + var err error + if idx < len(m.errors) { + err = m.errors[idx] + } + return m.responses[idx], err +} + +// successResult returns a RunResult with output and a small cost. +func successResult(output string) runners.RunResult { + return runners.RunResult{Output: output, CostUSD: 0.01} +} + +// --------------------------------------------------------------------------- +// truncate +// --------------------------------------------------------------------------- + +func TestTruncate(t *testing.T) { + tests := []struct { + input string + n int + want string + }{ + {"short", 10, "short"}, + {"exactly10!", 10, "exactly10!"}, + {"this is longer than five", 5, "this ..."}, + {"", 5, ""}, + {"abc", 0, "..."}, + } + for _, tc := range tests { + got := truncate(tc.input, tc.n) + if got != tc.want { + t.Errorf("truncate(%q, %d) = %q, want %q", tc.input, tc.n, got, tc.want) + } + } +} + +// --------------------------------------------------------------------------- +// filterRunners +// --------------------------------------------------------------------------- + +func TestFilterRunners_NoFilter(t *testing.T) { + available := []runners.Runner{ + mockRunner("claude", nil, nil), + mockRunner("codex", nil, nil), + } + got := filterRunners(available, nil) + if len(got) != 2 { + t.Errorf("no filter: got %d runners, want 2", len(got)) + } +} + +func TestFilterRunners_EmptyNames(t *testing.T) { + available := []runners.Runner{ + mockRunner("claude", nil, nil), + } + got := filterRunners(available, []string{}) + if len(got) != 1 { + t.Errorf("empty names: got %d runners, want 1", len(got)) + } +} + +func TestFilterRunners_SelectSpecific(t *testing.T) { + available := []runners.Runner{ + mockRunner("claude", nil, nil), + mockRunner("codex", nil, nil), + mockRunner("gemini", nil, nil), + } + got := filterRunners(available, []string{"claude", "gemini"}) + if len(got) != 2 { + t.Errorf("select 2: got %d runners, want 2", len(got)) + } + names := map[string]bool{} + for _, r := range got { + names[r.Name()] = true + } + if !names["claude"] || !names["gemini"] { + t.Errorf("expected claude and gemini, got %v", names) + } +} + +func TestFilterRunners_NoneMatch(t *testing.T) { + available := []runners.Runner{ + mockRunner("claude", nil, nil), + } + got := filterRunners(available, []string{"nonexistent"}) + if len(got) != 0 { + t.Errorf("none match: got %d runners, want 0", len(got)) + } +} + +func TestFilterRunners_CaseInsensitive(t *testing.T) { + available := []runners.Runner{ + mockRunner("claude", nil, nil), + } + got := filterRunners(available, []string{"Claude"}) + // filterRunners lowercases names, so "Claude" matches "claude" in nameSet + // but runner.Name() returns "claude" which must match the lowered key + if len(got) != 1 { + t.Errorf("case insensitive: got %d runners, want 1", len(got)) + } +} + +// --------------------------------------------------------------------------- +// RunDispatch +// --------------------------------------------------------------------------- + +func TestRunDispatch_Success(t *testing.T) { + db := tempDB(t) + available := []runners.Runner{ + mockRunner("claude", []runners.RunResult{successResult("review done")}, nil), + mockRunner("codex", []runners.RunResult{successResult("codex done")}, nil), + } + + result, err := RunDispatch(context.Background(), db, available, DispatchConfig{ + Prompt: "test prompt", + RepoRoot: t.TempDir(), + }) + if err != nil { + t.Fatalf("RunDispatch: %v", err) + } + if result.Session.Workflow != "dispatch" { + t.Errorf("workflow = %q, want dispatch", result.Session.Workflow) + } + if len(result.Results) != 2 { + t.Fatalf("results = %d, want 2", len(result.Results)) + } + if result.Results[0].Output != "review done" { + t.Errorf("result[0] output = %q", result.Results[0].Output) + } + if result.Results[1].Output != "codex done" { + t.Errorf("result[1] output = %q", result.Results[1].Output) + } +} + +func TestRunDispatch_NoAgents(t *testing.T) { + db := tempDB(t) + _, err := RunDispatch(context.Background(), db, nil, DispatchConfig{ + Prompt: "test", + }) + if err == nil { + t.Fatal("expected error with no agents") + } + if !strings.Contains(err.Error(), "no agents available") { + t.Errorf("error = %q, want 'no agents available'", err) + } +} + +func TestRunDispatch_FilteredToNone(t *testing.T) { + db := tempDB(t) + available := []runners.Runner{ + mockRunner("claude", nil, nil), + } + _, err := RunDispatch(context.Background(), db, available, DispatchConfig{ + Prompt: "test", + Agents: []string{"nonexistent"}, + }) + if err == nil { + t.Fatal("expected error when all agents filtered out") + } +} + +func TestRunDispatch_AgentError(t *testing.T) { + db := tempDB(t) + available := []runners.Runner{ + mockRunner("claude", []runners.RunResult{{Output: ""}}, []error{fmt.Errorf("agent crashed")}), + } + + result, err := RunDispatch(context.Background(), db, available, DispatchConfig{ + Prompt: "test", + RepoRoot: t.TempDir(), + }) + if err != nil { + t.Fatalf("RunDispatch should not return error for agent-level failures: %v", err) + } + if result.Results[0].Error == nil { + t.Error("expected agent error in result") + } +} + +func TestRunDispatch_SessionPersisted(t *testing.T) { + db := tempDB(t) + available := []runners.Runner{ + mockRunner("claude", []runners.RunResult{successResult("ok")}, nil), + } + + result, err := RunDispatch(context.Background(), db, available, DispatchConfig{ + Prompt: "persist test", + RepoRoot: t.TempDir(), + }) + if err != nil { + t.Fatalf("RunDispatch: %v", err) + } + + session, err := db.GetSession(result.Session.ID) + if err != nil { + t.Fatalf("get session: %v", err) + } + if session.Status != "done" { + t.Errorf("session status = %q, want done", session.Status) + } +} + +// --------------------------------------------------------------------------- +// RunReview +// --------------------------------------------------------------------------- + +func TestRunReview_NoDiff(t *testing.T) { + db := tempDB(t) + _, git := initGitRepo(t) + available := []runners.Runner{ + mockRunner("claude", nil, nil), + } + + _, err := RunReview(context.Background(), db, available, git, ReviewConfig{ + RepoRoot: git.Dir, + }) + if err == nil { + t.Fatal("expected error with no diff") + } + if !strings.Contains(err.Error(), "no diff found") { + t.Errorf("error = %q, want 'no diff found'", err) + } +} + +func TestRunReview_NoAgents(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + // Create a diff by making a change on a new branch + makeChange(t, dir, git) + + _, err := RunReview(context.Background(), db, nil, git, ReviewConfig{ + RepoRoot: dir, + }) + if err == nil { + t.Fatal("expected error with no agents") + } + if !strings.Contains(err.Error(), "no agents available") { + t.Errorf("error = %q", err) + } +} + +func TestRunReview_Success(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + makeChange(t, dir, git) + + available := []runners.Runner{ + mockRunner("claude", []runners.RunResult{successResult("looks good")}, nil), + } + + result, err := RunReview(context.Background(), db, available, git, ReviewConfig{ + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunReview: %v", err) + } + if result.Session.Workflow != "review" { + t.Errorf("workflow = %q, want review", result.Session.Workflow) + } + if len(result.Results) != 1 { + t.Fatalf("results = %d, want 1", len(result.Results)) + } + if result.Results[0].Output != "looks good" { + t.Errorf("output = %q", result.Results[0].Output) + } +} + +func TestRunReview_SecurityFlag(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + makeChange(t, dir, git) + + // We verify the security prompt gets passed by checking the runner receives it. + // Since MockRunner doesn't expose prompts, we just verify no error and the session is created. + available := []runners.Runner{ + mockRunner("claude", []runners.RunResult{successResult("no security issues")}, nil), + } + + result, err := RunReview(context.Background(), db, available, git, ReviewConfig{ + RepoRoot: dir, + Security: true, + }) + if err != nil { + t.Fatalf("RunReview with security: %v", err) + } + if result.Session.Workflow != "review" { + t.Errorf("workflow = %q", result.Session.Workflow) + } +} + +func TestRunReview_DiffTruncation(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + // Create a large diff + largeContent := strings.Repeat("x", 40000) + if err := os.WriteFile(filepath.Join(dir, "large.txt"), []byte(largeContent), 0o644); err != nil { + t.Fatal(err) + } + run(t, dir, "git", "checkout", "-b", "test-branch") + run(t, dir, "git", "add", "-A") + run(t, dir, "git", "commit", "-m", "large change") + + available := []runners.Runner{ + mockRunner("claude", []runners.RunResult{successResult("reviewed")}, nil), + } + + result, err := RunReview(context.Background(), db, available, git, ReviewConfig{ + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunReview: %v", err) + } + if len(result.Results) != 1 { + t.Fatalf("results = %d, want 1", len(result.Results)) + } +} + +func TestRunReview_CustomPrompt(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + makeChange(t, dir, git) + + available := []runners.Runner{ + mockRunner("claude", []runners.RunResult{successResult("custom review")}, nil), + } + + result, err := RunReview(context.Background(), db, available, git, ReviewConfig{ + Prompt: "Focus on performance only", + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunReview: %v", err) + } + if result.Session.Prompt != "Focus on performance only" { + t.Errorf("prompt = %q", result.Session.Prompt) + } +} + +func TestRunReview_MultipleAgents(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + makeChange(t, dir, git) + + available := []runners.Runner{ + mockRunner("claude", []runners.RunResult{successResult("claude review")}, nil), + mockRunner("codex", []runners.RunResult{successResult("codex review")}, nil), + } + + result, err := RunReview(context.Background(), db, available, git, ReviewConfig{ + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunReview: %v", err) + } + if len(result.Results) != 2 { + t.Fatalf("results = %d, want 2", len(result.Results)) + } +} + +// --------------------------------------------------------------------------- +// RunBugfix +// --------------------------------------------------------------------------- + +func TestRunBugfix_Success(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + successResult("root cause: off by one in loop"), + successResult("applied fix to main.go"), + }, nil) + + result, err := RunBugfix(context.Background(), db, runner, git, BugfixConfig{ + Description: "tests fail with index out of range", + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunBugfix: %v", err) + } + if result.Session.Workflow != "bugfix" { + t.Errorf("workflow = %q, want bugfix", result.Session.Workflow) + } + if runner.CallCount() != 2 { + t.Errorf("runner calls = %d, want 2 (diagnose + fix)", runner.CallCount()) + } +} + +func TestRunBugfix_WithTestVerification(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + successResult("diagnosis"), + successResult("fix applied"), + }, nil) + + result, err := RunBugfix(context.Background(), db, runner, git, BugfixConfig{ + Description: "test bug", + TestCmd: "true", // always passes + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunBugfix: %v", err) + } + if result.Session.Workflow != "bugfix" { + t.Errorf("workflow = %q", result.Session.Workflow) + } +} + +func TestRunBugfix_TestFailsThenRepairs(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + // Write a file that the "fix" step might change + counterFile := filepath.Join(dir, "counter.txt") + os.WriteFile(counterFile, []byte("0"), 0o644) + + runner := mockRunner("claude", []runners.RunResult{ + successResult("diagnosis"), + successResult("initial fix"), + successResult("repair fix"), + }, nil) + + // Use a test command that fails first, then passes after a state change + // We use "true" since the runner mock doesn't actually modify files. + // The repair path is exercised when test fails — use "false" as testcmd + // to trigger the repair branch, but the repair will also fail since + // RunMetric with "false" always exits non-zero. + result, err := RunBugfix(context.Background(), db, runner, git, BugfixConfig{ + Description: "test bug", + TestCmd: "false", // always fails - triggers repair path + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunBugfix: %v", err) + } + // Should still complete (repair will be reverted but session finishes) + if result.Session == nil { + t.Fatal("expected session in result") + } +} + +func TestRunBugfix_DiagnoseStepFails(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", + []runners.RunResult{{}}, + []error{fmt.Errorf("agent timeout")}, + ) + + _, err := RunBugfix(context.Background(), db, runner, git, BugfixConfig{ + Description: "bug", + RepoRoot: dir, + }) + if err == nil { + t.Fatal("expected error when diagnose fails") + } + if !strings.Contains(err.Error(), "diagnose step failed") { + t.Errorf("error = %q", err) + } +} + +func TestRunBugfix_FixStepFails(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + successResult("diagnosis"), + {}, // fix step response (will be overridden by error) + }, []error{nil, fmt.Errorf("fix failed")}) + + _, err := RunBugfix(context.Background(), db, runner, git, BugfixConfig{ + Description: "bug", + RepoRoot: dir, + }) + if err == nil { + t.Fatal("expected error when fix fails") + } + if !strings.Contains(err.Error(), "fix step failed") { + t.Errorf("error = %q", err) + } +} + +func TestRunBugfix_BudgetExhaustedAfterDiagnosis(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + {Output: "diagnosis", CostUSD: 5.00}, // costs the whole budget + }, nil) + + result, err := RunBugfix(context.Background(), db, runner, git, BugfixConfig{ + Description: "bug", + RepoRoot: dir, + BudgetUSD: 5.00, + }) + if err != nil { + t.Fatalf("RunBugfix: %v", err) + } + // Should stop after diagnosis, only 1 runner call + if runner.CallCount() != 1 { + t.Errorf("runner calls = %d, want 1 (budget stop after diagnose)", runner.CallCount()) + } + if result.Session == nil { + t.Fatal("expected session") + } +} + +func TestRunBugfix_ZeroBudgetMeansUnlimited(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + successResult("diagnosis"), + successResult("fix"), + }, nil) + + _, err := RunBugfix(context.Background(), db, runner, git, BugfixConfig{ + Description: "bug", + RepoRoot: dir, + BudgetUSD: 0, // zero means unlimited + }) + if err != nil { + t.Fatalf("RunBugfix: %v", err) + } + if runner.CallCount() != 2 { + t.Errorf("runner calls = %d, want 2 (unlimited budget)", runner.CallCount()) + } +} + +// --------------------------------------------------------------------------- +// RunFeature +// --------------------------------------------------------------------------- + +func TestRunFeature_SuccessNoTests(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + successResult("1. Add endpoint\n2. Add handler"), + successResult("implemented both steps"), + }, nil) + + result, err := RunFeature(context.Background(), db, runner, git, FeatureConfig{ + Description: "add user API", + Target: "src/api/", + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunFeature: %v", err) + } + if result.Session.Workflow != "feature" { + t.Errorf("workflow = %q, want feature", result.Session.Workflow) + } + if runner.CallCount() != 2 { + t.Errorf("runner calls = %d, want 2 (plan + implement)", runner.CallCount()) + } +} + +func TestRunFeature_WithPassingTests(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + successResult("plan"), + successResult("implemented"), + }, nil) + + result, err := RunFeature(context.Background(), db, runner, git, FeatureConfig{ + Description: "feature", + Target: "src/", + TestCmd: "true", + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunFeature: %v", err) + } + if result.Session.Workflow != "feature" { + t.Errorf("workflow = %q", result.Session.Workflow) + } +} + +func TestRunFeature_TestsFailAllAttempts(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + // plan + implement + 3 fix attempts + runner := mockRunner("claude", []runners.RunResult{ + successResult("plan"), + successResult("implemented"), + successResult("fix attempt 1"), + successResult("fix attempt 2"), + successResult("fix attempt 3"), + }, nil) + + result, err := RunFeature(context.Background(), db, runner, git, FeatureConfig{ + Description: "feature", + Target: "src/", + TestCmd: "false", // always fails + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunFeature: %v", err) + } + // Implementation should be reverted since tests never pass + if result.Session == nil { + t.Fatal("expected session") + } +} + +func TestRunFeature_PlanFails(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{{}}, []error{fmt.Errorf("planning error")}) + + _, err := RunFeature(context.Background(), db, runner, git, FeatureConfig{ + Description: "feature", + Target: "src/", + RepoRoot: dir, + }) + if err == nil { + t.Fatal("expected error when plan fails") + } + if !strings.Contains(err.Error(), "plan step failed") { + t.Errorf("error = %q", err) + } +} + +func TestRunFeature_ImplementFails(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + successResult("plan"), + {}, // implement step response (overridden by error) + }, []error{nil, fmt.Errorf("implement crashed")}) + + _, err := RunFeature(context.Background(), db, runner, git, FeatureConfig{ + Description: "feature", + Target: "src/", + RepoRoot: dir, + }) + if err == nil { + t.Fatal("expected error when implement fails") + } + if !strings.Contains(err.Error(), "implement step failed") { + t.Errorf("error = %q", err) + } +} + +func TestRunFeature_BudgetExhaustedAfterPlan(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + {Output: "expensive plan", CostUSD: 10.00}, + }, nil) + + result, err := RunFeature(context.Background(), db, runner, git, FeatureConfig{ + Description: "feature", + Target: "src/", + RepoRoot: dir, + BudgetUSD: 10.00, + }) + if err != nil { + t.Fatalf("RunFeature: %v", err) + } + if runner.CallCount() != 1 { + t.Errorf("runner calls = %d, want 1 (budget stop after plan)", runner.CallCount()) + } + if result.Session == nil { + t.Fatal("expected session") + } +} + +func TestRunFeature_WithLintCmd(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + successResult("plan"), + successResult("implemented"), + }, nil) + + result, err := RunFeature(context.Background(), db, runner, git, FeatureConfig{ + Description: "feature", + Target: "src/", + LintCmd: "true", // lint passes + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunFeature: %v", err) + } + if result.Session.Workflow != "feature" { + t.Errorf("workflow = %q", result.Session.Workflow) + } +} + +func TestRunFeature_LintFails(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + // plan + implement + lint fix + runner := mockRunner("claude", []runners.RunResult{ + successResult("plan"), + successResult("implemented"), + successResult("lint fixed"), + }, nil) + + result, err := RunFeature(context.Background(), db, runner, git, FeatureConfig{ + Description: "feature", + Target: "src/", + LintCmd: "false", // lint fails + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunFeature: %v", err) + } + if result.Session == nil { + t.Fatal("expected session") + } +} + +// --------------------------------------------------------------------------- +// RunRefactor +// --------------------------------------------------------------------------- + +func TestRunRefactor_SuccessNoTests(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + successResult("smells: long method, duplicate code"), + successResult("applied extraction refactor"), + }, nil) + + result, err := RunRefactor(context.Background(), db, runner, git, RefactorConfig{ + Description: "clean up handler", + Target: "src/handler.go", + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunRefactor: %v", err) + } + if result.Session.Workflow != "refactor" { + t.Errorf("workflow = %q, want refactor", result.Session.Workflow) + } + if runner.CallCount() != 2 { + t.Errorf("runner calls = %d, want 2 (analyze + transform)", runner.CallCount()) + } +} + +func TestRunRefactor_WithPassingTests(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + successResult("analysis"), + successResult("refactored"), + }, nil) + + result, err := RunRefactor(context.Background(), db, runner, git, RefactorConfig{ + Description: "refactor", + Target: "src/", + TestCmd: "true", + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunRefactor: %v", err) + } + if result.Session.Workflow != "refactor" { + t.Errorf("workflow = %q", result.Session.Workflow) + } +} + +func TestRunRefactor_TestsBreakAfterRefactor(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + successResult("analysis"), + successResult("refactored"), + }, nil) + + result, err := RunRefactor(context.Background(), db, runner, git, RefactorConfig{ + Description: "refactor", + Target: "src/", + TestCmd: "false", // tests always fail -> reverts + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunRefactor: %v", err) + } + if result.Session == nil { + t.Fatal("expected session") + } +} + +func TestRunRefactor_AnalyzeFails(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{{}}, []error{fmt.Errorf("analyze crash")}) + + _, err := RunRefactor(context.Background(), db, runner, git, RefactorConfig{ + Description: "refactor", + Target: "src/", + RepoRoot: dir, + }) + if err == nil { + t.Fatal("expected error when analyze fails") + } + if !strings.Contains(err.Error(), "analyze step failed") { + t.Errorf("error = %q", err) + } +} + +func TestRunRefactor_TransformFails(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + successResult("analysis"), + {}, // transform step response (overridden by error) + }, []error{nil, fmt.Errorf("transform crashed")}) + + _, err := RunRefactor(context.Background(), db, runner, git, RefactorConfig{ + Description: "refactor", + Target: "src/", + RepoRoot: dir, + }) + if err == nil { + t.Fatal("expected error when transform fails") + } + if !strings.Contains(err.Error(), "transform step failed") { + t.Errorf("error = %q", err) + } +} + +func TestRunRefactor_BudgetExhaustedAfterAnalysis(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + {Output: "expensive analysis", CostUSD: 5.00}, + }, nil) + + result, err := RunRefactor(context.Background(), db, runner, git, RefactorConfig{ + Description: "refactor", + Target: "src/", + RepoRoot: dir, + BudgetUSD: 5.00, + }) + if err != nil { + t.Fatalf("RunRefactor: %v", err) + } + if runner.CallCount() != 1 { + t.Errorf("runner calls = %d, want 1", runner.CallCount()) + } + if result.Session == nil { + t.Fatal("expected session") + } +} + +// --------------------------------------------------------------------------- +// RunTestGen +// --------------------------------------------------------------------------- + +func TestRunTestGen_SuccessNoTestCmd(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + successResult("generated test_handler_test.go"), + }, nil) + + result, err := RunTestGen(context.Background(), db, runner, git, TestGenConfig{ + Target: "src/handler.go", + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunTestGen: %v", err) + } + if result.Session.Workflow != "test-gen" { + t.Errorf("workflow = %q, want test-gen", result.Session.Workflow) + } + if runner.CallCount() != 1 { + t.Errorf("runner calls = %d, want 1", runner.CallCount()) + } +} + +func TestRunTestGen_WithPassingTests(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + successResult("tests generated"), + }, nil) + + result, err := RunTestGen(context.Background(), db, runner, git, TestGenConfig{ + Target: "src/", + TestCmd: "true", + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunTestGen: %v", err) + } + + session, err := db.GetSession(result.Session.ID) + if err != nil { + t.Fatalf("get session: %v", err) + } + if session.Status != "done" { + t.Errorf("status = %q, want done", session.Status) + } +} + +func TestRunTestGen_TestsFailAllAttempts(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + // generate + 5 fix attempts + runner := mockRunner("claude", []runners.RunResult{ + successResult("tests generated"), + successResult("fix 1"), + successResult("fix 2"), + successResult("fix 3"), + successResult("fix 4"), + successResult("fix 5"), + }, nil) + + result, err := RunTestGen(context.Background(), db, runner, git, TestGenConfig{ + Target: "src/", + TestCmd: "false", // always fails + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunTestGen: %v", err) + } + + session, err := db.GetSession(result.Session.ID) + if err != nil { + t.Fatalf("get session: %v", err) + } + if session.Status != "failed" { + t.Errorf("status = %q, want failed (tests never passed)", session.Status) + } +} + +func TestRunTestGen_GenerateStepFails(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{{}}, []error{fmt.Errorf("gen failed")}) + + _, err := RunTestGen(context.Background(), db, runner, git, TestGenConfig{ + Target: "src/", + RepoRoot: dir, + }) + if err == nil { + t.Fatal("expected error when generate fails") + } + if !strings.Contains(err.Error(), "generate step failed") { + t.Errorf("error = %q", err) + } +} + +func TestRunTestGen_BudgetExhausted(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + {Output: "tests generated", CostUSD: 5.00}, + }, nil) + + result, err := RunTestGen(context.Background(), db, runner, git, TestGenConfig{ + Target: "src/", + TestCmd: "false", // would trigger fix attempts but budget is exhausted + RepoRoot: dir, + BudgetUSD: 5.00, + }) + if err != nil { + t.Fatalf("RunTestGen: %v", err) + } + // Should not attempt fixes because budget is exhausted + if runner.CallCount() != 1 { + t.Errorf("runner calls = %d, want 1 (budget stop)", runner.CallCount()) + } + if result.Session == nil { + t.Fatal("expected session") + } +} + +func TestRunTestGen_ContextCancelled(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + ctx, cancel := context.WithCancel(context.Background()) + + runner := mockRunner("claude", []runners.RunResult{ + successResult("tests generated"), + }, nil) + + // Cancel after generate step + cancel() + + result, err := RunTestGen(ctx, db, runner, git, TestGenConfig{ + Target: "src/", + TestCmd: "false", + RepoRoot: dir, + }) + // Generate step may or may not fail depending on timing, but should not panic + if err != nil { + // acceptable: generate step may fail due to cancelled context + return + } + if result.Session == nil { + t.Fatal("expected session") + } +} + +// --------------------------------------------------------------------------- +// RunImproveLoop +// --------------------------------------------------------------------------- + +func TestRunImproveLoop_SingleIterationPasses(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + successResult("improved code"), + }, nil) + + result, err := RunImproveLoop(context.Background(), db, runner, git, ImproveConfig{ + Target: "src/", + Metric: "true", // always passes + Objective: "improve performance", + MaxIterations: 1, + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunImproveLoop: %v", err) + } + if result.Session.Workflow != "improve" { + t.Errorf("workflow = %q, want improve", result.Session.Workflow) + } + if result.StopReason != "completed" { + t.Errorf("stop reason = %q, want completed", result.StopReason) + } +} + +func TestRunImproveLoop_AllIterationsReverted(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + successResult("change 1"), + successResult("change 2"), + successResult("change 3"), + }, nil) + + result, err := RunImproveLoop(context.Background(), db, runner, git, ImproveConfig{ + Target: "src/", + Metric: "false", // always fails -> all reverted + Objective: "fix tests", + MaxIterations: 5, + MaxFailures: 3, + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunImproveLoop: %v", err) + } + if !strings.Contains(result.StopReason, "consecutive failures") { + t.Errorf("stop reason = %q, want consecutive failures", result.StopReason) + } +} + +func TestRunImproveLoop_BudgetExhausted(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + {Output: "expensive change", CostUSD: 10.00}, + }, nil) + + result, err := RunImproveLoop(context.Background(), db, runner, git, ImproveConfig{ + Target: "src/", + Metric: "true", + Objective: "improve", + MaxIterations: 10, + BudgetUSD: 10.00, + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunImproveLoop: %v", err) + } + if !strings.Contains(result.StopReason, "budget exhausted") { + t.Errorf("stop reason = %q, want budget exhausted", result.StopReason) + } +} + +func TestRunImproveLoop_ContextCancelled(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + runner := mockRunner("claude", []runners.RunResult{ + successResult("change"), + }, nil) + + result, err := RunImproveLoop(ctx, db, runner, git, ImproveConfig{ + Target: "src/", + Metric: "true", + Objective: "improve", + MaxIterations: 5, + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunImproveLoop: %v", err) + } + if result.StopReason != "interrupted" { + t.Errorf("stop reason = %q, want interrupted", result.StopReason) + } +} + +func TestRunImproveLoop_AgentError(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + {}, {}, {}, + }, []error{ + fmt.Errorf("fail 1"), + fmt.Errorf("fail 2"), + fmt.Errorf("fail 3"), + }) + + result, err := RunImproveLoop(context.Background(), db, runner, git, ImproveConfig{ + Target: "src/", + Metric: "true", + Objective: "improve", + MaxIterations: 10, + MaxFailures: 3, + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunImproveLoop: %v", err) + } + if !strings.Contains(result.StopReason, "consecutive failures") { + t.Errorf("stop reason = %q", result.StopReason) + } +} + +func TestRunImproveLoop_DefaultMaxFailures(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + runner := mockRunner("claude", []runners.RunResult{ + {}, {}, {}, + }, []error{ + fmt.Errorf("fail 1"), + fmt.Errorf("fail 2"), + fmt.Errorf("fail 3"), + }) + + result, err := RunImproveLoop(context.Background(), db, runner, git, ImproveConfig{ + Target: "src/", + Metric: "true", + Objective: "improve", + MaxIterations: 10, + MaxFailures: 0, // should default to 3 + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunImproveLoop: %v", err) + } + // With default MaxFailures=3, should stop after 3 consecutive failures + if !strings.Contains(result.StopReason, "3 consecutive failures") { + t.Errorf("stop reason = %q, want 3 consecutive failures", result.StopReason) + } +} + +func TestRunImproveLoop_MixedKeptAndReverted(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + // First passes, second fails, third passes + runner := mockRunner("claude", []runners.RunResult{ + successResult("good change"), + successResult("bad change"), + successResult("good change 2"), + }, nil) + + // We need a metric that alternates. Use a file-based approach: + // "true" always passes, so all will be kept. Test with "true". + result, err := RunImproveLoop(context.Background(), db, runner, git, ImproveConfig{ + Target: "src/", + Metric: "true", + Objective: "improve", + MaxIterations: 3, + RepoRoot: dir, + }) + if err != nil { + t.Fatalf("RunImproveLoop: %v", err) + } + if result.StopReason != "completed" { + t.Errorf("stop reason = %q, want completed", result.StopReason) + } + if runner.CallCount() != 3 { + t.Errorf("runner calls = %d, want 3", runner.CallCount()) + } +} + +// --------------------------------------------------------------------------- +// ResumeImproveLoop +// --------------------------------------------------------------------------- + +func TestResumeImproveLoop(t *testing.T) { + db := tempDB(t) + dir, git := initGitRepo(t) + + // Create an existing session with 2 completed steps + session := &lib.Session{ + ID: lib.NewSessionID(), + Workflow: "improve", + Target: "src/", + Metric: "true", + Objective: "improve perf", + MaxIterations: 5, + BudgetUSD: 10.00, + Status: "paused", + } + if err := db.CreateSession(session); err != nil { + t.Fatalf("create session: %v", err) + } + for i := 1; i <= 2; i++ { + step := &lib.Step{SessionID: session.ID, Iteration: i, Status: "kept", AgentName: "claude", CostUSD: 0.50} + if err := db.CreateStep(step); err != nil { + t.Fatalf("create step: %v", err) + } + } + + // Create the branch the session expects + branchName := fmt.Sprintf("self-improve/%s", session.ID) + run(t, dir, "git", "checkout", "-b", branchName) + + runner := mockRunner("claude", []runners.RunResult{ + successResult("iteration 3"), + successResult("iteration 4"), + successResult("iteration 5"), + }, nil) + + result, err := ResumeImproveLoop(context.Background(), db, runner, git, session, dir) + if err != nil { + t.Fatalf("ResumeImproveLoop: %v", err) + } + if result.StopReason != "completed" { + t.Errorf("stop reason = %q, want completed", result.StopReason) + } + // Should resume from iteration 3 (after 2 completed) + if runner.CallCount() != 3 { + t.Errorf("runner calls = %d, want 3 (iterations 3-5)", runner.CallCount()) + } +} + +// --------------------------------------------------------------------------- +// buildImprovePrompt +// --------------------------------------------------------------------------- + +func TestBuildImprovePrompt(t *testing.T) { + cfg := ImproveConfig{ + Target: "src/handler.go", + Objective: "reduce latency", + Metric: "go test -bench .", + } + prompt := buildImprovePrompt(cfg) + + if !strings.Contains(prompt, "src/handler.go") { + t.Error("prompt should contain target") + } + if !strings.Contains(prompt, "reduce latency") { + t.Error("prompt should contain objective") + } + if !strings.Contains(prompt, "go test -bench .") { + t.Error("prompt should contain metric") + } +} + +func TestBuildImprovePrompt_EmptyFields(t *testing.T) { + prompt := buildImprovePrompt(ImproveConfig{}) + if prompt == "" { + t.Error("prompt should not be empty even with empty config") + } +} + +// --------------------------------------------------------------------------- +// git helper for review tests +// --------------------------------------------------------------------------- + +func makeChange(t *testing.T, dir string, git *lib.Git) { + t.Helper() + run(t, dir, "git", "checkout", "-b", "test-review-branch") + if err := os.WriteFile(filepath.Join(dir, "changed.txt"), []byte("new content\n"), 0o644); err != nil { + t.Fatal(err) + } + run(t, dir, "git", "add", "-A") + run(t, dir, "git", "commit", "-m", "test change") +} + +func run(t *testing.T, dir string, name string, args ...string) { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s %v: %s", name, args, out) + } +}