summaryrefslogtreecommitdiff
path: root/internal/executor/local_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/executor/local_test.go')
-rw-r--r--internal/executor/local_test.go193
1 files changed, 137 insertions, 56 deletions
diff --git a/internal/executor/local_test.go b/internal/executor/local_test.go
index ffe87f9..2ae8380 100644
--- a/internal/executor/local_test.go
+++ b/internal/executor/local_test.go
@@ -3,7 +3,7 @@ package executor
import (
"context"
"encoding/json"
- "fmt"
+ "errors"
"io"
"log/slog"
"net/http"
@@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"strings"
+ "sync"
"testing"
"github.com/google/uuid"
@@ -19,60 +20,77 @@ import (
"github.com/thepeterstone/claudomator/internal/task"
)
-// fakeOpenAIServer returns an httptest.Server that replies with a streaming
-// chat completion containing the supplied content (split into chunks) plus a
-// usage record.
-func fakeOpenAIServer(t *testing.T, chunks []string, promptTok, outTok int) *httptest.Server {
+// 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()
- return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/event-stream")
- flusher, _ := w.(http.Flusher)
- for _, c := range chunks {
- payload := map[string]any{
- "model": "fake",
- "choices": []map[string]any{{"delta": map[string]string{"content": c}}},
- }
- b, _ := json.Marshal(payload)
- fmt.Fprintf(w, "data: %s\n\n", b)
- if flusher != nil {
- flusher.Flush()
- }
+ 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
}
- final := map[string]any{
+ resp := map[string]any{
"model": "fake",
- "choices": []map[string]any{{"delta": map[string]string{}, "finish_reason": "stop"}},
- "usage": map[string]int{"prompt_tokens": promptTok, "completion_tokens": outTok},
+ "choices": []map[string]any{{"message": msg, "finish_reason": "stop"}},
+ "usage": map[string]int{"prompt_tokens": turn.promptTok, "completion_tokens": turn.outTok},
}
- fb, _ := json.Marshal(final)
- fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", fb)
+ _ = json.NewEncoder(w).Encode(resp)
}))
}
-func TestLocalRunner_Run_WritesStreamJSON(t *testing.T) {
- srv := fakeOpenAIServer(t,
- []string{"## Summary\n", "All ", "good."},
- 11, 22,
- )
- defer srv.Close()
+func toolCall(id, name, args string) llm.ToolCall {
+ return llm.ToolCall{ID: id, Type: "function", Function: llm.ToolCallFunction{Name: name, Arguments: args}}
+}
- logRoot := t.TempDir()
- r := &LocalRunner{
+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: logRoot,
+ LogDir: t.TempDir(),
}
- tt := &task.Task{
+}
+
+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(nil, tt.ID)); err != nil {
+ if err := r.Run(context.Background(), tt, exec, newStoreChannel(&fakeChannelStore{}, tt.ID)); err != nil {
t.Fatalf("Run: %v", err)
}
@@ -83,40 +101,103 @@ func TestLocalRunner_Run_WritesStreamJSON(t *testing.T) {
t.Errorf("tokens: want 11/22 got %d/%d", exec.TokensIn, exec.TokensOut)
}
- // Verify stdout.log contains stream-json envelopes that extractSummary can parse.
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")
- if len(lines) < 4 {
- t.Fatalf("expected at least 4 lines (3 deltas + 1 result), got %d:\n%s", len(lines), data)
- }
- for i, line := range lines[:3] {
- var env struct {
- Type string `json:"type"`
- Message struct {
- Content []struct {
- Type string `json:"type"`
- Text string `json:"text"`
- }
- }
- }
- if err := json.Unmarshal([]byte(line), &env); err != nil {
- t.Fatalf("line %d not JSON: %v: %s", i, err, line)
- }
- if env.Type != "assistant" {
- t.Errorf("line %d: want type=assistant, got %q", i, env.Type)
- }
+ // 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])
}
- summary := extractSummary(stdoutPath)
- if !strings.Contains(summary, "All good.") {
+ 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"}}