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. func TestClassifier_Classify_Mock(t *testing.T) { // Create a temporary mock binary. mockBinary := filepathJoin(t.TempDir(), "mock-gemini") mockContent := `#!/bin/sh echo '{"response": "{\"agent_type\": \"gemini\", \"model\": \"gemini-2.5-flash-lite\", \"reason\": \"test reason\"}"}' ` if err := os.WriteFile(mockBinary, []byte(mockContent), 0755); err != nil { t.Fatal(err) } c := &Classifier{GeminiBinaryPath: mockBinary} status := SystemStatus{ ActiveTasks: map[string]int{"claude": 5, "gemini": 1}, RateLimited: map[string]bool{"claude": false, "gemini": false}, } cls, err := c.Classify(context.Background(), "Test Task", "Test Instructions", status, "gemini") if err != nil { t.Fatalf("Classify failed: %v", err) } if cls.AgentType != "gemini" { t.Errorf("expected gemini, got %s", cls.AgentType) } if cls.Model != "gemini-2.5-flash-lite" { t.Errorf("expected gemini-2.5-flash-lite, got %s", cls.Model) } } // 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 { if i == 0 { path = e } else { path = path + string(os.PathSeparator) + e } } return path }