summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/elaborate.go337
-rw-r--r--internal/api/elaborate_local_test.go214
-rw-r--r--internal/api/elaborate_test.go527
-rw-r--r--internal/api/server.go1
-rw-r--r--internal/api/server_test.go28
-rw-r--r--internal/api/validate_test.go21
6 files changed, 21 insertions, 1107 deletions
diff --git a/internal/api/elaborate.go b/internal/api/elaborate.go
index 8676b36..e8930a6 100644
--- a/internal/api/elaborate.go
+++ b/internal/api/elaborate.go
@@ -6,128 +6,13 @@ import (
"encoding/json"
"fmt"
"net/http"
- "os"
"os/exec"
- "path/filepath"
- "sort"
"strings"
"time"
-
- "github.com/thepeterstone/claudomator/internal/llm"
)
const elaborateTimeout = 30 * time.Second
-func buildElaboratePrompt(workDir string) string {
- workDirLine := ` "project_dir": string — leave empty unless you have a specific reason to set it,`
- if workDir != "" {
- workDirLine = fmt.Sprintf(` "project_dir": string — use %q for tasks that operate on this codebase, empty string otherwise,`, workDir)
- }
- return `You are a task configuration assistant for Claudomator, an AI task runner that executes tasks by running Claude or Gemini as a subprocess.
-
-Your ONLY job is to convert any user request into a Claudomator task JSON object. You MUST always output valid JSON. Never ask clarifying questions. Never explain. Never refuse. Make reasonable assumptions and produce the JSON.
-
-Output ONLY a valid JSON object matching this schema (no markdown fences, no prose, no explanation):
-
-{
- "name": string — short imperative title (≤60 chars),
- "description": string — 1-2 sentence summary,
- "agent": {
- "type": "claude" | "gemini",
- "model": string — "sonnet" for claude, "gemini-2.5-flash-lite" for gemini,
- "instructions": string — detailed, step-by-step instructions for the agent. Must end with a "## Acceptance Criteria" section listing measurable conditions that define success. For coding tasks, include TDD requirements (write failing tests first, then implement),
-` + workDirLine + `
- "max_budget_usd": number — conservative estimate (0.25–5.00),
- "allowed_tools": array — every tool the task genuinely needs. Include "Write" if creating files, "Edit" if modifying files, "Read" if reading files, "Bash" for shell/git/test commands, "Grep"/"Glob" for searching.
- },
- "timeout": string — e.g. "15m",
- "priority": string — "normal" | "high" | "low",
- "tags": array — relevant lowercase tags
-}`
-}
-
-// elaboratedTask mirrors the task creation schema for elaboration responses.
-type elaboratedTask struct {
- Name string `json:"name"`
- Description string `json:"description"`
- Agent elaboratedAgent `json:"agent"`
- Timeout string `json:"timeout"`
- Priority string `json:"priority"`
- Tags []string `json:"tags"`
-}
-
-type elaboratedAgent struct {
- Type string `json:"type"`
- Model string `json:"model"`
- Instructions string `json:"instructions"`
- ProjectDir string `json:"project_dir"`
- MaxBudgetUSD float64 `json:"max_budget_usd"`
- AllowedTools []string `json:"allowed_tools"`
-}
-
-// sanitizeElaboratedTask enforces tool completeness and dev practice compliance.
-// It modifies t in place, inferring missing tools from instruction keywords and
-// appending required sections when they are absent.
-func sanitizeElaboratedTask(t *elaboratedTask) {
- lower := strings.ToLower(t.Agent.Instructions)
-
- // Build current tool set.
- toolSet := make(map[string]bool, len(t.Agent.AllowedTools))
- for _, tool := range t.Agent.AllowedTools {
- toolSet[tool] = true
- }
-
- // Infer missing tools from instruction keywords.
- type rule struct {
- tool string
- keywords []string
- }
- rules := []rule{
- {"Write", []string{"create file", "write file", "new file", "write to", "save to", "output to", "generate file", "creates a file", "create a new file"}},
- {"Edit", []string{"edit", "modify", "refactor", "replace", "patch"}},
- {"Read", []string{"read", "inspect", "examine", "look at the file"}},
- {"Bash", []string{"run", "execute", "bash", "shell", "command", "build", "compile", "git", "install", "make"}},
- {"Grep", []string{"search for", "grep", "find in", "locate in"}},
- {"Glob", []string{"find file", "list file", "search file"}},
- }
- for _, r := range rules {
- if toolSet[r.tool] {
- continue
- }
- for _, kw := range r.keywords {
- if strings.Contains(lower, kw) {
- toolSet[r.tool] = true
- break
- }
- }
- }
- // Edit without Read is almost always wrong.
- if toolSet["Edit"] && !toolSet["Read"] {
- toolSet["Read"] = true
- }
- // Rebuild the list only when tools were added.
- if len(toolSet) > len(t.Agent.AllowedTools) {
- tools := make([]string, 0, len(toolSet))
- for tool := range toolSet {
- tools = append(tools, tool)
- }
- sort.Strings(tools)
- t.Agent.AllowedTools = tools
- }
-
- // Append an acceptance criteria section when none is present.
- if !strings.Contains(lower, "acceptance") &&
- !strings.Contains(lower, "done when") &&
- !strings.Contains(lower, "success criteria") {
- t.Agent.Instructions += "\n\n## Acceptance Criteria\nBefore finishing, verify all stated goals are met, tests pass (if applicable), and no unintended side effects were introduced."
- }
-
- // Append a TDD reminder for coding tasks that do not already mention tests.
- if (toolSet["Edit"] || toolSet["Write"]) && !strings.Contains(lower, "test") {
- t.Agent.Instructions += "\n\n## Dev Practices\nFollow TDD: write a failing test first, then implement the minimum code to make it pass. Commit all changes before finishing."
- }
-}
-
// claudeJSONResult is the top-level object returned by `claude --output-format json`.
type claudeJSONResult struct {
Result string `json:"result"`
@@ -167,142 +52,6 @@ func (s *Server) geminiBinaryPath() string {
return "gemini"
}
-func readProjectContext(workDir string) string {
- if workDir == "" {
- return ""
- }
- var sb strings.Builder
- for _, filename := range []string{"CLAUDE.md", ".agent/worklog.md"} {
- path := filepath.Join(workDir, filename)
- if data, err := os.ReadFile(path); err == nil {
- if sb.Len() > 0 {
- sb.WriteString("\n\n")
- }
- sb.WriteString(fmt.Sprintf("--- %s ---\n%s", filename, string(data)))
- }
- }
- return sb.String()
-}
-
-func (s *Server) appendRawNarrative(workDir, prompt string) {
- if workDir == "" {
- return
- }
- docsDir := filepath.Join(workDir, "docs")
- if err := os.MkdirAll(docsDir, 0755); err != nil {
- s.logger.Error("elaborate: failed to create docs directory", "error", err, "path", docsDir)
- return
- }
- path := filepath.Join(docsDir, "RAW_NARRATIVE.md")
- f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
- if err != nil {
- s.logger.Error("elaborate: failed to open RAW_NARRATIVE.md", "error", err, "path", path)
- return
- }
- defer f.Close()
-
- entry := fmt.Sprintf("\n--- %s ---\n%s\n", time.Now().Format(time.RFC3339), prompt)
- if _, err := f.WriteString(entry); err != nil {
- s.logger.Error("elaborate: failed to write to RAW_NARRATIVE.md", "error", err, "path", path)
- }
-}
-
-func (s *Server) elaborateWithClaude(ctx context.Context, workDir, fullPrompt string) (*elaboratedTask, error) {
- cmd := exec.CommandContext(ctx, s.claudeBinaryPath(),
- "-p", fullPrompt,
- "--system-prompt", buildElaboratePrompt(workDir),
- "--output-format", "json",
- "--model", "haiku",
- )
-
- var stdout, stderr bytes.Buffer
- cmd.Stdout = &stdout
- cmd.Stderr = &stderr
-
- // Claude returns exit 1 if rate limited but still outputs JSON with is_error: true
- err := cmd.Run()
-
- output := stdout.Bytes()
- if len(output) == 0 {
- if err != nil {
- return nil, fmt.Errorf("claude failed: %w (stderr: %s)", err, stderr.String())
- }
- return nil, fmt.Errorf("claude returned no output")
- }
-
- var wrapper claudeJSONResult
- if jerr := json.Unmarshal(output, &wrapper); jerr != nil {
- return nil, fmt.Errorf("failed to parse claude JSON wrapper: %w (output: %s)", jerr, string(output))
- }
-
- if wrapper.IsError {
- return nil, fmt.Errorf("claude error: %s", wrapper.Result)
- }
-
- var result elaboratedTask
- if jerr := json.Unmarshal([]byte(extractJSON(wrapper.Result)), &result); jerr != nil {
- return nil, fmt.Errorf("failed to parse elaborated task JSON: %w (result: %s)", jerr, wrapper.Result)
- }
-
- return &result, nil
-}
-
-// elaborateWithLocal runs elaboration through an OpenAI-compatible local LLM.
-// It uses the same prompt template as the Claude/Gemini paths and requests
-// json_object response format so we can decode directly without the
-// markdown-fence cleanup needed for the CLI paths.
-func elaborateWithLocal(ctx context.Context, c *llm.Client, workDir, fullPrompt string) (*elaboratedTask, error) {
- if c == nil {
- return nil, fmt.Errorf("local llm: no client configured")
- }
- systemPrompt := buildElaboratePrompt(workDir)
- resp, err := c.Chat(ctx, llm.ChatRequest{
- Messages: []llm.Message{
- {Role: "system", Content: systemPrompt},
- {Role: "user", Content: fullPrompt},
- },
- ResponseJSON: true,
- })
- if err != nil {
- return nil, fmt.Errorf("local llm: %w", err)
- }
- body := strings.TrimSpace(resp.Content)
- var result elaboratedTask
- if jerr := json.Unmarshal([]byte(extractJSON(body)), &result); jerr != nil {
- return nil, fmt.Errorf("local llm: parse JSON: %w (response: %s)", jerr, body)
- }
- return &result, nil
-}
-
-func (s *Server) elaborateWithGemini(ctx context.Context, workDir, fullPrompt string) (*elaboratedTask, error) {
- combinedPrompt := fmt.Sprintf("%s\n\n%s", buildElaboratePrompt(workDir), fullPrompt)
- cmd := exec.CommandContext(ctx, s.geminiBinaryPath(),
- "-p", combinedPrompt,
- "--output-format", "json",
- "--model", "gemini-2.5-flash-lite",
- )
-
- var stdout, stderr bytes.Buffer
- cmd.Stdout = &stdout
- cmd.Stderr = &stderr
-
- if err := cmd.Run(); err != nil {
- return nil, fmt.Errorf("gemini failed: %w (stderr: %s)", err, stderr.String())
- }
-
- var wrapper geminiJSONResult
- if err := json.Unmarshal(stdout.Bytes(), &wrapper); err != nil {
- return nil, fmt.Errorf("failed to parse gemini JSON wrapper: %w (output: %s)", err, stdout.String())
- }
-
- var result elaboratedTask
- if err := json.Unmarshal([]byte(extractJSON(wrapper.Response)), &result); err != nil {
- return nil, fmt.Errorf("failed to parse elaborated task JSON: %w (response: %s)", err, wrapper.Response)
- }
-
- return &result, nil
-}
-
// elaboratedStorySubtask is a leaf unit within a story task.
type elaboratedStorySubtask struct {
Name string `json:"name"`
@@ -493,89 +242,3 @@ func (s *Server) handleElaborateStory(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, result)
}
-
-func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) {
- if s.elaborateLimiter != nil && !s.elaborateLimiter.allow(realIP(r)) {
- writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "rate limit exceeded"})
- return
- }
-
- var input struct {
- Prompt string `json:"prompt"`
- ProjectID string `json:"project_id"`
- // project_dir kept for backward compat; project_id takes precedence
- ProjectDir string `json:"project_dir"`
- }
- if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
- return
- }
- if input.Prompt == "" {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": "prompt is required"})
- return
- }
-
- workDir := s.workDir
- if input.ProjectID != "" {
- if proj, err := s.store.GetProject(input.ProjectID); err == nil {
- workDir = proj.LocalPath
- }
- } else if input.ProjectDir != "" {
- workDir = input.ProjectDir
- }
-
- if workDir != s.workDir {
- go s.appendRawNarrative(workDir, input.Prompt)
- }
-
- projectContext := readProjectContext(workDir)
- fullPrompt := input.Prompt
- if projectContext != "" {
- fullPrompt = fmt.Sprintf("Project context from %s:\n%s\n\nUser request: %s", workDir, projectContext, input.Prompt)
- }
-
- ctx, cancel := context.WithTimeout(r.Context(), elaborateTimeout)
- defer cancel()
-
- var result *elaboratedTask
- var err error
-
- // Try local LLM first when configured. Falls back to Claude → Gemini on
- // hard failure of each prior attempt.
- if s.llm != nil {
- result, err = elaborateWithLocal(ctx, s.llm, workDir, fullPrompt)
- if err != nil {
- s.logger.Warn("elaborate: local llm failed, falling back to claude", "error", err)
- result = nil
- }
- }
- if result == nil {
- result, err = s.elaborateWithClaude(ctx, workDir, fullPrompt)
- if err != nil {
- s.logger.Warn("elaborate: claude failed, falling back to gemini", "error", err)
- result, err = s.elaborateWithGemini(ctx, workDir, fullPrompt)
- if err != nil {
- s.logger.Error("elaborate: gemini also failed", "error", err)
- writeJSON(w, http.StatusBadGateway, map[string]string{
- "error": fmt.Sprintf("elaboration failed: %v", err),
- })
- return
- }
- }
- }
-
- if result.Name == "" || result.Agent.Instructions == "" {
- writeJSON(w, http.StatusBadGateway, map[string]string{
- "error": "elaboration failed: missing required fields in response",
- })
- return
- }
-
- if result.Agent.Type == "" {
- result.Agent.Type = "claude"
- }
-
- sanitizeElaboratedTask(result)
-
- writeJSON(w, http.StatusOK, result)
-}
diff --git a/internal/api/elaborate_local_test.go b/internal/api/elaborate_local_test.go
deleted file mode 100644
index 09a8f9e..0000000
--- a/internal/api/elaborate_local_test.go
+++ /dev/null
@@ -1,214 +0,0 @@
-package api
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync/atomic"
- "testing"
-
- "github.com/thepeterstone/claudomator/internal/llm"
-)
-
-// fakeChatCompletionsServer returns an httptest server that responds to a
-// /chat/completions POST with the given assistant content (which should be a
-// JSON-encoded elaboratedTask). Returns the server and a counter of calls
-// received so tests can assert dispatch ordering.
-func fakeChatCompletionsServer(t *testing.T, assistantContent string) (*httptest.Server, *int32) {
- t.Helper()
- var calls int32
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- atomic.AddInt32(&calls, 1)
- w.Header().Set("Content-Type", "application/json")
- // The assistant content has to be JSON-encoded inside the wire format.
- escaped, _ := json.Marshal(assistantContent)
- fmt.Fprintf(w, `{
- "model":"local",
- "choices":[{"message":{"role":"assistant","content":%s},"finish_reason":"stop"}],
- "usage":{"prompt_tokens":10,"completion_tokens":50}
- }`, string(escaped))
- }))
- t.Cleanup(srv.Close)
- return srv, &calls
-}
-
-func TestElaborateWithLocal_ParsesValidResponse(t *testing.T) {
- taskBody, _ := json.Marshal(elaboratedTask{
- Name: "Test elaborated task",
- Description: "From local llm",
- Agent: elaboratedAgent{
- Type: "claude",
- Model: "sonnet",
- Instructions: "Run go build.",
- MaxBudgetUSD: 0.25,
- AllowedTools: []string{"Bash"},
- },
- Timeout: "10m",
- Priority: "normal",
- Tags: []string{"build"},
- })
- srv, calls := fakeChatCompletionsServer(t, string(taskBody))
-
- c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"}
- result, err := elaborateWithLocal(context.Background(), c, "/some/dir", "build the project")
- if err != nil {
- t.Fatalf("elaborateWithLocal: %v", err)
- }
- if result.Name != "Test elaborated task" {
- t.Errorf("Name: %q", result.Name)
- }
- if result.Agent.Instructions != "Run go build." {
- t.Errorf("Instructions: %q", result.Agent.Instructions)
- }
- if got := atomic.LoadInt32(calls); got != 1 {
- t.Errorf("expected 1 call, got %d", got)
- }
-}
-
-func TestElaborateWithLocal_NilClient(t *testing.T) {
- _, err := elaborateWithLocal(context.Background(), nil, "", "p")
- if err == nil || !strings.Contains(err.Error(), "no client") {
- t.Errorf("expected nil-client error, got %v", err)
- }
-}
-
-func TestElaborateWithLocal_BadJSON(t *testing.T) {
- srv, _ := fakeChatCompletionsServer(t, "this is not JSON at all")
- c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"}
- _, err := elaborateWithLocal(context.Background(), c, "", "p")
- if err == nil || !strings.Contains(err.Error(), "parse JSON") {
- t.Errorf("expected parse error, got %v", err)
- }
-}
-
-// TestElaborateTask_LocalLLMPreferred verifies the dispatcher uses local LLM
-// when SetLLM is configured, and does not invoke claude.
-func TestElaborateTask_LocalLLMPreferred(t *testing.T) {
- srv, _ := testServer(t)
-
- taskBody, _ := json.Marshal(elaboratedTask{
- Name: "Local-elaborated",
- Description: "From local",
- Agent: elaboratedAgent{
- Type: "claude",
- Model: "sonnet",
- Instructions: "Do work. Tests pass when complete.",
- MaxBudgetUSD: 0.25,
- AllowedTools: []string{"Bash"},
- },
- Timeout: "10m",
- Priority: "normal",
- })
- llmSrv, _ := fakeChatCompletionsServer(t, string(taskBody))
- srv.SetLLM(&llm.Client{Endpoint: llmSrv.URL + "/v1", Model: "fake"})
- // Point Claude binary at a path that would fail if called.
- srv.elaborateCmdPath = "/nonexistent/claude-should-not-run"
-
- body := `{"prompt":"do work"}`
- 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 got elaboratedTask
- if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
- t.Fatalf("decode response: %v", err)
- }
- if got.Name != "Local-elaborated" {
- t.Errorf("Name: want Local-elaborated got %q", got.Name)
- }
-}
-
-// TestElaborateTask_LocalFails_FallsBackToClaude verifies the dispatcher
-// falls back to the Claude path when the local LLM returns an error.
-func TestElaborateTask_LocalFails_FallsBackToClaude(t *testing.T) {
- srv, _ := testServer(t)
-
- // Local LLM server that always 500s.
- failSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- http.Error(w, "boom", http.StatusInternalServerError)
- }))
- t.Cleanup(failSrv.Close)
- srv.SetLLM(&llm.Client{Endpoint: failSrv.URL + "/v1", Model: "fake"})
-
- // Configure a working fake Claude binary.
- taskBody, _ := json.Marshal(elaboratedTask{
- Name: "Claude-fallback",
- Description: "From claude after local failed",
- Agent: elaboratedAgent{
- Type: "claude",
- Model: "sonnet",
- Instructions: "Run tests.",
- MaxBudgetUSD: 0.25,
- AllowedTools: []string{"Bash"},
- },
- Timeout: "10m",
- Priority: "normal",
- })
- wrapper, _ := json.Marshal(map[string]string{"result": string(taskBody)})
- srv.elaborateCmdPath = createFakeClaude(t, string(wrapper), 0)
-
- body := `{"prompt":"run tests"}`
- 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 got elaboratedTask
- if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
- t.Fatalf("decode response: %v", err)
- }
- if got.Name != "Claude-fallback" {
- t.Errorf("Name: want Claude-fallback (fallback path) got %q", got.Name)
- }
-}
-
-// TestElaborateTask_NoLocalLLM_UsesClaude verifies that when SetLLM is not
-// called, behavior is unchanged (Claude path still primary).
-func TestElaborateTask_NoLocalLLM_UsesClaude(t *testing.T) {
- srv, _ := testServer(t)
-
- taskBody, _ := json.Marshal(elaboratedTask{
- Name: "Claude-only",
- Description: "no local llm configured",
- Agent: elaboratedAgent{
- Type: "claude",
- Model: "sonnet",
- Instructions: "Do work.",
- MaxBudgetUSD: 0.25,
- AllowedTools: []string{"Bash"},
- },
- Timeout: "10m",
- Priority: "normal",
- })
- wrapper, _ := json.Marshal(map[string]string{"result": string(taskBody)})
- srv.elaborateCmdPath = createFakeClaude(t, string(wrapper), 0)
-
- body := `{"prompt":"do work"}`
- 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 got elaboratedTask
- if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
- t.Fatalf("decode response: %v", err)
- }
- if got.Name != "Claude-only" {
- t.Errorf("Name: %q", got.Name)
- }
-}
-
diff --git a/internal/api/elaborate_test.go b/internal/api/elaborate_test.go
deleted file mode 100644
index 32cec3c..0000000
--- a/internal/api/elaborate_test.go
+++ /dev/null
@@ -1,527 +0,0 @@
-package api
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-)
-
-// 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
-}
-
-// hasTool is a test helper that reports whether name is in the tools slice.
-func hasTool(tools []string, name string) bool {
- for _, t := range tools {
- if t == name {
- return true
- }
- }
- return false
-}
-
-// --- sanitizeElaboratedTask unit tests ---
-
-func TestSanitize_AddsWriteWhenInstructionsMentionFileCreation(t *testing.T) {
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: "Create a new file called output.txt with the results.",
- AllowedTools: []string{"Bash"},
- },
- }
- sanitizeElaboratedTask(task)
- if !hasTool(task.Agent.AllowedTools, "Write") {
- t.Errorf("expected Write in allowed_tools, got %v", task.Agent.AllowedTools)
- }
-}
-
-func TestSanitize_AddsReadWhenEditIsPresent(t *testing.T) {
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: "Modify the configuration file.",
- AllowedTools: []string{"Edit"},
- },
- }
- sanitizeElaboratedTask(task)
- if !hasTool(task.Agent.AllowedTools, "Read") {
- t.Errorf("expected Read added alongside Edit, got %v", task.Agent.AllowedTools)
- }
-}
-
-func TestSanitize_NoDuplicateTools(t *testing.T) {
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: "Run go test ./...",
- AllowedTools: []string{"Bash"},
- },
- }
- sanitizeElaboratedTask(task)
- count := 0
- for _, tool := range task.Agent.AllowedTools {
- if tool == "Bash" {
- count++
- }
- }
- if count != 1 {
- t.Errorf("Bash duplicated in allowed_tools: %v", task.Agent.AllowedTools)
- }
-}
-
-func TestSanitize_AddsAcceptanceCriteriaWhenMissing(t *testing.T) {
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: "Do something useful with the codebase.",
- AllowedTools: []string{"Bash"},
- },
- }
- sanitizeElaboratedTask(task)
- lower := strings.ToLower(task.Agent.Instructions)
- if !strings.Contains(lower, "acceptance") && !strings.Contains(lower, "done when") {
- t.Error("expected acceptance criteria section appended to instructions")
- }
-}
-
-func TestSanitize_NoopWhenAcceptanceCriteriaAlreadyPresent(t *testing.T) {
- original := "Do something.\n\n## Acceptance Criteria\n- All tests pass."
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: original,
- AllowedTools: []string{"Bash"},
- },
- }
- sanitizeElaboratedTask(task)
- if task.Agent.Instructions != original {
- t.Errorf("instructions were modified when acceptance criteria were already present")
- }
-}
-
-func TestSanitize_AddsTDDReminderForCodingTaskWithoutTestMention(t *testing.T) {
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: "## Acceptance Criteria\nFix the bug.\n\nModify the handler to return 404 instead of 500.",
- AllowedTools: []string{"Edit", "Read"},
- },
- }
- sanitizeElaboratedTask(task)
- lower := strings.ToLower(task.Agent.Instructions)
- if !strings.Contains(lower, "tdd") && !strings.Contains(lower, "test") {
- t.Error("expected TDD reminder for coding task without test mention")
- }
-}
-
-func TestSanitize_NoTDDReminderWhenTestsAlreadyMentioned(t *testing.T) {
- original := "## Acceptance Criteria\nAll tests pass.\n\nEdit the file and run go test ./... to verify."
- task := &elaboratedTask{
- Agent: elaboratedAgent{
- Instructions: original,
- AllowedTools: []string{"Edit", "Read", "Bash"},
- },
- }
- before := task.Agent.Instructions
- sanitizeElaboratedTask(task)
- // Should NOT add a second TDD block since tests are already mentioned.
- // Count occurrences of "tdd" / "test" — just verify no double-append.
- if strings.Count(strings.ToLower(task.Agent.Instructions), "tdd") > 1 {
- t.Errorf("TDD block added twice; instructions:\n%s", task.Agent.Instructions)
- }
- _ = before
-}
-
-func TestElaboratePrompt_RequiresAcceptanceCriteria(t *testing.T) {
- prompt := buildElaboratePrompt("")
- lower := strings.ToLower(prompt)
- if !strings.Contains(lower, "acceptance criteria") {
- t.Error("elaborate prompt should instruct the model to include acceptance criteria")
- }
-}
-
-func TestElaboratePrompt_RequiresAllRelevantTools(t *testing.T) {
- prompt := buildElaboratePrompt("")
- // Prompt must remind the model to include file-creating tools when needed.
- if !strings.Contains(prompt, "Write") {
- t.Error("elaborate prompt should mention the Write tool so models know to include it")
- }
-}
-
-func TestElaborateTask_SanitizationAppliedToResponse(t *testing.T) {
- srv, _ := testServer(t)
-
- // Elaborator returns a task that needs Write (instructions say "create file")
- // but does NOT include it in allowed_tools.
- task := elaboratedTask{
- Name: "Generate report",
- Description: "Creates a report file.",
- Agent: elaboratedAgent{
- Type: "claude",
- Model: "sonnet",
- Instructions: "Create a new file called report.md with the analysis results.\n\n## Acceptance Criteria\n- report.md exists.",
- MaxBudgetUSD: 0.5,
- AllowedTools: []string{"Bash"}, // Write intentionally missing
- },
- Timeout: "15m",
- Priority: "normal",
- Tags: []string{"report"},
- }
- taskJSON, _ := json.Marshal(task)
- wrapper := map[string]string{"result": string(taskJSON)}
- wrapperJSON, _ := json.Marshal(wrapper)
-
- srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0)
-
- body := `{"prompt":"generate a report"}`
- 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 !hasTool(result.Agent.AllowedTools, "Write") {
- t.Errorf("expected Write in sanitized allowed_tools, got %v", result.Agent.AllowedTools)
- }
-}
-
-func TestElaboratePrompt_ContainsWorkDir(t *testing.T) {
- prompt := buildElaboratePrompt("/some/custom/path")
- if !strings.Contains(prompt, "/some/custom/path") {
- t.Error("prompt should contain the provided workDir")
- }
- if strings.Contains(prompt, "/root/workspace/claudomator") {
- t.Error("prompt should not hardcode /root/workspace/claudomator")
- }
-}
-
-func TestElaboratePrompt_EmptyWorkDir(t *testing.T) {
- prompt := buildElaboratePrompt("")
- if strings.Contains(prompt, "/root") {
- t.Error("prompt should not reference /root when workDir is empty")
- }
-}
-
-func TestElaborateTask_Success(t *testing.T) {
- srv, _ := testServer(t)
-
- // Build fake Claude output: {"result": "<task-json>"}
- task := elaboratedTask{
- Name: "Run Go tests with race detector",
- Description: "Runs the Go test suite with -race flag and checks coverage.",
- Agent: elaboratedAgent{
- Type: "claude",
- Model: "sonnet",
- Instructions: "Run go test -race ./... and report results.",
- ProjectDir: "",
- 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.Agent.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.",
- Agent: elaboratedAgent{
- Type: "claude",
- 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)
- // Ensure Gemini fallback also fails so we get the expected 502.
- srv.geminiBinPath = "/nonexistent/gemini"
-
- 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")
- }
-}
-
-func createFakeClaudeCapturingArgs(t *testing.T, output string, exitCode int, argsFile string) 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")
- // Use printf to handle arguments safely
- content := fmt.Sprintf("#!/bin/sh\nprintf \"%%s\\n\" \"$@\" > %q\ncat %q\nexit %d\n", argsFile, outputFile, exitCode)
- if err := os.WriteFile(script, []byte(content), 0755); err != nil {
- t.Fatal(err)
- }
- return script
-}
-
-func TestElaborateTask_WithProjectContext(t *testing.T) {
- srv, _ := testServer(t)
-
- // Create a temporary workspace with CLAUDE.md and .agent/worklog.md
- workDir := t.TempDir()
- claudeContent := "Claude context info"
- sessionContent := "Session state info"
- if err := os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(claudeContent), 0600); err != nil {
- t.Fatal(err)
- }
- if err := os.MkdirAll(filepath.Join(workDir, ".agent"), 0700); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(workDir, ".agent", "worklog.md"), []byte(sessionContent), 0600); err != nil {
- t.Fatal(err)
- }
-
- // Capture arguments passed to claude
- argsFile := filepath.Join(t.TempDir(), "args.txt")
-
- task := elaboratedTask{
- Name: "Task with context",
- Agent: elaboratedAgent{
- Instructions: "Instructions",
- },
- }
- taskJSON, _ := json.Marshal(task)
- wrapper := map[string]string{"result": string(taskJSON)}
- wrapperJSON, _ := json.Marshal(wrapper)
-
- // Modified createFakeClaude to capture arguments
- srv.elaborateCmdPath = createFakeClaudeCapturingArgs(t, string(wrapperJSON), 0, argsFile)
-
- body := fmt.Sprintf(`{"prompt":"do something", "project_dir":"%s"}`, workDir)
- 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())
- }
-
- // Check if captured arguments contain the context
- capturedArgs, err := os.ReadFile(argsFile)
- if err != nil {
- t.Fatal(err)
- }
- argsStr := string(capturedArgs)
- if !strings.Contains(argsStr, claudeContent) {
- t.Errorf("expected arguments to contain CLAUDE.md content, got %s", argsStr)
- }
- if !strings.Contains(argsStr, sessionContent) {
- t.Errorf("expected arguments to contain .agent/worklog.md content, got %s", argsStr)
- }
-}
-
-func TestElaborateTask_NoRawNarrativeWithoutExplicitProjectDir(t *testing.T) {
- srv, _ := testServer(t)
- // Point workDir at a temp dir so any accidental write is detectable.
- srv.workDir = t.TempDir()
-
- task := elaboratedTask{
- Name: "Task",
- Agent: elaboratedAgent{Instructions: "Instructions"},
- }
- taskJSON, _ := json.Marshal(task)
- wrapper := map[string]string{"result": string(taskJSON)}
- wrapperJSON, _ := json.Marshal(wrapper)
- srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0)
-
- // No project_dir in request body.
- 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", w.Code)
- }
-
- time.Sleep(30 * time.Millisecond) // let goroutine run if it was incorrectly triggered
- narrativePath := filepath.Join(srv.workDir, "docs", "RAW_NARRATIVE.md")
- if _, err := os.Stat(narrativePath); err == nil {
- t.Errorf("RAW_NARRATIVE.md should NOT be written when project_dir is not provided by the user")
- }
-}
-
-func TestElaborateTask_AppendsRawNarrative(t *testing.T) {
- srv, _ := testServer(t)
-
- workDir := t.TempDir()
- prompt := "this is my raw request"
-
- task := elaboratedTask{
- Name: "Task",
- Agent: elaboratedAgent{
- Instructions: "Instructions",
- },
- }
- taskJSON, _ := json.Marshal(task)
- wrapper := map[string]string{"result": string(taskJSON)}
- wrapperJSON, _ := json.Marshal(wrapper)
-
- srv.elaborateCmdPath = createFakeClaude(t, string(wrapperJSON), 0)
-
- body := fmt.Sprintf(`{"prompt":"%s", "project_dir":"%s"}`, prompt, workDir)
- 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())
- }
-
- // It runs in a goroutine, so wait a bit
- path := filepath.Join(workDir, "docs", "RAW_NARRATIVE.md")
- var data []byte
- var err error
- for i := 0; i < 10; i++ {
- data, err = os.ReadFile(path)
- if err == nil {
- break
- }
- time.Sleep(10 * time.Millisecond)
- }
-
- if err != nil {
- t.Fatalf("failed to read RAW_NARRATIVE.md: %v", err)
- }
- if !strings.Contains(string(data), prompt) {
- t.Errorf("expected RAW_NARRATIVE.md to contain prompt, got %s", string(data))
- }
-}
diff --git a/internal/api/server.go b/internal/api/server.go
index c9fadb9..6564280 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -133,7 +133,6 @@ func (s *Server) StartHub() {
}
func (s *Server) routes() {
- s.mux.HandleFunc("POST /api/tasks/elaborate", s.handleElaborateTask)
s.mux.HandleFunc("POST /api/tasks/validate", s.handleValidateTask)
s.mux.HandleFunc("POST /api/tasks", s.handleCreateTask)
s.mux.HandleFunc("GET /api/tasks", s.handleListTasks)
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
index dd6eed5..43d91b0 100644
--- a/internal/api/server_test.go
+++ b/internal/api/server_test.go
@@ -1228,34 +1228,6 @@ func TestServer_AnswerQuestion_UpdateStateFails_Returns500(t *testing.T) {
}
}
-func TestRateLimit_ElaborateRejectsExcess(t *testing.T) {
- srv, _ := testServer(t)
- // Use burst-1 and rate-0 so the second request from the same IP is rejected.
- srv.elaborateLimiter = newIPRateLimiter(0, 1)
-
- makeReq := func(remoteAddr string) int {
- req := httptest.NewRequest("POST", "/api/tasks/elaborate", bytes.NewBufferString(`{"description":"x"}`))
- req.Header.Set("Content-Type", "application/json")
- req.RemoteAddr = remoteAddr
- w := httptest.NewRecorder()
- srv.Handler().ServeHTTP(w, req)
- return w.Code
- }
-
- // First request from IP A: limiter allows it (non-429).
- if code := makeReq("192.0.2.1:1234"); code == http.StatusTooManyRequests {
- t.Errorf("first request should not be rate limited, got 429")
- }
- // Second request from IP A: bucket exhausted, must be 429.
- if code := makeReq("192.0.2.1:1234"); code != http.StatusTooManyRequests {
- t.Errorf("second request from same IP should be 429, got %d", code)
- }
- // First request from IP B: separate bucket, not limited.
- if code := makeReq("192.0.2.2:1234"); code == http.StatusTooManyRequests {
- t.Errorf("first request from different IP should not be rate limited, got 429")
- }
-}
-
func TestListWorkspaces_RequiresAuth(t *testing.T) {
srv, _ := testServer(t)
srv.SetAPIToken("secret-token")
diff --git a/internal/api/validate_test.go b/internal/api/validate_test.go
index c3d7b1f..60fec14 100644
--- a/internal/api/validate_test.go
+++ b/internal/api/validate_test.go
@@ -3,11 +3,32 @@ package api
import (
"bytes"
"encoding/json"
+ "fmt"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"testing"
)
+// createFakeClaude writes a stub `claude` script that prints the given output
+// and exits with the given code, returning its path for use as a binary
+// override in 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 TestValidateTask_Success(t *testing.T) {
srv, _ := testServer(t)