summaryrefslogtreecommitdiff
path: root/internal/executor/local_test.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-05-01 22:14:37 -1000
committerGitHub <noreply@github.com>2026-05-01 22:14:37 -1000
commit99115d8158137083239c45e5a860b718ff4cefa1 (patch)
tree1bf3bd0505eea79375c67af83c7c5fe8c0f274ff /internal/executor/local_test.go
parentc2aa026f6ce1c9e216b99d74f294fc133d5fcddd (diff)
parent50f8fe8c1ff8b82e0bd399e5776e58bda3e57d1c (diff)
Merge pull request #1 from thepeterstone/claude/local-oss-model-agents-MEBqj
Local OSS models as a third runner (epic)
Diffstat (limited to 'internal/executor/local_test.go')
-rw-r--r--internal/executor/local_test.go152
1 files changed, 152 insertions, 0 deletions
diff --git a/internal/executor/local_test.go b/internal/executor/local_test.go
new file mode 100644
index 0000000..d8ab678
--- /dev/null
+++ b/internal/executor/local_test.go
@@ -0,0 +1,152 @@
+package executor
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/thepeterstone/claudomator/internal/llm"
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "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 {
+ 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()
+ }
+ }
+ final := 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},
+ }
+ fb, _ := json.Marshal(final)
+ fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", fb)
+ }))
+}
+
+func TestLocalRunner_Run_WritesStreamJSON(t *testing.T) {
+ srv := fakeOpenAIServer(t,
+ []string{"## Summary\n", "All ", "good."},
+ 11, 22,
+ )
+ defer srv.Close()
+
+ logRoot := t.TempDir()
+ r := &LocalRunner{
+ Client: &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"},
+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ LogDir: logRoot,
+ }
+ tt := &task.Task{
+ ID: "task-1",
+ Name: "test",
+ Agent: task.AgentConfig{
+ Type: "local",
+ Model: "fake",
+ Instructions: "Do a thing.",
+ },
+ }
+ exec := &storage.Execution{ID: uuid.New().String(), TaskID: tt.ID}
+
+ if err := r.Run(context.Background(), tt, exec); 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)
+ }
+
+ // 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)
+ }
+ }
+
+ summary := extractSummary(stdoutPath)
+ if !strings.Contains(summary, "All good.") {
+ t.Errorf("extractSummary should find 'All good.', got %q", summary)
+ }
+}
+
+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)
+ 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)
+ 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)
+ }
+}