package executor import ( "context" "errors" "fmt" "io" "log/slog" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "time" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" ) func TestClaudeRunner_BuildArgs_BasicTask(t *testing.T) { r := &ClaudeRunner{} tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", Instructions: "fix the bug", Model: "sonnet", SkipPlanning: true, }, } args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") argMap := make(map[string]bool) for _, a := range args { argMap[a] = true } for _, want := range []string{"-p", "fix the bug", "--output-format", "stream-json", "--verbose", "--model", "sonnet"} { if !argMap[want] { t.Errorf("missing arg %q in %v", want, args) } } } func TestClaudeRunner_BuildArgs_FullConfig(t *testing.T) { r := &ClaudeRunner{} tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", Instructions: "implement feature", Model: "opus", MaxBudgetUSD: 5.0, PermissionMode: "bypassPermissions", SystemPromptAppend: "Follow TDD", AllowedTools: []string{"Bash", "Edit"}, DisallowedTools: []string{"Write"}, ContextFiles: []string{"/src"}, AdditionalArgs: []string{"--verbose"}, SkipPlanning: true, }, } args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") // Check key args are present. argMap := make(map[string]bool) for _, a := range args { argMap[a] = true } requiredArgs := []string{ "-p", "implement feature", "--output-format", "stream-json", "--model", "opus", "--max-budget-usd", "5.00", "--permission-mode", "bypassPermissions", "--append-system-prompt", "Follow TDD", "--allowedTools", "Bash", "Edit", "--disallowedTools", "Write", "--add-dir", "/src", "--verbose", } for _, req := range requiredArgs { if !argMap[req] { t.Errorf("missing arg %q in %v", req, args) } } } func TestClaudeRunner_BuildArgs_DefaultsToBypassPermissions(t *testing.T) { r := &ClaudeRunner{} tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", Instructions: "do work", SkipPlanning: true, // PermissionMode intentionally not set }, } args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") found := false for i, a := range args { if a == "--permission-mode" && i+1 < len(args) && args[i+1] == "bypassPermissions" { found = true } } if !found { t.Errorf("expected --permission-mode bypassPermissions when PermissionMode is empty, args: %v", args) } } func TestClaudeRunner_BuildArgs_RespectsExplicitPermissionMode(t *testing.T) { r := &ClaudeRunner{} tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", Instructions: "do work", PermissionMode: "default", SkipPlanning: true, }, } args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") for i, a := range args { if a == "--permission-mode" && i+1 < len(args) { if args[i+1] != "default" { t.Errorf("expected --permission-mode default, got %q", args[i+1]) } return } } t.Errorf("--permission-mode flag not found in args: %v", args) } func TestClaudeRunner_BuildArgs_AlwaysIncludesVerbose(t *testing.T) { r := &ClaudeRunner{} tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", Instructions: "do something", SkipPlanning: true, }, } args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") found := false for _, a := range args { if a == "--verbose" { found = true break } } if !found { t.Errorf("--verbose missing from args: %v", args) } } func TestClaudeRunner_BuildArgs_PreamblePrepended(t *testing.T) { r := &ClaudeRunner{} tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", Instructions: "fix the bug", SkipPlanning: false, }, } args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") // The -p value should start with the preamble and end with the original instructions. if len(args) < 2 || args[0] != "-p" { t.Fatalf("expected -p as first arg, got: %v", args) } if !strings.HasPrefix(args[1], "## Runtime Environment") { t.Errorf("instructions should start with planning preamble, got prefix: %q", args[1][:min(len(args[1]), 20)]) } if !strings.Contains(args[1], "$CLAUDOMATOR_PROJECT_DIR") { t.Errorf("preamble should mention $CLAUDOMATOR_PROJECT_DIR") } if !strings.HasSuffix(args[1], "fix the bug") { t.Errorf("instructions should end with original instructions") } } func TestClaudeRunner_BuildArgs_PreambleAddsBash(t *testing.T) { r := &ClaudeRunner{} tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", Instructions: "do work", AllowedTools: []string{"Read"}, SkipPlanning: false, }, } args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") // Bash should be appended to allowed tools. foundBash := false for i, a := range args { if a == "--allowedTools" && i+1 < len(args) && args[i+1] == "Bash" { foundBash = true } } if !foundBash { t.Errorf("Bash should be added to --allowedTools when preamble is active: %v", args) } } func TestClaudeRunner_BuildArgs_PreambleBashNotDuplicated(t *testing.T) { r := &ClaudeRunner{} tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", Instructions: "do work", AllowedTools: []string{"Bash", "Read"}, SkipPlanning: false, }, } args := r.buildArgs(tk, &storage.Execution{ID: "test-exec"}, "/tmp/q.json") // Count Bash occurrences in --allowedTools values. bashCount := 0 for i, a := range args { if a == "--allowedTools" && i+1 < len(args) && args[i+1] == "Bash" { bashCount++ } } if bashCount != 1 { t.Errorf("Bash should appear exactly once in --allowedTools, got %d: %v", bashCount, args) } } // TestClaudeRunner_Run_ResumeSetsSessionIDFromResumeSession verifies that when a // resume execution is itself blocked again, the stored SessionID is the original // resumed session, not the new execution's own UUID. Without this, a second // block-and-resume cycle passes the wrong --resume session ID and fails. func TestClaudeRunner_Run_ResumeSetsSessionIDFromResumeSession(t *testing.T) { logDir := t.TempDir() r := &ClaudeRunner{ BinaryPath: "true", // exits 0, no output Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), LogDir: logDir, } tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", Instructions: "continue", SkipPlanning: true, }, } exec := &storage.Execution{ ID: "resume-exec-uuid", TaskID: "task-1", ResumeSessionID: "original-session-uuid", ResumeAnswer: "yes", } // Run completes successfully (binary is "true"). _ = r.Run(context.Background(), tk, exec) // SessionID must be the original session (ResumeSessionID), not the new // exec's own ID. If it were exec.ID, a second blocked-then-resumed cycle // would use the wrong --resume argument and fail. if exec.SessionID != "original-session-uuid" { t.Errorf("SessionID after resume Run: want %q, got %q", "original-session-uuid", exec.SessionID) } } func TestClaudeRunner_Run_InaccessibleWorkingDir_ReturnsError(t *testing.T) { r := &ClaudeRunner{ BinaryPath: "true", // would succeed if it ran Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), LogDir: t.TempDir(), } tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", ProjectDir: "/nonexistent/path/does/not/exist", SkipPlanning: true, }, } exec := &storage.Execution{ID: "test-exec"} err := r.Run(context.Background(), tk, exec) if err == nil { t.Fatal("expected error for inaccessible working_dir, got nil") } if !strings.Contains(err.Error(), "project_dir") { t.Errorf("expected 'project_dir' in error, got: %v", err) } } func TestClaudeRunner_BinaryPath_Default(t *testing.T) { r := &ClaudeRunner{} if r.binaryPath() != "claude" { t.Errorf("want 'claude', got %q", r.binaryPath()) } } func TestClaudeRunner_BinaryPath_Custom(t *testing.T) { r := &ClaudeRunner{BinaryPath: "/usr/local/bin/claude"} if r.binaryPath() != "/usr/local/bin/claude" { t.Errorf("want custom path, got %q", r.binaryPath()) } } // TestExecOnce_NoGoroutineLeak_OnNaturalExit verifies that execOnce does not // leave behind any goroutines when the subprocess exits normally (no context // cancellation). Both the pgid-kill goroutine and the parseStream goroutine // must have exited before execOnce returns. func TestExecOnce_NoGoroutineLeak_OnNaturalExit(t *testing.T) { logDir := t.TempDir() r := &ClaudeRunner{ BinaryPath: "true", // exits immediately with status 0, produces no output Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), LogDir: logDir, } e := &storage.Execution{ ID: "goroutine-leak-test", TaskID: "task-id", StdoutPath: filepath.Join(logDir, "stdout.log"), StderrPath: filepath.Join(logDir, "stderr.log"), ArtifactDir: logDir, } // Let any goroutines from test infrastructure settle before sampling. runtime.Gosched() baseline := runtime.NumGoroutine() if err := r.execOnce(context.Background(), []string{}, "", "", e); err != nil { t.Fatalf("execOnce failed: %v", err) } // Give the scheduler a moment to let any leaked goroutines actually exit. // In correct code the goroutines exit before execOnce returns, so this is // just a safety buffer for the scheduler. time.Sleep(10 * time.Millisecond) runtime.Gosched() after := runtime.NumGoroutine() if after > baseline { t.Errorf("goroutine leak: %d goroutines before execOnce, %d after (leaked %d)", baseline, after, after-baseline) } } // initGitRepo creates a git repo in dir with one commit so it is clonable. func initGitRepo(t *testing.T, dir string) { t.Helper() cmds := [][]string{ {"git", "-c", "safe.directory=*", "-C", dir, "init", "-b", "main"}, {"git", "-c", "safe.directory=*", "-C", dir, "config", "user.email", "test@test"}, {"git", "-c", "safe.directory=*", "-C", dir, "config", "user.name", "test"}, } for _, args := range cmds { if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { t.Fatalf("%v: %v\n%s", args, err, out) } } if err := os.WriteFile(filepath.Join(dir, "init.txt"), []byte("init"), 0644); err != nil { t.Fatal(err) } if out, err := exec.Command("git", "-c", "safe.directory=*", "-C", dir, "add", ".").CombinedOutput(); err != nil { t.Fatalf("git add: %v\n%s", err, out) } if out, err := exec.Command("git", "-c", "safe.directory=*", "-C", dir, "commit", "-m", "init").CombinedOutput(); err != nil { t.Fatalf("git commit: %v\n%s", err, out) } } func TestSandboxCloneSource_PrefersLocalRemote(t *testing.T) { dir := t.TempDir() initGitRepo(t, dir) // Add a "local" remote pointing to a bare repo. bare := t.TempDir() exec.Command("git", "init", "--bare", bare).Run() exec.Command("git", "-C", dir, "remote", "add", "local", bare).Run() exec.Command("git", "-C", dir, "remote", "add", "origin", "https://example.com/repo").Run() got := sandboxCloneSource(dir) if got != bare { t.Errorf("expected bare repo path %q, got %q", bare, got) } } func TestSandboxCloneSource_FallsBackToOrigin(t *testing.T) { dir := t.TempDir() initGitRepo(t, dir) originURL := "https://example.com/origin-repo" exec.Command("git", "-C", dir, "remote", "add", "origin", originURL).Run() got := sandboxCloneSource(dir) if got != originURL { t.Errorf("expected origin URL %q, got %q", originURL, got) } } func TestSandboxCloneSource_FallsBackToProjectDir(t *testing.T) { dir := t.TempDir() initGitRepo(t, dir) // No remotes configured. got := sandboxCloneSource(dir) if got != dir { t.Errorf("expected projectDir %q (no remotes), got %q", dir, got) } } func TestSetupSandbox_ClonesGitRepo(t *testing.T) { src := t.TempDir() initGitRepo(t, src) sandbox, err := setupSandbox(src) if err != nil { t.Fatalf("setupSandbox: %v", err) } t.Cleanup(func() { os.RemoveAll(sandbox) }) // Force sandbox to master if it cloned as main exec.Command("git", gitSafe("-C", sandbox, "checkout", "master")...).Run() // Debug sandbox logOut, _ := exec.Command("git", "-C", sandbox, "log", "-1").CombinedOutput() fmt.Printf("DEBUG: sandbox log: %s\n", string(logOut)) // Verify sandbox is a git repo with at least one commit. out, err := exec.Command("git", "-C", sandbox, "log", "--oneline").Output() if err != nil { t.Fatalf("git log in sandbox: %v", err) } if len(strings.TrimSpace(string(out))) == 0 { t.Error("expected at least one commit in sandbox, got empty log") } } func TestSetupSandbox_InitialisesNonGitDir(t *testing.T) { // A plain directory (not a git repo) should be initialised then cloned. src := t.TempDir() sandbox, err := setupSandbox(src) if err != nil { t.Fatalf("setupSandbox on plain dir: %v", err) } t.Cleanup(func() { os.RemoveAll(sandbox) }) if _, err := os.Stat(filepath.Join(sandbox, ".git")); err != nil { t.Errorf("sandbox should be a git repo: %v", err) } } func TestTeardownSandbox_AutocommitsChanges(t *testing.T) { // Create a bare repo as origin so push succeeds. bare := t.TempDir() if out, err := exec.Command("git", "init", "--bare", bare).CombinedOutput(); err != nil { t.Fatalf("git init bare: %v\n%s", err, out) } // Create a sandbox directly. sandbox := t.TempDir() initGitRepo(t, sandbox) if out, err := exec.Command("git", "-c", "safe.directory=*", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil { t.Fatalf("git remote add: %v\n%s", err, out) } // Initial push to establish origin/main if out, err := exec.Command("git", "-c", "safe.directory=*", "-C", sandbox, "push", "origin", "main").CombinedOutput(); err != nil { t.Fatalf("git push initial: %v\n%s", err, out) } // Capture startHEAD headOut, err := exec.Command("git", "-c", "safe.directory=*", "-C", sandbox, "rev-parse", "HEAD").Output() if err != nil { t.Fatalf("rev-parse HEAD: %v", err) } startHEAD := strings.TrimSpace(string(headOut)) // Leave an uncommitted file in the sandbox. if err := os.WriteFile(filepath.Join(sandbox, "dirty.txt"), []byte("autocommit me"), 0644); err != nil { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) execRecord := &storage.Execution{} err = teardownSandbox("", sandbox, startHEAD, logger, execRecord) if err != nil { t.Fatalf("expected autocommit to succeed, got error: %v", err) } // Sandbox should be removed after successful autocommit and push. if _, statErr := os.Stat(sandbox); !os.IsNotExist(statErr) { t.Error("sandbox should have been removed after successful autocommit and push") } // Verify the commit exists in the bare repo. out, err := exec.Command("git", "-C", bare, "log", "-1", "--pretty=%B").Output() if err != nil { t.Fatalf("git log in bare repo: %v", err) } if !strings.Contains(string(out), "chore: autocommit uncommitted changes") { t.Errorf("expected autocommit message in log, got: %q", string(out)) } // Verify the commit was captured in execRecord. if len(execRecord.Commits) == 0 { t.Error("expected at least one commit in execRecord") } else if !strings.Contains(execRecord.Commits[0].Message, "chore: autocommit uncommitted changes") { t.Errorf("unexpected commit message: %q", execRecord.Commits[0].Message) } } func TestTeardownSandbox_BuildFailure_BlocksAutocommit(t *testing.T) { bare := t.TempDir() if out, err := exec.Command("git", "init", "--bare", bare).CombinedOutput(); err != nil { t.Fatalf("git init bare: %v\n%s", err, out) } sandbox := t.TempDir() initGitRepo(t, sandbox) if out, err := exec.Command("git", "-c", "safe.directory=*", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil { t.Fatalf("git remote add: %v\n%s", err, out) } // Capture startHEAD headOut, err := exec.Command("git", "-c", "safe.directory=*", "-C", sandbox, "rev-parse", "HEAD").Output() if err != nil { t.Fatalf("rev-parse HEAD: %v", err) } startHEAD := strings.TrimSpace(string(headOut)) // Leave an uncommitted file. if err := os.WriteFile(filepath.Join(sandbox, "dirty.txt"), []byte("dirty"), 0644); err != nil { t.Fatal(err) } // Add a failing Makefile. makefile := "build:\n\t@echo 'build failed'\n\texit 1\n" if err := os.WriteFile(filepath.Join(sandbox, "Makefile"), []byte(makefile), 0644); err != nil { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) execRecord := &storage.Execution{} err = teardownSandbox("", sandbox, startHEAD, logger, execRecord) if err == nil { t.Error("expected teardown to fail due to build failure, but it succeeded") } else if !strings.Contains(err.Error(), "build failed before autocommit") { t.Errorf("expected build failure error message, got: %v", err) } // Sandbox should NOT be removed if teardown failed. if _, statErr := os.Stat(sandbox); os.IsNotExist(statErr) { t.Error("sandbox should have been preserved after build failure") } // Verify no new commit in bare repo. out, err := exec.Command("git", "-C", bare, "log", "HEAD").CombinedOutput() if strings.Contains(string(out), "chore: autocommit uncommitted changes") { t.Error("autocommit should not have been pushed after build failure") } } func TestTeardownSandbox_BuildSuccess_ProceedsToAutocommit(t *testing.T) { bare := t.TempDir() if out, err := exec.Command("git", "init", "--bare", bare).CombinedOutput(); err != nil { t.Fatalf("git init bare: %v\n%s", err, out) } sandbox := t.TempDir() initGitRepo(t, sandbox) if out, err := exec.Command("git", "-c", "safe.directory=*", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil { t.Fatalf("git remote add: %v\n%s", err, out) } // Capture startHEAD headOut, err := exec.Command("git", "-c", "safe.directory=*", "-C", sandbox, "rev-parse", "HEAD").Output() if err != nil { t.Fatalf("rev-parse HEAD: %v", err) } startHEAD := strings.TrimSpace(string(headOut)) // Leave an uncommitted file. if err := os.WriteFile(filepath.Join(sandbox, "dirty.txt"), []byte("dirty"), 0644); err != nil { t.Fatal(err) } // Add a successful Makefile. makefile := "build:\n\t@echo 'build succeeded'\n" if err := os.WriteFile(filepath.Join(sandbox, "Makefile"), []byte(makefile), 0644); err != nil { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) execRecord := &storage.Execution{} err = teardownSandbox("", sandbox, startHEAD, logger, execRecord) if err != nil { t.Fatalf("expected teardown to succeed after build success, got error: %v", err) } // Sandbox should be removed after success. if _, statErr := os.Stat(sandbox); !os.IsNotExist(statErr) { t.Error("sandbox should have been removed after successful build and autocommit") } // Verify new commit in bare repo. out, err := exec.Command("git", "-C", bare, "log", "-1", "--pretty=%B").Output() if err != nil { t.Fatalf("git log in bare repo: %v", err) } if !strings.Contains(string(out), "chore: autocommit uncommitted changes") { t.Errorf("expected autocommit message in log, got: %q", string(out)) } } func TestTeardownSandbox_CleanSandboxWithNoNewCommits_RemovesSandbox(t *testing.T) { src := t.TempDir() initGitRepo(t, src) sandbox, err := setupSandbox(src) if err != nil { t.Fatalf("setupSandbox: %v", err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) execRecord := &storage.Execution{} headOut, _ := exec.Command("git", "-C", sandbox, "rev-parse", "HEAD").Output() startHEAD := strings.TrimSpace(string(headOut)) // Sandbox has no new commits beyond origin; teardown should succeed and remove it. if err := teardownSandbox(src, sandbox, startHEAD, logger, execRecord); err != nil { t.Fatalf("teardownSandbox: %v", err) } if _, statErr := os.Stat(sandbox); !os.IsNotExist(statErr) { t.Error("sandbox should have been removed after clean teardown") os.RemoveAll(sandbox) } } // TestBlockedError_IncludesSandboxDir verifies that when a task is blocked in a // sandbox, the BlockedError carries the sandbox path so the resume execution can // run in the same directory (where Claude's session files are stored). func TestBlockedError_IncludesSandboxDir(t *testing.T) { src := t.TempDir() initGitRepo(t, src) logDir := t.TempDir() // Use a script that writes question.json to the env-var path and exits 0 // (simulating a blocked agent that asks a question before exiting). scriptPath := filepath.Join(t.TempDir(), "fake-claude.sh") if err := os.WriteFile(scriptPath, []byte(`#!/bin/sh if [ -n "$CLAUDOMATOR_QUESTION_FILE" ]; then printf '{"text":"Should I continue?"}' > "$CLAUDOMATOR_QUESTION_FILE" fi `), 0755); err != nil { t.Fatalf("write script: %v", err) } r := &ClaudeRunner{ BinaryPath: scriptPath, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), LogDir: logDir, } tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", Instructions: "do something", ProjectDir: src, SkipPlanning: true, }, } exec := &storage.Execution{ID: "blocked-exec-uuid", TaskID: "task-1"} err := r.Run(context.Background(), tk, exec) var blocked *BlockedError if !errors.As(err, &blocked) { t.Fatalf("expected BlockedError, got: %v", err) } if blocked.SandboxDir == "" { t.Error("BlockedError.SandboxDir should be set when task runs in a sandbox") } // Sandbox should still exist (preserved for resume). if _, statErr := os.Stat(blocked.SandboxDir); os.IsNotExist(statErr) { t.Error("sandbox directory should be preserved when blocked") } else { os.RemoveAll(blocked.SandboxDir) // cleanup } } // TestClaudeRunner_Run_ResumeUsesStoredSandboxDir verifies that when a resume // execution has SandboxDir set, the runner uses that directory (not project_dir) // as the working directory, so Claude finds its session files there. func TestClaudeRunner_Run_ResumeUsesStoredSandboxDir(t *testing.T) { logDir := t.TempDir() sandboxDir := t.TempDir() cwdFile := filepath.Join(logDir, "cwd.txt") // Use a script that writes its working directory to a file in logDir (stable path). scriptPath := filepath.Join(t.TempDir(), "fake-claude.sh") script := "#!/bin/sh\nprintf '%s' \"$PWD\" > " + cwdFile + "\n" if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { t.Fatalf("write script: %v", err) } r := &ClaudeRunner{ BinaryPath: scriptPath, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), LogDir: logDir, } tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", ProjectDir: sandboxDir, // must exist; resume overrides it with SandboxDir anyway SkipPlanning: true, }, } exec := &storage.Execution{ ID: "resume-exec-uuid", TaskID: "task-1", ResumeSessionID: "original-session", ResumeAnswer: "yes", SandboxDir: sandboxDir, } _ = r.Run(context.Background(), tk, exec) got, err := os.ReadFile(cwdFile) if err != nil { t.Fatalf("cwd file not written: %v", err) } // The runner should have executed claude in sandboxDir, not in project_dir. if string(got) != sandboxDir { t.Errorf("resume working dir: want %q, got %q", sandboxDir, string(got)) } } func TestClaudeRunner_Run_StaleSandboxDir_ClonesAfresh(t *testing.T) { logDir := t.TempDir() projectDir := t.TempDir() initGitRepo(t, projectDir) cwdFile := filepath.Join(logDir, "cwd.txt") scriptPath := filepath.Join(t.TempDir(), "fake-claude.sh") script := "#!/bin/sh\nprintf '%s' \"$PWD\" > " + cwdFile + "\n" if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { t.Fatalf("write script: %v", err) } r := &ClaudeRunner{ BinaryPath: scriptPath, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), LogDir: logDir, } tk := &task.Task{ Agent: task.AgentConfig{ Type: "claude", ProjectDir: projectDir, SkipPlanning: true, }, } // Point to a sandbox that no longer exists (e.g. /tmp was purged). staleSandbox := filepath.Join(t.TempDir(), "gone") e := &storage.Execution{ ID: "resume-exec-2", TaskID: "task-2", ResumeSessionID: "session-abc", ResumeAnswer: "ok", SandboxDir: staleSandbox, } if err := r.Run(context.Background(), tk, e); err != nil { t.Fatalf("Run with stale sandbox: %v", err) } got, err := os.ReadFile(cwdFile) if err != nil { t.Fatalf("cwd file not written: %v", err) } // Should have run in a fresh sandbox (not the stale path, not the raw projectDir). // The sandbox is removed after teardown, so we only check what it wasn't. cwd := string(got) if cwd == staleSandbox { t.Error("ran in stale sandbox dir that doesn't exist") } if cwd == projectDir { t.Error("ran directly in project_dir; expected a fresh sandbox clone") } // cwd should look like a claudomator sandbox path. if !strings.Contains(cwd, "claudomator-sandbox-") { t.Errorf("expected sandbox path, got %q", cwd) } } func TestIsCompletionReport(t *testing.T) { tests := []struct { name string json string expected bool }{ { name: "real question with options", json: `{"text": "Should I proceed with implementation?", "options": ["Yes", "No"]}`, expected: false, }, { name: "real question no options", json: `{"text": "Which approach do you prefer?"}`, expected: false, }, { name: "completion report no options no question mark", json: `{"text": "All tests pass. Implementation complete. Summary written to CLAUDOMATOR_SUMMARY_FILE."}`, expected: true, }, { name: "completion report with empty options", json: `{"text": "Feature implemented and committed.", "options": []}`, expected: true, }, { name: "invalid json treated as not a report", json: `not json`, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := isCompletionReport(tt.json) if got != tt.expected { t.Errorf("isCompletionReport(%q) = %v, want %v", tt.json, got, tt.expected) } }) } } func TestTailFile_ReturnsLastNLines(t *testing.T) { f, err := os.CreateTemp("", "tailfile-*") if err != nil { t.Fatal(err) } defer os.Remove(f.Name()) for i := 1; i <= 30; i++ { fmt.Fprintf(f, "line %d\n", i) } f.Close() got := tailFile(f.Name(), 5) lines := strings.Split(got, "\n") if len(lines) != 5 { t.Fatalf("want 5 lines, got %d: %q", len(lines), got) } if lines[0] != "line 26" || lines[4] != "line 30" { t.Errorf("want lines 26-30, got: %q", got) } } func TestTailFile_MissingFile_ReturnsEmpty(t *testing.T) { got := tailFile("/nonexistent/path/file.log", 10) if got != "" { t.Errorf("want empty string for missing file, got %q", got) } } func TestGitSafe_PrependsSafeDirectory(t *testing.T) { got := gitSafe("-C", "/some/path", "status") want := []string{"-c", "safe.directory=*", "-C", "/some/path", "status"} if len(got) != len(want) { t.Fatalf("gitSafe() = %v, want %v", got, want) } for i := range want { if got[i] != want[i] { t.Errorf("gitSafe()[%d] = %q, want %q", i, got[i], want[i]) } } }