summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-05-13 04:02:20 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-05-13 04:02:20 +0000
commit68399a598924775a3ec22a39c2336ae497fb07f3 (patch)
tree29ade8224eb51eca47a1d9d03bb4d0d3653a72aa /internal/api
parentf01231cc45f41ce2dc37072e77428e467ef3fc15 (diff)
parentd970c0730ff0dc7d714d3261197d8ba52b5d21f4 (diff)
merge: integrate github/main — LocalRunner, real GeminiRunner, llm clientHEADmain
Merges 12 commits from github/main (formerly master) that were developed independently. Key additions: - LocalRunner: OpenAI-compatible local LLM execution (Ollama, LM Studio) - Real GeminiRunner with full sandbox parity to ClaudeRunner - llm.Client for enriching CI failures and elaboration via local model - retry.ParseRetryAfter moved to shared package - tokens_in/tokens_out columns in executions table Conflict resolutions: - Kept local main's VAPID/push, stories, projects, agent events schema - Merged both sets of Config fields (local + LocalModel from github/main) - Unified activePerAgent accounting (decActiveAgent helper) - Removed duplicate helpers from claude.go (now in helpers.go) - Fixed double-decrement bug in handleRunResult vs decActiveAgent Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/elaborate.go60
-rw-r--r--internal/api/elaborate_local_test.go214
-rw-r--r--internal/api/server.go10
-rw-r--r--internal/api/webhook.go15
-rw-r--r--internal/api/webhook_llm.go127
-rw-r--r--internal/api/webhook_llm_test.go228
6 files changed, 642 insertions, 12 deletions
diff --git a/internal/api/elaborate.go b/internal/api/elaborate.go
index 0cb298d..8676b36 100644
--- a/internal/api/elaborate.go
+++ b/internal/api/elaborate.go
@@ -12,6 +12,8 @@ import (
"sort"
"strings"
"time"
+
+ "github.com/thepeterstone/claudomator/internal/llm"
)
const elaborateTimeout = 30 * time.Second
@@ -245,6 +247,33 @@ func (s *Server) elaborateWithClaude(ctx context.Context, workDir, fullPrompt st
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(),
@@ -511,18 +540,27 @@ func (s *Server) handleElaborateTask(w http.ResponseWriter, r *http.Request) {
var result *elaboratedTask
var err error
- // Try Claude first.
- result, err = s.elaborateWithClaude(ctx, workDir, fullPrompt)
- if err != nil {
- s.logger.Warn("elaborate: claude failed, falling back to gemini", "error", err)
- // Fallback to Gemini.
- result, err = s.elaborateWithGemini(ctx, workDir, fullPrompt)
+ // 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.Error("elaborate: fallback gemini also failed", "error", err)
- writeJSON(w, http.StatusBadGateway, map[string]string{
- "error": fmt.Sprintf("elaboration failed: %v", err),
- })
- return
+ 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
+ }
}
}
diff --git a/internal/api/elaborate_local_test.go b/internal/api/elaborate_local_test.go
new file mode 100644
index 0000000..09a8f9e
--- /dev/null
+++ b/internal/api/elaborate_local_test.go
@@ -0,0 +1,214 @@
+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/server.go b/internal/api/server.go
index e7756d1..28cfe4a 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -12,6 +12,7 @@ import (
"github.com/thepeterstone/claudomator/internal/config"
"github.com/thepeterstone/claudomator/internal/executor"
+ "github.com/thepeterstone/claudomator/internal/llm"
"github.com/thepeterstone/claudomator/internal/notify"
"github.com/thepeterstone/claudomator/internal/storage"
"github.com/thepeterstone/claudomator/internal/task"
@@ -57,6 +58,7 @@ type Server struct {
vapidEmail string
pushStore pushSubscriptionStore
dropsDir string
+ llm *llm.Client
}
// SetAPIToken configures a bearer token that must be supplied to access the API.
@@ -89,6 +91,14 @@ func (s *Server) SetWorkspaceRoot(path string) {
// Pool returns the executor pool, for graceful shutdown by the caller.
func (s *Server) Pool() *executor.Pool { return s.pool }
+// SetLLM wires a local OpenAI-compatible LLM client for use by elaboration
+// (and future internal helpers). When non-nil, elaboration will prefer it
+// over the Claude CLI; on failure it falls back to claude → gemini.
+func (s *Server) SetLLM(c *llm.Client) {
+ s.llm = c
+}
+
+
func NewServer(store *storage.DB, pool *executor.Pool, logger *slog.Logger, claudeBinPath, geminiBinPath string) *Server {
wd, _ := os.Getwd()
s := &Server{
diff --git a/internal/api/webhook.go b/internal/api/webhook.go
index 141224f..3af4cc8 100644
--- a/internal/api/webhook.go
+++ b/internal/api/webhook.go
@@ -1,6 +1,7 @@
package api
import (
+ "context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
@@ -184,7 +185,7 @@ func (s *Server) createCIFailureTask(w http.ResponseWriter, repoName, fullName,
htmlURL = fmt.Sprintf("https://github.com/%s/commit/%s", fullName, sha)
}
- instructions := fmt.Sprintf(
+ fallback := fmt.Sprintf(
"A CI failure has been detected and requires investigation.\n\n"+
"Repository: %s\n"+
"Branch: %s\n"+
@@ -199,6 +200,18 @@ func (s *Server) createCIFailureTask(w http.ResponseWriter, repoName, fullName,
fullName, branch, sha, checkName, htmlURL,
)
+ tctx := ciTriageContext{
+ Repo: fullName,
+ Branch: branch,
+ SHA: sha,
+ CheckName: checkName,
+ URL: htmlURL,
+ }
+ if project != nil {
+ tctx.ProjectDir = project.Dir
+ }
+ instructions := enrichCIInstructions(context.Background(), s.llm, tctx, fallback)
+
now := time.Now().UTC()
t := &task.Task{
ID: uuid.New().String(),
diff --git a/internal/api/webhook_llm.go b/internal/api/webhook_llm.go
new file mode 100644
index 0000000..1cbca17
--- /dev/null
+++ b/internal/api/webhook_llm.go
@@ -0,0 +1,127 @@
+package api
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/thepeterstone/claudomator/internal/llm"
+)
+
+// ciTriagePromptTimeout caps the LLM enrichment call so a slow local model
+// can't stall webhook handling. On timeout the original template is used.
+const ciTriagePromptTimeout = 10 * time.Second
+
+// ciTriageContext holds everything we know at webhook time, plus best-effort
+// project-side signals (recent git log, CLAUDE.md content) when project_dir
+// is available.
+type ciTriageContext struct {
+ Repo string
+ Branch string
+ SHA string
+ CheckName string
+ URL string
+ ProjectDir string
+ RecentCommits string // multi-line, may be ""
+ ProjectDoc string // first ~4 KB of CLAUDE.md, may be ""
+}
+
+// enrichCIInstructions asks the local LLM to produce a tighter, project-aware
+// investigation plan than the hardcoded template. On any error (no client,
+// timeout, parse failure) it returns fallback unchanged so the webhook flow
+// is never worse off for trying.
+func enrichCIInstructions(parent context.Context, c *llm.Client, ctx ciTriageContext, fallback string) string {
+ if c == nil {
+ return fallback
+ }
+
+ // Pull project-side signals best-effort. Errors are silently swallowed —
+ // the LLM still gets the metadata it does have.
+ if ctx.ProjectDir != "" {
+ ctx.RecentCommits = readRecentCommits(ctx.ProjectDir, 5)
+ ctx.ProjectDoc = readProjectDoc(ctx.ProjectDir)
+ }
+
+ cctx, cancel := context.WithTimeout(parent, ciTriagePromptTimeout)
+ defer cancel()
+
+ prompt := buildCITriagePrompt(ctx)
+ resp, err := c.Chat(cctx, llm.ChatRequest{
+ Messages: []llm.Message{
+ {Role: "system", Content: "You produce concise, actionable CI failure investigation plans. Respond with plain text only — no markdown fences, no JSON, no preamble."},
+ {Role: "user", Content: prompt},
+ },
+ })
+ if err != nil {
+ return fallback
+ }
+ body := strings.TrimSpace(resp.Content)
+ if body == "" {
+ return fallback
+ }
+ // Always preserve the metadata header from the fallback so investigators
+ // can see repo/branch/SHA/URL even if the LLM body is terse.
+ return ciInstructionsHeader(ctx) + "\n\n" + body
+}
+
+func buildCITriagePrompt(ctx ciTriageContext) string {
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "CI just failed.\n\nRepository: %s\nBranch: %s\nCommit SHA: %s\nCheck/Workflow: %s\nRun URL: %s\n",
+ ctx.Repo, ctx.Branch, ctx.SHA, ctx.CheckName, ctx.URL)
+ if ctx.RecentCommits != "" {
+ fmt.Fprintf(&sb, "\nRecent commits on this branch (newest first):\n%s\n", ctx.RecentCommits)
+ }
+ if ctx.ProjectDoc != "" {
+ fmt.Fprintf(&sb, "\nProject context (CLAUDE.md, truncated):\n%s\n", ctx.ProjectDoc)
+ }
+ sb.WriteString("\nProduce 6–12 lines of investigation steps. Name suspect commits or files when you can; otherwise give concrete starting actions (which logs to read, which tests to re-run locally). End with an explicit 'Acceptance Criteria' section listing what 'fixed' looks like.")
+ return sb.String()
+}
+
+func ciInstructionsHeader(ctx ciTriageContext) string {
+ return fmt.Sprintf(
+ "A CI failure has been detected and requires investigation.\n\n"+
+ "Repository: %s\n"+
+ "Branch: %s\n"+
+ "Commit SHA: %s\n"+
+ "Check/Workflow: %s\n"+
+ "Run URL: %s",
+ ctx.Repo, ctx.Branch, ctx.SHA, ctx.CheckName, ctx.URL,
+ )
+}
+
+// readRecentCommits returns the last n commits as a `git log --oneline`-style
+// string, or "" on any error.
+func readRecentCommits(projectDir string, n int) string {
+ if projectDir == "" {
+ return ""
+ }
+ cctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(cctx, "git", "-C", projectDir, "log", "--oneline", fmt.Sprintf("-n%d", n))
+ out, err := cmd.Output()
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(out))
+}
+
+// readProjectDoc returns CLAUDE.md content (capped at 4KB) or "".
+func readProjectDoc(projectDir string) string {
+ if projectDir == "" {
+ return ""
+ }
+ data, err := os.ReadFile(filepath.Join(projectDir, "CLAUDE.md"))
+ if err != nil {
+ return ""
+ }
+ const cap = 4096
+ if len(data) > cap {
+ data = data[:cap]
+ }
+ return strings.TrimSpace(string(data))
+}
diff --git a/internal/api/webhook_llm_test.go b/internal/api/webhook_llm_test.go
new file mode 100644
index 0000000..f2381a1
--- /dev/null
+++ b/internal/api/webhook_llm_test.go
@@ -0,0 +1,228 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/thepeterstone/claudomator/internal/config"
+ "github.com/thepeterstone/claudomator/internal/llm"
+)
+
+// initGitRepo creates a fresh git repo with two commits and returns its path.
+// Used to verify enrichCIInstructions picks up recent commits.
+func initGitRepo(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ run := func(args ...string) {
+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
+ "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
+ // Disable signing in case the host has a global pre-commit signer.
+ "GIT_CONFIG_GLOBAL=/dev/null",
+ )
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+ run("init", "-q")
+ run("config", "commit.gpgsign", "false")
+ run("config", "tag.gpgsign", "false")
+ if err := os.WriteFile(filepath.Join(dir, "README"), []byte("v1\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ run("add", "README")
+ run("commit", "-q", "-m", "first commit", "--no-gpg-sign")
+ if err := os.WriteFile(filepath.Join(dir, "README"), []byte("v2\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ run("add", "README")
+ run("commit", "-q", "-m", "fix: bump readme", "--no-gpg-sign")
+ return dir
+}
+
+func TestEnrichCIInstructions_NilClient_ReturnsFallback(t *testing.T) {
+ got := enrichCIInstructions(context.Background(), nil, ciTriageContext{}, "FALLBACK")
+ if got != "FALLBACK" {
+ t.Errorf("nil client: want FALLBACK, got %q", got)
+ }
+}
+
+func TestEnrichCIInstructions_LLMFailure_ReturnsFallback(t *testing.T) {
+ // Server that always 500s.
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "boom", http.StatusInternalServerError)
+ }))
+ defer srv.Close()
+
+ c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"}
+ got := enrichCIInstructions(context.Background(), c,
+ ciTriageContext{Repo: "x", Branch: "main"}, "FALLBACK")
+ if got != "FALLBACK" {
+ t.Errorf("llm failure: want FALLBACK, got %q", got)
+ }
+}
+
+func TestEnrichCIInstructions_EmptyLLMBody_ReturnsFallback(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintln(w, `{"model":"x","choices":[{"message":{"content":""},"finish_reason":"stop"}],"usage":{}}`)
+ }))
+ defer srv.Close()
+ c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"}
+ got := enrichCIInstructions(context.Background(), c, ciTriageContext{}, "FALLBACK-2")
+ if got != "FALLBACK-2" {
+ t.Errorf("empty body: want fallback, got %q", got)
+ }
+}
+
+func TestEnrichCIInstructions_LLMSuccess_ReturnsEnriched(t *testing.T) {
+ expected := "1. Look at commit abc123\n2. Re-run build locally\n3. Check unit tests"
+
+ var capturedPrompt string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Messages []struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ } `json:"messages"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ // Capture the user message so we can assert metadata is in the prompt.
+ for _, m := range body.Messages {
+ if m.Role == "user" {
+ capturedPrompt = m.Content
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintf(w, `{"model":"x","choices":[{"message":{"content":%q},"finish_reason":"stop"}],"usage":{}}`, expected)
+ }))
+ defer srv.Close()
+
+ c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"}
+ tctx := ciTriageContext{
+ Repo: "owner/myrepo",
+ Branch: "main",
+ SHA: "abc123",
+ CheckName: "CI Build",
+ URL: "https://github.com/owner/myrepo/runs/1",
+ }
+ got := enrichCIInstructions(context.Background(), c, tctx, "FALLBACK")
+
+ if !strings.Contains(got, expected) {
+ t.Errorf("enriched body missing LLM content; got: %s", got)
+ }
+ if !strings.Contains(got, "Repository: owner/myrepo") {
+ t.Errorf("enriched body missing metadata header; got: %s", got)
+ }
+ for _, want := range []string{"owner/myrepo", "main", "abc123", "CI Build"} {
+ if !strings.Contains(capturedPrompt, want) {
+ t.Errorf("prompt missing %q; got: %s", want, capturedPrompt)
+ }
+ }
+}
+
+func TestEnrichCIInstructions_IncludesRecentCommits(t *testing.T) {
+ repo := initGitRepo(t)
+
+ var capturedPrompt string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Messages []struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ } `json:"messages"`
+ }
+ json.NewDecoder(r.Body).Decode(&body)
+ for _, m := range body.Messages {
+ if m.Role == "user" {
+ capturedPrompt = m.Content
+ }
+ }
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintln(w, `{"model":"x","choices":[{"message":{"content":"plan"},"finish_reason":"stop"}],"usage":{}}`)
+ }))
+ defer srv.Close()
+
+ c := &llm.Client{Endpoint: srv.URL + "/v1", Model: "fake"}
+ enrichCIInstructions(context.Background(), c,
+ ciTriageContext{Repo: "x", Branch: "y", ProjectDir: repo}, "FALLBACK")
+
+ if !strings.Contains(capturedPrompt, "Recent commits") {
+ t.Errorf("expected prompt to include recent commits section; got:\n%s", capturedPrompt)
+ }
+ if !strings.Contains(capturedPrompt, "fix: bump readme") {
+ t.Errorf("expected most recent commit message in prompt; got:\n%s", capturedPrompt)
+ }
+}
+
+// TestWebhook_NoLLM_InstructionsPreserved is the regression guard: when no
+// LLM is configured, webhook task instructions match the historical template
+// exactly.
+func TestWebhook_NoLLM_InstructionsPreserved(t *testing.T) {
+ srv, store := testServer(t)
+ srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}}
+
+ w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "")
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: %d", w.Code)
+ }
+ var resp map[string]string
+ json.NewDecoder(w.Body).Decode(&resp)
+ tk, err := store.GetTask(resp["task_id"])
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{
+ "A CI failure has been detected",
+ "Please investigate the failure by:",
+ "1. Reviewing recent commits on the branch",
+ "4. Fixing the root cause and ensuring the build passes",
+ } {
+ if !strings.Contains(tk.Agent.Instructions, want) {
+ t.Errorf("instructions missing %q (regression: LLM path leaked into no-LLM case)", want)
+ }
+ }
+}
+
+// TestWebhook_WithLLM_InstructionsEnriched verifies the LLM body appears in
+// the created task's instructions when SetLLM is configured.
+func TestWebhook_WithLLM_InstructionsEnriched(t *testing.T) {
+ srv, store := testServer(t)
+ srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}}
+
+ llmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintln(w, `{"model":"x","choices":[{"message":{"content":"LLM-GENERATED-PLAN"},"finish_reason":"stop"}],"usage":{}}`)
+ }))
+ defer llmSrv.Close()
+ srv.SetLLM(&llm.Client{Endpoint: llmSrv.URL + "/v1", Model: "fake"})
+
+ w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "")
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: %d body: %s", w.Code, w.Body.String())
+ }
+ var resp map[string]string
+ json.NewDecoder(w.Body).Decode(&resp)
+ tk, err := store.GetTask(resp["task_id"])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(tk.Agent.Instructions, "LLM-GENERATED-PLAN") {
+ t.Errorf("instructions missing LLM body; got:\n%s", tk.Agent.Instructions)
+ }
+ if !strings.Contains(tk.Agent.Instructions, "Repository: owner/myrepo") {
+ t.Errorf("instructions missing metadata header; got:\n%s", tk.Agent.Instructions)
+ }
+}