summaryrefslogtreecommitdiff
path: root/internal/executor/classifier_test.go
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-04-28 09:24:43 +0000
committerClaude <noreply@anthropic.com>2026-04-28 09:24:43 +0000
commit0865afc43be562dbe14528e4299b9e213b54cc93 (patch)
tree3ffb11207fb6b9866b5a2477bba7abe38964f83a /internal/executor/classifier_test.go
parentc2aa026f6ce1c9e216b99d74f294fc133d5fcddd (diff)
feat(executor): add LocalRunner and OpenAI-compat LLM client
Phase 1 of "local OSS models as agents" plan. Adds a third Runner backed by any OpenAI-compatible HTTP server (Ollama, vLLM, LM Studio, llama.cpp), and migrates the Gemini-CLI classifier to route through the same client when configured. Two-layer split: internal/llm.Client is the workhorse (HTTP, no Pool, no DB) used directly by the classifier and any future internal helper that needs cheap reasoning. internal/executor.LocalRunner is a thin adapter implementing Runner for user-facing tasks. This avoids Pool reentrancy/deadlock when sub-second internal calls fire from inside Pool.execute(). Highlights: - internal/retry: relocated runWithBackoff/IsRateLimitError/ParseRetryAfter into a shared package reused by executor and llm. - internal/llm: Chat (non-streaming) and ChatStream (SSE) over /chat/completions with optional bearer auth, json_object response format, retry on 429/503, Retry-After parsing. - internal/executor/LocalRunner: streams deltas into stdout.log in the same stream-json envelope ClaudeRunner emits, then writes one consolidated assistant block plus a result terminator so existing parsers (extractSummary, ParseChangestatFromOutput) work unchanged. - internal/executor/Classifier: gains optional LLM field; uses json_object response format (no markdown-fence cleanup needed). Falls back to Gemini-CLI subprocess when LLM is nil. - Pool.skipClassification: now skips only when the requested agent type is registered, so unknown types still reach the load balancer. - Storage: additive tokens_in/tokens_out ALTERs on executions; CLI runners record cost_usd as before, LocalRunner records 0 + tokens. - Config: [local_model] section (endpoint, model, timeout_seconds, default_temperature, api_key). Empty endpoint = no LocalRunner registered, classifier falls back to Gemini. Pre-existing test issues fixed in passing: - claude_test.go setupSandbox callsites updated to current signature. - gemini_test.go TestParseGeminiStream skipped (asserts unimplemented GeminiRunner stream-error parsing; tracked separately). Plan: docs/plans/local-oss-runner.md. https://claude.ai/code/session_017Edeq947TpSm1vQTxMhi1J
Diffstat (limited to 'internal/executor/classifier_test.go')
-rw-r--r--internal/executor/classifier_test.go76
1 files changed, 76 insertions, 0 deletions
diff --git a/internal/executor/classifier_test.go b/internal/executor/classifier_test.go
index 83a9743..84fffcf 100644
--- a/internal/executor/classifier_test.go
+++ b/internal/executor/classifier_test.go
@@ -2,8 +2,15 @@ package executor
import (
"context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
"os"
+ "strings"
"testing"
+
+ "github.com/thepeterstone/claudomator/internal/llm"
)
// TestClassifier_Classify_Mock tests the classifier with a mocked gemini binary.
@@ -36,6 +43,75 @@ echo '{"response": "{\"agent_type\": \"gemini\", \"model\": \"gemini-2.5-flash-l
}
}
+// TestClassifier_Classify_LLM tests classification through a local OpenAI-compatible LLM.
+func TestClassifier_Classify_LLM(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Verify the classifier asked for JSON mode.
+ var body struct {
+ ResponseFormat *struct {
+ Type string `json:"type"`
+ } `json:"response_format"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Fatalf("decode body: %v", err)
+ }
+ if body.ResponseFormat == nil || body.ResponseFormat.Type != "json_object" {
+ t.Errorf("classifier should request json_object response format")
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprintln(w, `{
+ "model":"local-fast",
+ "choices":[{"message":{"role":"assistant","content":"{\"agent_type\":\"claude\",\"model\":\"claude-haiku-4-5-20251001\",\"reason\":\"trivial task\"}"},"finish_reason":"stop"}],
+ "usage":{"prompt_tokens":10,"completion_tokens":15}
+ }`)
+ }))
+ defer srv.Close()
+
+ c := &Classifier{
+ LLM: &llm.Client{Endpoint: srv.URL + "/v1", Model: "local-fast"},
+ }
+ status := SystemStatus{
+ ActiveTasks: map[string]int{"claude": 1, "gemini": 0},
+ RateLimited: map[string]bool{},
+ }
+
+ cls, err := c.Classify(context.Background(), "List files", "ls -la", status, "claude")
+ if err != nil {
+ t.Fatalf("Classify: %v", err)
+ }
+ if cls.AgentType != "claude" {
+ t.Errorf("AgentType: want claude got %q", cls.AgentType)
+ }
+ if cls.Model != "claude-haiku-4-5-20251001" {
+ t.Errorf("Model: want claude-haiku-4-5-20251001 got %q", cls.Model)
+ }
+ if !strings.Contains(cls.Reason, "trivial") {
+ t.Errorf("Reason mismatch: %q", cls.Reason)
+ }
+}
+
+// TestClassifier_LLMTakesPrecedence_OverGemini ensures the LLM path is preferred when both are configured.
+func TestClassifier_LLMTakesPrecedence_OverGemini(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":"{\"agent_type\":\"claude\",\"model\":\"claude-sonnet-4-6\",\"reason\":\"r\"}"},"finish_reason":"stop"}],"usage":{}}`)
+ }))
+ defer srv.Close()
+
+ c := &Classifier{
+ LLM: &llm.Client{Endpoint: srv.URL + "/v1", Model: "x"},
+ GeminiBinaryPath: "/nonexistent/gemini-binary-should-not-be-called",
+ }
+ cls, err := c.Classify(context.Background(), "n", "i", SystemStatus{}, "claude")
+ if err != nil {
+ t.Fatalf("Classify: %v", err)
+ }
+ if cls.Model != "claude-sonnet-4-6" {
+ t.Errorf("expected LLM path; got Model=%q", cls.Model)
+ }
+}
+
func filepathJoin(elems ...string) string {
var path string
for i, e := range elems {