summaryrefslogtreecommitdiff
path: root/internal/executor/nativerunner_test.go
diff options
context:
space:
mode:
authorClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 08:49:43 +0000
committerClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 08:49:43 +0000
commit67e0081c6d573b701ed931f96e14dbe5b4258a17 (patch)
treebc7f8ce0d57876dd85720924d0275dc39c05e5b4 /internal/executor/nativerunner_test.go
parent3d286974cdc28c68c5ee536ce9303899dae9540e (diff)
refactor(executor): extract provider-neutral tool-use loop (Phase 1)
Splits LocalRunner's OpenAI-specific agentic loop into reusable, provider- agnostic pieces so later phases can add native Anthropic/OpenAI/Google/Groq/ OpenRouter adapters without duplicating the control flow: - internal/provider: neutral Provider/ChatRequest/ChatResponse types, plus an openaicompat adapter wrapping the existing internal/llm.Client unchanged - internal/sandbox: Sandbox interface + HostSandbox (git clone/push/cleanup, read_file/write_file/run_bash/glob), lifted verbatim from local.go/localtools.go - internal/agentloop: the extracted tool-use loop (request/response/tool- dispatch/loop, ask_user blocking, stream-json envelope, summary fallback) - internal/agentchannel: AgentChannel/SubtaskSpec/BlockedError/ErrAgentBlocked moved out of internal/executor so agentloop can use them without an import cycle; internal/executor re-exports via type aliases, so no call site changes - internal/executor/nativerunner.go: NativeRunner replaces LocalRunner, wiring agentloop.Loop + openaicompat + HostSandbox together - config.Providers map[string]ProviderConfig added (unused until Phase 2+) Zero intended behavior change: go test -race ./... passes across all packages, and end-to-end stream-json/summary/changestats output was verified byte-compatible against a fake OpenAI-compatible server. Adds test coverage for sandbox tool-dispatch (git clone/push, read/write/bash/glob) that LocalRunner never had. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
Diffstat (limited to 'internal/executor/nativerunner_test.go')
-rw-r--r--internal/executor/nativerunner_test.go353
1 files changed, 353 insertions, 0 deletions
diff --git a/internal/executor/nativerunner_test.go b/internal/executor/nativerunner_test.go
new file mode 100644
index 0000000..f76b365
--- /dev/null
+++ b/internal/executor/nativerunner_test.go
@@ -0,0 +1,353 @@
+package executor
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/thepeterstone/claudomator/internal/llm"
+ "github.com/thepeterstone/claudomator/internal/provider/openaicompat"
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// TestNativeRunner_Run_ChangestatsParseable confirms that a git diff --stat
+// summary line embedded in the model's assistant text (the only way such a
+// line can reach stdout.log — tool results themselves are never logged) is
+// still recoverable by task.ParseChangestatFromFile via the new
+// NativeRunner/agentloop path, exactly as it was via the old LocalRunner
+// (neither runner logs raw tool output; both only log assistant text +
+// result lines, so this has always depended on the model itself echoing the
+// diff stat in its reply).
+func TestNativeRunner_Run_ChangestatsParseable(t *testing.T) {
+ srv := fakeChatServer(t, []fakeTurn{
+ {content: "## Summary\nCommitted the fix.\n2 files changed, 30 insertions(+), 4 deletions(-)", promptTok: 3, outTok: 7},
+ })
+ defer srv.Close()
+
+ r := newLocalRunner(t, srv)
+ tt := localTask()
+ exec := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID}
+
+ if err := r.Run(context.Background(), tt, exec, newStoreChannel(&fakeChannelStore{}, tt.ID)); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ stdoutPath := filepath.Join(r.ExecLogDir(exec.ID), "stdout.log")
+ cs := task.ParseChangestatFromFile(stdoutPath)
+ if cs == nil {
+ t.Fatalf("ParseChangestatFromFile found nothing in %s", stdoutPath)
+ }
+ if cs.FilesChanged != 2 || cs.LinesAdded != 30 || cs.LinesRemoved != 4 {
+ t.Errorf("changestats: got %+v, want {2 30 4}", cs)
+ }
+}
+
+// This file exercises NativeRunner — the provider.Provider/agentloop.Loop
+// based successor to the former LocalRunner — via a fake OpenAI-compatible
+// HTTP server, using the same fake-server test pattern LocalRunner's tests
+// used (fakeChatServer/fakeTurn/toolCall below), so it verifies behavioral
+// equivalence: same stdout.log stream-json shape, same summary/changestats
+// extraction, same tool-loop semantics.
+
+// fakeTurn is one canned chat-completion response the fake server returns.
+type fakeTurn struct {
+ content string
+ toolCalls []llm.ToolCall
+ promptTok int
+ outTok int
+}
+
+// fakeChatServer replies to /chat/completions with the supplied turns in order,
+// one per request (non-streaming JSON), so a tool-use loop can be driven
+// deterministically. Requests beyond the list repeat the final turn.
+func fakeChatServer(t *testing.T, turns []fakeTurn) *httptest.Server {
+ t.Helper()
+ var mu sync.Mutex
+ idx := 0
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ mu.Lock()
+ turn := turns[idx]
+ if idx < len(turns)-1 {
+ idx++
+ }
+ mu.Unlock()
+
+ msg := map[string]any{"role": "assistant", "content": turn.content}
+ if len(turn.toolCalls) > 0 {
+ msg["tool_calls"] = turn.toolCalls
+ }
+ resp := map[string]any{
+ "model": "fake",
+ "choices": []map[string]any{{"message": msg, "finish_reason": "stop"}},
+ "usage": map[string]int{"prompt_tokens": turn.promptTok, "completion_tokens": turn.outTok},
+ }
+ _ = json.NewEncoder(w).Encode(resp)
+ }))
+}
+
+func toolCall(id, name, args string) llm.ToolCall {
+ return llm.ToolCall{ID: id, Type: "function", Function: llm.ToolCallFunction{Name: name, Arguments: args}}
+}
+
+func newLocalRunner(t *testing.T, srv *httptest.Server) *NativeRunner {
+ t.Helper()
+ client := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"}
+ return &NativeRunner{
+ Provider: openaicompat.New(client),
+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ LogDir: t.TempDir(),
+ }
+}
+
+func localTask() *task.Task {
+ return &task.Task{
+ ID: "task-1",
+ Name: "test",
+ Agent: task.AgentConfig{
+ Type: "local",
+ Model: "fake",
+ Instructions: "Do a thing.",
+ SkipPlanning: true, // keep the preamble out of these assertions
+ },
+ }
+}
+
+func TestNativeRunner_Run_WritesStreamJSON(t *testing.T) {
+ srv := fakeChatServer(t, []fakeTurn{{content: "## Summary\nAll good.", promptTok: 11, outTok: 22}})
+ defer srv.Close()
+
+ r := newLocalRunner(t, srv)
+ tt := localTask()
+ exec := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID}
+
+ if err := r.Run(context.Background(), tt, exec, newStoreChannel(&fakeChannelStore{}, tt.ID)); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if exec.CostUSD != 0 {
+ t.Errorf("CostUSD should be 0 for native runner, got %v", exec.CostUSD)
+ }
+ if exec.TokensIn != 11 || exec.TokensOut != 22 {
+ t.Errorf("tokens: want 11/22 got %d/%d", exec.TokensIn, exec.TokensOut)
+ }
+
+ stdoutPath := filepath.Join(r.ExecLogDir(exec.ID), "stdout.log")
+ data, err := os.ReadFile(stdoutPath)
+ if err != nil {
+ t.Fatalf("read stdout: %v", err)
+ }
+ lines := strings.Split(strings.TrimSpace(string(data)), "\n")
+ // One assistant envelope + one result line.
+ if len(lines) != 2 {
+ t.Fatalf("expected 2 lines (assistant + result), got %d:\n%s", len(lines), data)
+ }
+ var env struct {
+ Type string `json:"type"`
+ }
+ if err := json.Unmarshal([]byte(lines[0]), &env); err != nil || env.Type != "assistant" {
+ t.Fatalf("line 0 should be an assistant envelope: %v: %s", err, lines[0])
+ }
+
+ if summary := extractSummary(stdoutPath); !strings.Contains(summary, "All good.") {
+ t.Errorf("extractSummary should find 'All good.', got %q", summary)
+ }
+}
+
+func TestNativeRunner_Run_ToolLoop_ReportSummaryAndSpawn(t *testing.T) {
+ // Turn 1: model spawns a subtask. Turn 2: reports summary. Turn 3: finishes.
+ srv := fakeChatServer(t, []fakeTurn{
+ {toolCalls: []llm.ToolCall{toolCall("c1", "spawn_subtask", `{"name":"sub A","instructions":"do sub"}`)}, promptTok: 5, outTok: 3},
+ {toolCalls: []llm.ToolCall{toolCall("c2", "report_summary", `{"summary":"all done"}`)}, promptTok: 4, outTok: 2},
+ {content: "finished", promptTok: 2, outTok: 1},
+ })
+ defer srv.Close()
+
+ r := newLocalRunner(t, srv)
+ tt := localTask()
+ store := &fakeChannelStore{}
+ ch := newStoreChannel(store, tt.ID)
+ exec := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID}
+
+ if err := r.Run(context.Background(), tt, exec, ch); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if len(store.createdTasks) != 1 || store.createdTasks[0].Name != "sub A" {
+ t.Errorf("expected one spawned subtask 'sub A', got %+v", store.createdTasks)
+ }
+ if store.createdTasks[0].ParentTaskID != tt.ID {
+ t.Errorf("subtask parent: want %q, got %q", tt.ID, store.createdTasks[0].ParentTaskID)
+ }
+ if sum, ok := ch.ReportedSummary(); !ok || sum != "all done" {
+ t.Errorf("expected reported summary 'all done', got %q (set=%v)", sum, ok)
+ }
+ // Tokens accumulate across all three turns.
+ if exec.TokensIn != 11 || exec.TokensOut != 6 {
+ t.Errorf("tokens should accumulate: want 11/6, got %d/%d", exec.TokensIn, exec.TokensOut)
+ }
+}
+
+func TestNativeRunner_Run_RecordProgress(t *testing.T) {
+ srv := fakeChatServer(t, []fakeTurn{
+ {toolCalls: []llm.ToolCall{toolCall("c1", "record_progress", `{"message":"halfway there"}`)}},
+ {content: "done"},
+ })
+ defer srv.Close()
+
+ store := &fakeChannelStore{}
+ tt := localTask()
+ exec := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID}
+ if err := newLocalRunner(t, srv).Run(context.Background(), tt, exec, newStoreChannel(store, tt.ID)); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if len(store.createdEvents) != 1 {
+ t.Fatalf("expected 1 progress event, got %d", len(store.createdEvents))
+ }
+ if !strings.Contains(string(store.createdEvents[0].Payload), "halfway there") {
+ t.Errorf("progress event payload: %s", store.createdEvents[0].Payload)
+ }
+}
+
+func TestNativeRunner_Run_AskUser_Blocks(t *testing.T) {
+ srv := fakeChatServer(t, []fakeTurn{
+ {toolCalls: []llm.ToolCall{toolCall("c1", "ask_user", `{"question":"which branch?"}`)}},
+ {content: "should not be reached"},
+ })
+ defer srv.Close()
+
+ tt := localTask()
+ exec := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID}
+ err := newLocalRunner(t, srv).Run(context.Background(), tt, exec, newStoreChannel(&fakeChannelStore{}, tt.ID))
+
+ var be *BlockedError
+ if !errors.As(err, &be) {
+ t.Fatalf("expected *BlockedError, got %v", err)
+ }
+ if !strings.Contains(be.QuestionJSON, "which branch?") {
+ t.Errorf("BlockedError question: %q", be.QuestionJSON)
+ }
+}
+
+func TestNativeRunner_Run_NoProvider_Errors(t *testing.T) {
+ r := &NativeRunner{LogDir: t.TempDir()}
+ tt := &task.Task{ID: "x", Agent: task.AgentConfig{Instructions: "hi"}}
+ exec := &storage.Execution{ID: "exec-x"}
+ err := r.Run(context.Background(), tt, exec, newStoreChannel(nil, tt.ID))
+ if err == nil || !strings.Contains(err.Error(), "no provider configured") {
+ t.Errorf("expected 'no provider configured' error, got %v", err)
+ }
+}
+
+func TestNativeRunner_Run_EmptyInstructions_Errors(t *testing.T) {
+ r := &NativeRunner{
+ Provider: openaicompat.New(&llm.Client{Endpoint: "http://unused", Model: "x"}),
+ LogDir: t.TempDir(),
+ }
+ tt := &task.Task{ID: "x", Agent: task.AgentConfig{}}
+ exec := &storage.Execution{ID: "exec-x"}
+ err := r.Run(context.Background(), tt, exec, newStoreChannel(nil, tt.ID))
+ if err == nil || !strings.Contains(err.Error(), "empty instructions") {
+ t.Errorf("expected empty-instructions error, got %v", err)
+ }
+}
+
+func TestNativeRunner_ExecLogDir(t *testing.T) {
+ r := &NativeRunner{LogDir: "/tmp/logs"}
+ if got := r.ExecLogDir("abc"); got != "/tmp/logs/abc" {
+ t.Errorf("ExecLogDir: got %q", got)
+ }
+ r.LogDir = ""
+ if got := r.ExecLogDir("abc"); got != "" {
+ t.Errorf("ExecLogDir empty LogDir: got %q", got)
+ }
+}
+
+// TestNativeRunner_Run_ProjectDir_SandboxToolsAndPush drives a task with
+// Agent.ProjectDir set through write_file/read_file/glob/run_bash tool calls
+// against a real HostSandbox (git clone into a temp dir), confirming the
+// verbatim-ported sandbox.HostSandbox behavior end-to-end: tool dispatch
+// hits the real filesystem/shell, a commit made via run_bash is pushed back
+// to the origin repo on a clean finish, and the sandbox temp dir is removed
+// afterward — exactly the git-clone/push/cleanup sequence LocalRunner used
+// to run inline. No prior test (LocalRunner's included) exercised this path;
+// it's new coverage for logic that was previously only exercised manually.
+func TestNativeRunner_Run_ProjectDir_SandboxToolsAndPush(t *testing.T) {
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skip("git not available")
+ }
+
+ // Bare origin repo so `git push origin HEAD` from the sandbox clone
+ // doesn't hit receive.denyCurrentBranch.
+ origin := t.TempDir()
+ run := func(dir string, args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
+ "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
+ )
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+ run(origin, "init", "--bare", "-b", "main")
+
+ // Seed the bare repo with an initial commit (an empty bare repo has no
+ // HEAD, so LocalRunner's plain `git clone <remote> <dir>` needs something
+ // to check out).
+ seed := t.TempDir()
+ run(seed, "init", "-b", "main")
+ run(seed, "remote", "add", "origin", origin)
+ if err := os.WriteFile(filepath.Join(seed, "README.md"), []byte("seed\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ run(seed, "add", "-A")
+ run(seed, "commit", "-m", "seed")
+ run(seed, "push", "origin", "main")
+
+ srv := fakeChatServer(t, []fakeTurn{
+ {toolCalls: []llm.ToolCall{toolCall("c1", "write_file", `{"path":"hello.txt","content":"hello world"}`)}},
+ {toolCalls: []llm.ToolCall{toolCall("c2", "read_file", `{"path":"hello.txt"}`)}},
+ {toolCalls: []llm.ToolCall{toolCall("c3", "glob", `{"pattern":"*.txt"}`)}},
+ {toolCalls: []llm.ToolCall{toolCall("c4", "run_bash", `{"command":"git add -A && git -c user.name=test -c user.email=test@example.com commit -m 'add hello'"}`)}},
+ {content: "## Summary\nAdded hello.txt and committed."},
+ })
+ defer srv.Close()
+
+ r := newLocalRunner(t, srv)
+ tt := localTask()
+ tt.Agent.ProjectDir = origin
+ exec_ := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID}
+
+ if err := r.Run(context.Background(), tt, exec_, newStoreChannel(&fakeChannelStore{}, tt.ID)); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ if exec_.SandboxDir != "" {
+ t.Errorf("expected SandboxDir cleared after clean finish, got %q", exec_.SandboxDir)
+ }
+
+ // Verify the commit actually landed on origin.
+ logCmd := exec.Command("git", "-C", origin, "log", "--oneline", "main")
+ out, err := logCmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git log on origin: %v\n%s", err, out)
+ }
+ if !strings.Contains(string(out), "add hello") {
+ t.Errorf("expected pushed commit 'add hello' in origin log, got:\n%s", out)
+ }
+}