package executor import ( "context" "encoding/json" "errors" "io" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "github.com/google/uuid" "github.com/thepeterstone/claudomator/internal/llm" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" ) // 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) *LocalRunner { t.Helper() return &LocalRunner{ Client: &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"}, 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 TestLocalRunner_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 local 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 TestLocalRunner_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 TestLocalRunner_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 TestLocalRunner_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 TestLocalRunner_Run_NoClient_Errors(t *testing.T) { r := &LocalRunner{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 LLM client") { t.Errorf("expected 'no LLM client' error, got %v", err) } } func TestLocalRunner_Run_EmptyInstructions_Errors(t *testing.T) { r := &LocalRunner{ Client: &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 TestLocalRunner_ExecLogDir(t *testing.T) { r := &LocalRunner{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) } }