From 74cc740398cf2d90804ab19db728c844c2e056b7 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Tue, 3 Mar 2026 21:15:50 +0000 Subject: Add elaborate, logs-stream, templates, and subtask-list endpoints - POST /api/tasks/elaborate: calls claude to draft a task config from a natural-language prompt - GET /api/executions/{id}/logs/stream: SSE tail of stdout.log - CRUD /api/templates: create/list/get/update/delete reusable task configs - GET /api/tasks/{id}/subtasks: list child tasks - Server.NewServer accepts claudeBinPath for elaborate; injectable elaborateCmdPath and logStore for test isolation - Valid-transition guard added to POST /api/tasks/{id}/run - CLI passes claude binary path through to the server Co-Authored-By: Claude Sonnet 4.6 --- internal/api/elaborate_test.go | 171 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 internal/api/elaborate_test.go (limited to 'internal/api/elaborate_test.go') diff --git a/internal/api/elaborate_test.go b/internal/api/elaborate_test.go new file mode 100644 index 0000000..ff158a8 --- /dev/null +++ b/internal/api/elaborate_test.go @@ -0,0 +1,171 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +// createFakeClaude writes a shell script to a temp dir that prints output and exits with the +// given code. Returns the script path. Used to mock the claude binary in elaborate tests. +func createFakeClaude(t *testing.T, output string, exitCode int) string { + t.Helper() + dir := t.TempDir() + outputFile := filepath.Join(dir, "output.json") + if err := os.WriteFile(outputFile, []byte(output), 0600); err != nil { + t.Fatal(err) + } + script := filepath.Join(dir, "claude") + content := fmt.Sprintf("#!/bin/sh\ncat %q\nexit %d\n", outputFile, exitCode) + if err := os.WriteFile(script, []byte(content), 0755); err != nil { + t.Fatal(err) + } + return script +} + +func TestElaborateTask_Success(t *testing.T) { + srv, _ := testServer(t) + + // Build fake Claude output: {"result": ""} + task := elaboratedTask{ + Name: "Run Go tests with race detector", + Description: "Runs the Go test suite with -race flag and checks coverage.", + Claude: elaboratedClaude{ + Model: "sonnet", + Instructions: "Run go test -race ./... and report results.", + WorkingDir: "", + MaxBudgetUSD: 0.5, + AllowedTools: []string{"Bash"}, + }, + Timeout: "15m", + Priority: "normal", + Tags: []string{"testing", "ci"}, + } + taskJSON, err := json.Marshal(task) + if err != nil { + t.Fatal(err) + } + wrapper := map[string]string{"result": string(taskJSON)} + wrapperJSON, err := json.Marshal(wrapper) + if err != nil { + t.Fatal(err) + } + + srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0) + + body := `{"prompt":"run the Go test suite with race detector and fail if coverage < 80%"}` + req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) + } + + var result elaboratedTask + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if result.Name == "" { + t.Error("expected non-empty name") + } + if result.Claude.Instructions == "" { + t.Error("expected non-empty instructions") + } +} + +func TestElaborateTask_EmptyPrompt(t *testing.T) { + srv, _ := testServer(t) + + body := `{"prompt":""}` + req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status: want 400, got %d; body: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if resp["error"] == "" { + t.Error("expected error message in response") + } +} + +func TestElaborateTask_MarkdownFencedJSON(t *testing.T) { + srv, _ := testServer(t) + + // Build a valid task JSON but wrap it in markdown fences as haiku sometimes does. + task := elaboratedTask{ + Name: "Test task", + Description: "Does something.", + Claude: elaboratedClaude{ + Model: "sonnet", + Instructions: "Do the thing.", + MaxBudgetUSD: 0.5, + AllowedTools: []string{"Bash"}, + }, + Timeout: "15m", + Priority: "normal", + Tags: []string{"test"}, + } + taskJSON, _ := json.Marshal(task) + fenced := "```json\n" + string(taskJSON) + "\n```" + wrapper := map[string]string{"result": fenced} + wrapperJSON, _ := json.Marshal(wrapper) + + srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0) + + body := `{"prompt":"do something"}` + req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String()) + } + + var result elaboratedTask + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if result.Name != task.Name { + t.Errorf("name: want %q, got %q", task.Name, result.Name) + } +} + +func TestElaborateTask_InvalidJSONFromClaude(t *testing.T) { + srv, _ := testServer(t) + + // Fake Claude returns something that is not valid JSON. + srv.elaborateCmdPath = createFakeClaude(t, "not valid json at all", 0) + + body := `{"prompt":"do something"}` + req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusBadGateway { + t.Fatalf("status: want 502, got %d; body: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if resp["error"] == "" { + t.Error("expected error message in response") + } +} -- cgit v1.2.3