package llm import ( "encoding/json" "errors" "io" "log/slog" "os" "path/filepath" "strings" "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) { // DisableTools alone (no AllowedTools) → --tools "". cfg := config.ClaudeCodeCfg{ DisableTools: true, } args := buildClaudeArgs(cfg, coretypes.CompletionRequest{}) assertContains(t, args, "--tools", "") for _, a := range args { if a == "--allowedTools" { t.Error("--allowedTools should not appear when DisableTools=true and AllowedTools is empty") } } } func TestBuildClaudeArgs_DisableToolsButAllowedToolsWins(t *testing.T) { // Issue 0145: DisableTools=true plus a non-empty AllowedTools is a // contradiction the launcher's ApplyMCPBridge guards against. The // builder itself now also gives AllowedTools priority (precedence // matches the launcher) so direct callers cannot accidentally produce // the broken `--tools "" --allowedTools ...` combo. cfg := config.ClaudeCodeCfg{ DisableTools: true, AllowedTools: []string{"Bash"}, } args := buildClaudeArgs(cfg, coretypes.CompletionRequest{}) for _, a := range args { if a == "--tools" { t.Error("--tools should not appear once AllowedTools is non-empty (AllowedTools wins)") } } assertContains(t, args, "--allowedTools", "Bash") } func TestBuildClaudeArgs_MCPConfigPath(t *testing.T) { // Issue 0145: --mcp-config is emitted whenever MCPConfigPath is set so // claude knows how to spawn the per-agent devicemesh MCP server. cfg := config.ClaudeCodeCfg{ MCPConfigPath: "/tmp/agent-x-mcp-config.json", AllowedTools: []string{"mcp__devicemesh__exec"}, } args := buildClaudeArgs(cfg, coretypes.CompletionRequest{}) assertContains(t, args, "--mcp-config", "/tmp/agent-x-mcp-config.json") assertContains(t, args, "--allowedTools", "mcp__devicemesh__exec") } 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]) } } // ── resolveWorkDir ────────────────────────────────────────────────────── func TestResolveWorkDir_EmptyCreatesTempDir(t *testing.T) { dir := resolveWorkDir("", discardLog) if dir == "" { t.Fatal("expected a temp directory, got empty string") } defer os.RemoveAll(dir) if !strings.Contains(dir, "claude-agent-") { t.Errorf("temp dir %q should contain 'claude-agent-' prefix", dir) } info, err := os.Stat(dir) if err != nil { t.Fatalf("temp dir should exist: %v", err) } if !info.IsDir() { t.Error("temp dir should be a directory") } } func TestResolveWorkDir_ConfiguredValueUsed(t *testing.T) { want := filepath.Join(t.TempDir(), "custom-workdir") got := resolveWorkDir(want, discardLog) if got != want { t.Errorf("got %q, want %q", got, want) } info, err := os.Stat(got) if err != nil { t.Fatalf("configured dir should be created: %v", err) } if !info.IsDir() { t.Error("configured dir should be a directory") } } func TestResolveWorkDir_ConfiguredAlreadyExists(t *testing.T) { want := t.TempDir() // already exists got := resolveWorkDir(want, discardLog) if got != want { t.Errorf("got %q, want %q", got, want) } } // ── parseStreamLine ───────────────────────────────────────────────── func TestParseStreamLine_SystemInit(t *testing.T) { line := []byte(`{"type":"system","subtype":"init","session_id":"abc","tools":["Bash","Read"],"model":"sonnet"}`) evt, result, err := parseStreamLine(line) if err != nil { t.Fatalf("unexpected error: %v", err) } if evt.Kind != coretypes.StreamInit { t.Errorf("kind = %q, want %q", evt.Kind, coretypes.StreamInit) } if result != nil { t.Error("expected nil result for system event") } } func TestParseStreamLine_AssistantToolUse(t *testing.T) { line := []byte(`{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","id":"call_1","input":{"command":"ls -la /tmp"}}]}}`) evt, result, err := parseStreamLine(line) if err != nil { t.Fatalf("unexpected error: %v", err) } if evt.Kind != coretypes.StreamToolUse { t.Errorf("kind = %q, want %q", evt.Kind, coretypes.StreamToolUse) } if evt.ToolName != "Bash" { t.Errorf("tool_name = %q, want %q", evt.ToolName, "Bash") } if evt.ToolInput != "ls -la /tmp" { t.Errorf("tool_input = %q, want %q", evt.ToolInput, "ls -la /tmp") } if result != nil { t.Error("expected nil result for assistant event") } } func TestParseStreamLine_AssistantToolUseFilePath(t *testing.T) { line := []byte(`{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","id":"call_2","input":{"file_path":"/home/user/main.go"}}]}}`) evt, _, err := parseStreamLine(line) if err != nil { t.Fatalf("unexpected error: %v", err) } if evt.Kind != coretypes.StreamToolUse { t.Errorf("kind = %q, want %q", evt.Kind, coretypes.StreamToolUse) } if evt.ToolName != "Read" { t.Errorf("tool_name = %q, want %q", evt.ToolName, "Read") } if evt.ToolInput != "/home/user/main.go" { t.Errorf("tool_input = %q, want %q", evt.ToolInput, "/home/user/main.go") } } func TestParseStreamLine_AssistantText(t *testing.T) { line := []byte(`{"type":"assistant","message":{"content":[{"type":"text","text":"Hello, world!"}]}}`) evt, result, err := parseStreamLine(line) if err != nil { t.Fatalf("unexpected error: %v", err) } if evt.Kind != coretypes.StreamText { t.Errorf("kind = %q, want %q", evt.Kind, coretypes.StreamText) } if evt.Content != "Hello, world!" { t.Errorf("content = %q, want %q", evt.Content, "Hello, world!") } if result != nil { t.Error("expected nil result for text event") } } func TestParseStreamLine_AssistantNoContent(t *testing.T) { line := []byte(`{"type":"assistant","message":{"content":[]}}`) evt, _, err := parseStreamLine(line) if err != nil { t.Fatalf("unexpected error: %v", err) } if evt.Kind != coretypes.StreamText { t.Errorf("kind = %q, want %q", evt.Kind, coretypes.StreamText) } } func TestParseStreamLine_ResultSuccess(t *testing.T) { line := []byte(`{"type":"result","subtype":"success","is_error":false,"result":"The answer is 42","num_turns":3,"total_cost_usd":0.05,"usage":{"input_tokens":100,"output_tokens":50}}`) evt, result, err := parseStreamLine(line) if err != nil { t.Fatalf("unexpected error: %v", err) } if evt.Kind != coretypes.StreamResult { t.Errorf("kind = %q, want %q", evt.Kind, coretypes.StreamResult) } if evt.Content != "The answer is 42" { t.Errorf("content = %q, want %q", evt.Content, "The answer is 42") } if evt.IsError { t.Error("expected IsError=false") } if result == nil { t.Fatal("expected non-nil result for result event") } if result.Result != "The answer is 42" { t.Errorf("result.Result = %q, want %q", result.Result, "The answer is 42") } if result.Usage.InputTokens != 100 { t.Errorf("input_tokens = %d, want 100", result.Usage.InputTokens) } if result.Usage.OutputTokens != 50 { t.Errorf("output_tokens = %d, want 50", result.Usage.OutputTokens) } if result.TotalCost != 0.05 { t.Errorf("total_cost = %f, want 0.05", result.TotalCost) } } func TestParseStreamLine_ResultError(t *testing.T) { line := []byte(`{"type":"result","subtype":"error","is_error":true,"result":"API key expired","num_turns":0}`) evt, result, err := parseStreamLine(line) if err != nil { t.Fatalf("unexpected error: %v", err) } if evt.Kind != coretypes.StreamResult { t.Errorf("kind = %q, want %q", evt.Kind, coretypes.StreamResult) } if !evt.IsError { t.Error("expected IsError=true") } if result == nil { t.Fatal("expected non-nil result") } if !result.IsError { t.Error("expected result.IsError=true") } } func TestParseStreamLine_UnknownType(t *testing.T) { line := []byte(`{"type":"future_event","data":"some_value"}`) evt, _, err := parseStreamLine(line) if err != nil { t.Fatalf("unexpected error: %v", err) } if evt.Kind != coretypes.StreamText { t.Errorf("kind = %q, want %q (fallback for unknown types)", evt.Kind, coretypes.StreamText) } } func TestParseStreamLine_InvalidJSON(t *testing.T) { line := []byte(`not valid json`) _, _, err := parseStreamLine(line) if err == nil { t.Error("expected error for invalid JSON") } } // ── truncateToolInput ─────────────────────────────────────────────── func TestTruncateToolInput_Nil(t *testing.T) { got := truncateToolInput(nil) if got != "" { t.Errorf("got %q, want empty", got) } } func TestTruncateToolInput_String(t *testing.T) { got := truncateToolInput("hello world") if got != "hello world" { t.Errorf("got %q, want %q", got, "hello world") } } func TestTruncateToolInput_LongString(t *testing.T) { long := strings.Repeat("x", 200) got := truncateToolInput(long) if len(got) != 100 { t.Errorf("len = %d, want 100", len(got)) } if !strings.HasSuffix(got, "...") { t.Error("should end with ...") } } func TestTruncateToolInput_MapWithCommand(t *testing.T) { input := map[string]any{"command": "ls -la /tmp"} got := truncateToolInput(input) if got != "ls -la /tmp" { t.Errorf("got %q, want %q", got, "ls -la /tmp") } } func TestTruncateToolInput_MapWithFilePath(t *testing.T) { input := map[string]any{"file_path": "/home/user/main.go"} got := truncateToolInput(input) if got != "/home/user/main.go" { t.Errorf("got %q, want %q", got, "/home/user/main.go") } } // ── buildClaudeArgs streaming ─────────────────────────────────────── func TestBuildClaudeArgs_StreamingEnabled(t *testing.T) { cfg := config.ClaudeCodeCfg{ Streaming: true, } streamFn := func(evt coretypes.StreamEvent) {} req := coretypes.CompletionRequest{ StreamFunc: streamFn, } args := buildClaudeArgs(cfg, req) assertContains(t, args, "--output-format", "stream-json") // Must also include --verbose for stream-json found := false for _, a := range args { if a == "--verbose" { found = true } } if !found { t.Error("--verbose should be present when streaming") } } func TestBuildClaudeArgs_StreamingDisabled(t *testing.T) { cfg := config.ClaudeCodeCfg{ Streaming: false, } req := coretypes.CompletionRequest{} args := buildClaudeArgs(cfg, req) assertContains(t, args, "--output-format", "json") for _, a := range args { if a == "--verbose" { t.Error("--verbose should NOT be present when not streaming") } } } func TestBuildClaudeArgs_StreamingEnabledNoStreamFunc(t *testing.T) { // Streaming config is true but StreamFunc is nil — should fall back to json cfg := config.ClaudeCodeCfg{ Streaming: true, } req := coretypes.CompletionRequest{ StreamFunc: nil, } args := buildClaudeArgs(cfg, req) assertContains(t, args, "--output-format", "json") } // ── executeStreaming with mock stdout ──────────────────────────────── func TestExecuteStreaming_MockStdout(t *testing.T) { // Simulate stream-json output by writing lines to an io.Pipe lines := []string{ `{"type":"system","subtype":"init","session_id":"test-123"}`, `{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","id":"call_1","input":{"command":"echo hello"}}]}}`, `{"type":"assistant","message":{"content":[{"type":"text","text":"Done executing."}]}}`, `{"type":"result","subtype":"success","is_error":false,"result":"The final answer","num_turns":2,"total_cost_usd":0.01,"usage":{"input_tokens":50,"output_tokens":25}}`, } var events []coretypes.StreamEvent streamFn := func(evt coretypes.StreamEvent) { events = append(events, evt) } // Parse lines manually using parseStreamLine to verify the full flow var lastResult *claudeJSONOutput for _, line := range lines { evt, parsed, err := parseStreamLine([]byte(line)) if err != nil { t.Fatalf("parse error on line: %v", err) } streamFn(evt) if parsed != nil && parsed.Type == "result" { lastResult = parsed } } // Verify events if len(events) != 4 { t.Fatalf("expected 4 events, got %d", len(events)) } if events[0].Kind != coretypes.StreamInit { t.Errorf("event[0].Kind = %q, want %q", events[0].Kind, coretypes.StreamInit) } if events[1].Kind != coretypes.StreamToolUse { t.Errorf("event[1].Kind = %q, want %q", events[1].Kind, coretypes.StreamToolUse) } if events[1].ToolName != "Bash" { t.Errorf("event[1].ToolName = %q, want %q", events[1].ToolName, "Bash") } if events[1].ToolInput != "echo hello" { t.Errorf("event[1].ToolInput = %q, want %q", events[1].ToolInput, "echo hello") } if events[2].Kind != coretypes.StreamText { t.Errorf("event[2].Kind = %q, want %q", events[2].Kind, coretypes.StreamText) } if events[3].Kind != coretypes.StreamResult { t.Errorf("event[3].Kind = %q, want %q", events[3].Kind, coretypes.StreamResult) } if events[3].Content != "The final answer" { t.Errorf("event[3].Content = %q, want %q", events[3].Content, "The final answer") } // Verify final result was captured if lastResult == nil { t.Fatal("expected lastResult to be set") } if lastResult.Result != "The final answer" { t.Errorf("lastResult.Result = %q", lastResult.Result) } // Verify buildResponseFromResult resp, err := buildResponseFromResult(lastResult, nil, time.Second, discardLog) if err != nil { t.Fatalf("unexpected error: %v", err) } if resp.Content != "The final answer" { t.Errorf("resp.Content = %q", resp.Content) } if resp.Usage.InputTokens != 50 { t.Errorf("input_tokens = %d, want 50", resp.Usage.InputTokens) } if resp.FinishReason != "stop" { t.Errorf("finish_reason = %q, want %q", resp.FinishReason, "stop") } } func TestBuildResponseFromResult_Error(t *testing.T) { result := &claudeJSONOutput{ Type: "result", IsError: true, Result: "API rate limited", } _, err := buildResponseFromResult(result, nil, time.Second, discardLog) if err == nil { t.Fatal("expected error for IsError=true") } if !contains(err.Error(), "API rate limited") { t.Errorf("error = %q, should contain 'API rate limited'", err.Error()) } } func TestBuildResponseFromResult_ExecError(t *testing.T) { result := &claudeJSONOutput{ Type: "result", Result: "partial output", Usage: claudeUsage{InputTokens: 10, OutputTokens: 5}, } resp, err := buildResponseFromResult(result, 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") } } // ── 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) }