test: añadir tests para claude-code provider y router
27 tests nuevos cubriendo las funciones del provider claude-code: - buildClaudeArgs: minimal, all options, disable_tools, disallowed_tools - flattenMessages: empty, multi-role, skips system messages - parseClaudeOutput: success, error response, process failed (con/sin stderr), fallback a plain text, content blocks, exec error con stdout parcial - filterEnv: single key, multiple keys, no match, prefix safety - Route: claude-code, claude-code/custom, claude-*, gpt-*, ollama/*, default - ModelName: ollama prefix strip, passthrough Todos pasan con 'go test -tags goolm ./shell/llm/ ./pkg/llm/'.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
package llm
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRoute(t *testing.T) {
|
||||
tests := []struct {
|
||||
model string
|
||||
want ProviderID
|
||||
}{
|
||||
{"claude-code", ProviderClaudeCode},
|
||||
{"claude-code/custom", ProviderClaudeCode},
|
||||
{"claude-sonnet-4-5-20250929", ProviderAnthropic},
|
||||
{"claude-opus-4", ProviderAnthropic},
|
||||
{"gpt-4o", ProviderOpenAI},
|
||||
{"o1-preview", ProviderOpenAI},
|
||||
{"o3-mini", ProviderOpenAI},
|
||||
{"ollama/mistral", ProviderOllama},
|
||||
{"unknown-model", ProviderOpenAI}, // default
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.model, func(t *testing.T) {
|
||||
got := Route(tt.model)
|
||||
if got != tt.want {
|
||||
t.Errorf("Route(%q) = %q, want %q", tt.model, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{"ollama/mistral", "mistral"},
|
||||
{"gpt-4o", "gpt-4o"},
|
||||
{"claude-sonnet-4-5-20250929", "claude-sonnet-4-5-20250929"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := ModelName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("ModelName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/enmanuel/agents/internal/config"
|
||||
coretypes "github.com/enmanuel/agents/pkg/llm"
|
||||
)
|
||||
|
||||
var discardLog = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
// ── buildClaudeArgs ──────────────────────────────────────────────────────
|
||||
|
||||
func TestBuildClaudeArgs_Minimal(t *testing.T) {
|
||||
cfg := config.ClaudeCodeCfg{}
|
||||
req := coretypes.CompletionRequest{}
|
||||
|
||||
args := buildClaudeArgs(cfg, req)
|
||||
|
||||
// Must always start with --print --output-format json
|
||||
want := []string{"--print", "--output-format", "json"}
|
||||
if len(args) != len(want) {
|
||||
t.Fatalf("got %v, want %v", args, want)
|
||||
}
|
||||
for i := range want {
|
||||
if args[i] != want[i] {
|
||||
t.Errorf("args[%d] = %q, want %q", i, args[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClaudeArgs_AllOptions(t *testing.T) {
|
||||
cfg := config.ClaudeCodeCfg{
|
||||
Model: "sonnet",
|
||||
FallbackModel: "haiku",
|
||||
PermissionMode: "bypassPermissions",
|
||||
AllowedTools: []string{"Bash(git:*)", "Read"},
|
||||
SessionID: "abc-123",
|
||||
AddDirs: []string{"/tmp/extra"},
|
||||
}
|
||||
req := coretypes.CompletionRequest{
|
||||
SystemPrompt: "You are a helpful bot",
|
||||
}
|
||||
|
||||
args := buildClaudeArgs(cfg, req)
|
||||
|
||||
assertContains(t, args, "--system-prompt", "You are a helpful bot")
|
||||
assertContains(t, args, "--model", "sonnet")
|
||||
assertContains(t, args, "--fallback-model", "haiku")
|
||||
assertContains(t, args, "--permission-mode", "bypassPermissions")
|
||||
assertContains(t, args, "--session-id", "abc-123")
|
||||
assertContains(t, args, "--add-dir", "/tmp/extra")
|
||||
assertContains(t, args, "--allowedTools", "Bash(git:*)")
|
||||
}
|
||||
|
||||
func TestBuildClaudeArgs_DisableTools(t *testing.T) {
|
||||
cfg := config.ClaudeCodeCfg{
|
||||
DisableTools: true,
|
||||
AllowedTools: []string{"Bash"}, // should be ignored
|
||||
}
|
||||
req := coretypes.CompletionRequest{}
|
||||
|
||||
args := buildClaudeArgs(cfg, req)
|
||||
|
||||
assertContains(t, args, "--tools", "")
|
||||
// --allowedTools must NOT appear when disable_tools is set
|
||||
for _, a := range args {
|
||||
if a == "--allowedTools" {
|
||||
t.Error("--allowedTools should not appear when DisableTools=true")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClaudeArgs_DisallowedTools(t *testing.T) {
|
||||
cfg := config.ClaudeCodeCfg{
|
||||
DisallowedTools: []string{"Edit", "Write"},
|
||||
}
|
||||
req := coretypes.CompletionRequest{}
|
||||
|
||||
args := buildClaudeArgs(cfg, req)
|
||||
assertContains(t, args, "--disallowedTools", "Edit")
|
||||
}
|
||||
|
||||
// ── flattenMessages ──────────────────────────────────────────────────────
|
||||
|
||||
func TestFlattenMessages_Empty(t *testing.T) {
|
||||
got := flattenMessages(nil)
|
||||
if got != "" {
|
||||
t.Errorf("expected empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenMessages_MultiRole(t *testing.T) {
|
||||
msgs := []coretypes.Message{
|
||||
{Role: coretypes.RoleUser, Content: "hello"},
|
||||
{Role: coretypes.RoleAssistant, Content: "hi there"},
|
||||
{Role: coretypes.RoleTool, Content: `{"time":"12:00"}`},
|
||||
{Role: coretypes.RoleUser, Content: "thanks"},
|
||||
}
|
||||
|
||||
got := flattenMessages(msgs)
|
||||
|
||||
expects := []string{
|
||||
"User: hello",
|
||||
"Assistant: hi there",
|
||||
`Tool result: {"time":"12:00"}`,
|
||||
"User: thanks",
|
||||
}
|
||||
for _, e := range expects {
|
||||
if !contains(got, e) {
|
||||
t.Errorf("missing %q in:\n%s", e, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenMessages_SkipsSystem(t *testing.T) {
|
||||
msgs := []coretypes.Message{
|
||||
{Role: coretypes.RoleSystem, Content: "system prompt"},
|
||||
{Role: coretypes.RoleUser, Content: "hello"},
|
||||
}
|
||||
|
||||
got := flattenMessages(msgs)
|
||||
if contains(got, "system prompt") {
|
||||
t.Error("system messages should not appear in flattened output")
|
||||
}
|
||||
if !contains(got, "User: hello") {
|
||||
t.Error("user message missing")
|
||||
}
|
||||
}
|
||||
|
||||
// ── parseClaudeOutput ────────────────────────────────────────────────────
|
||||
|
||||
func TestParseClaudeOutput_Success(t *testing.T) {
|
||||
output := claudeJSONOutput{
|
||||
Type: "result",
|
||||
Subtype: "success",
|
||||
IsError: false,
|
||||
NumTurns: 1,
|
||||
Result: "Hello! I'm Claude.",
|
||||
TotalCost: 0.025,
|
||||
Usage: claudeUsage{InputTokens: 10, OutputTokens: 50},
|
||||
}
|
||||
stdout, _ := json.Marshal(output)
|
||||
|
||||
resp, err := parseClaudeOutput(stdout, nil, nil, 2*time.Second, discardLog)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Content != "Hello! I'm Claude." {
|
||||
t.Errorf("content = %q, want %q", resp.Content, "Hello! I'm Claude.")
|
||||
}
|
||||
if resp.Usage.InputTokens != 10 {
|
||||
t.Errorf("input tokens = %d, want 10", resp.Usage.InputTokens)
|
||||
}
|
||||
if resp.Usage.OutputTokens != 50 {
|
||||
t.Errorf("output tokens = %d, want 50", resp.Usage.OutputTokens)
|
||||
}
|
||||
if resp.Usage.TotalTokens != 60 {
|
||||
t.Errorf("total tokens = %d, want 60", resp.Usage.TotalTokens)
|
||||
}
|
||||
if resp.FinishReason != "stop" {
|
||||
t.Errorf("finish reason = %q, want %q", resp.FinishReason, "stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeOutput_ErrorResponse(t *testing.T) {
|
||||
output := claudeJSONOutput{
|
||||
IsError: true,
|
||||
Result: "Invalid API key",
|
||||
}
|
||||
stdout, _ := json.Marshal(output)
|
||||
|
||||
_, err := parseClaudeOutput(stdout, nil, nil, time.Second, discardLog)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for IsError=true")
|
||||
}
|
||||
if !contains(err.Error(), "Invalid API key") {
|
||||
t.Errorf("error = %q, should contain 'Invalid API key'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeOutput_ProcessFailedNoStdout(t *testing.T) {
|
||||
_, err := parseClaudeOutput(nil, []byte("unknown option\n"), errors.New("exit 1"), time.Second, discardLog)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when process fails with no stdout")
|
||||
}
|
||||
if !contains(err.Error(), "unknown option") {
|
||||
t.Errorf("error = %q, should contain stderr message", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeOutput_ProcessFailedNoStderr(t *testing.T) {
|
||||
_, err := parseClaudeOutput(nil, nil, errors.New("exit 1"), time.Second, discardLog)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !contains(err.Error(), "exit 1") {
|
||||
t.Errorf("error = %q, should contain exec error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeOutput_FallbackPlainText(t *testing.T) {
|
||||
// Non-JSON stdout should be treated as plain text
|
||||
resp, err := parseClaudeOutput([]byte("just plain text\n"), nil, nil, time.Second, discardLog)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Content != "just plain text" {
|
||||
t.Errorf("content = %q, want %q", resp.Content, "just plain text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeOutput_ContentBlocks(t *testing.T) {
|
||||
output := claudeJSONOutput{
|
||||
Result: "", // empty result, content in blocks
|
||||
ContentBlock: []claudeContent{
|
||||
{Type: "text", Text: "First part."},
|
||||
{Type: "text", Text: "Second part."},
|
||||
},
|
||||
Usage: claudeUsage{InputTokens: 5, OutputTokens: 20},
|
||||
}
|
||||
stdout, _ := json.Marshal(output)
|
||||
|
||||
resp, err := parseClaudeOutput(stdout, nil, nil, time.Second, discardLog)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Content != "First part.\nSecond part." {
|
||||
t.Errorf("content = %q, want joined blocks", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeOutput_ExecErrWithStdout(t *testing.T) {
|
||||
// Process failed but produced valid JSON output — should parse and set finish_reason=error
|
||||
output := claudeJSONOutput{
|
||||
Result: "partial answer",
|
||||
Usage: claudeUsage{InputTokens: 3, OutputTokens: 10},
|
||||
}
|
||||
stdout, _ := json.Marshal(output)
|
||||
|
||||
resp, err := parseClaudeOutput(stdout, nil, errors.New("timeout"), time.Second, discardLog)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.FinishReason != "error" {
|
||||
t.Errorf("finish reason = %q, want %q", resp.FinishReason, "error")
|
||||
}
|
||||
if resp.Content != "partial answer" {
|
||||
t.Errorf("content = %q", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// ── filterEnv ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestFilterEnv_RemovesSingleKey(t *testing.T) {
|
||||
env := []string{
|
||||
"HOME=/home/user",
|
||||
"ANTHROPIC_API_KEY=sk-secret",
|
||||
"PATH=/usr/bin",
|
||||
}
|
||||
|
||||
got := filterEnv(env, "ANTHROPIC_API_KEY")
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d: %v", len(got), got)
|
||||
}
|
||||
for _, e := range got {
|
||||
if contains(e, "ANTHROPIC_API_KEY") {
|
||||
t.Errorf("ANTHROPIC_API_KEY should have been removed: %v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterEnv_RemovesMultipleKeys(t *testing.T) {
|
||||
env := []string{
|
||||
"HOME=/home/user",
|
||||
"ANTHROPIC_API_KEY=sk-secret",
|
||||
"OPENAI_API_KEY=sk-openai",
|
||||
"PATH=/usr/bin",
|
||||
}
|
||||
|
||||
got := filterEnv(env, "ANTHROPIC_API_KEY", "OPENAI_API_KEY")
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d: %v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterEnv_NoMatchKeepsAll(t *testing.T) {
|
||||
env := []string{"HOME=/home/user", "PATH=/usr/bin"}
|
||||
|
||||
got := filterEnv(env, "NONEXISTENT")
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterEnv_PrefixSafety(t *testing.T) {
|
||||
// ANTHROPIC_API_KEY_V2 should NOT be removed when filtering ANTHROPIC_API_KEY
|
||||
env := []string{
|
||||
"ANTHROPIC_API_KEY=secret",
|
||||
"ANTHROPIC_API_KEY_V2=other",
|
||||
}
|
||||
|
||||
got := filterEnv(env, "ANTHROPIC_API_KEY")
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1, got %d: %v", len(got), got)
|
||||
}
|
||||
if got[0] != "ANTHROPIC_API_KEY_V2=other" {
|
||||
t.Errorf("wrong entry kept: %q", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
|
||||
(len(s) > 0 && stringContains(s, substr)))
|
||||
}
|
||||
|
||||
func stringContains(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertContains(t *testing.T, args []string, flag, value string) {
|
||||
t.Helper()
|
||||
for i, a := range args {
|
||||
if a == flag && i+1 < len(args) && args[i+1] == value {
|
||||
return
|
||||
}
|
||||
// For --tools "" where value is empty string
|
||||
if a == flag && value == "" && i+1 < len(args) && args[i+1] == "" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("args %v missing %s %q", args, flag, value)
|
||||
}
|
||||
Reference in New Issue
Block a user