d1fd78324b
cmd/devicemesh-mcp/main_test.go (10 tests): - TestInitialize: JSON-RPC initialize frame → serverInfo + capabilities. - TestToolsList: tools/list → 16 user-mode entries, cada uno con name + inputSchema valido. - TestToolsCallExec: tools/call name=exec → mock device-agent (httptest) recibe capability=shell.exec, MCP response content contiene "hi". - TestToolsCallInvalidTool: name desconocido → isError o error envelope. - TestNotificationsInitializedNoResponse: notification (sin id) → cero responses. - TestUserModeFiltersPkgInstall: --mode user oculta pkg.install, --mode sudo la expone. - TestToolsAllowedNarrows: --tools-allowed exec,fs.read → solo 2. - TestSplitCSV, TestParseMode, TestIsCleanShutdown: helpers. cmd/devicemesh-mcp/integration_test.go: - TestIntegrationBinarySubprocess: build el binario en tmp + spawn como child via exec.Command + pipe real + secuencia initialize -> notifications/initialized -> tools/list -> tools/call. Valida el path identico al que usara claude. devagents/mcp_bridge_test.go (9 tests): - Disabled paths (nil DM, ExposeViaMCP=false, provider!=claude-code). - Applied path: /tmp/<agent>-mcp-config.json JSON valido, mode 0600, mcpServers.devicemesh con command apuntando al binario fake. - AllowedTools formato mcp__<server>__<tool>. - DisableTools=true overrideado a false. - URLEnv override gana sobre YAML. - Binary missing → ok=false sin panico. - BuildClaudeAllowedToolNames default server name. - ResolveBridgedToolNames respeta mode + ToolsAllowed. - ShouldExposeViaMCP cubre nil/disabled/default/explicit-true/false. shell/llm/claudecode_test.go: - TestBuildClaudeArgs_DisableTools actualizado: solo emite --tools "" cuando AllowedTools ESTA vacio. La regla nueva (issue 0145) da precedencia a AllowedTools. - Anadido TestBuildClaudeArgs_DisableToolsButAllowedToolsWins. - Anadido TestBuildClaudeArgs_MCPConfigPath. bridge.go fix: cambio NewTool + WithRawInputSchema a NewToolWithRawSchema porque NewTool inicializa ToolInputSchema.Type="object" por default, lo cual entra en conflicto con RawInputSchema en MarshalJSON del SDK. Suite completa pasa con -tags goolm -count=1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
804 lines
24 KiB
Go
804 lines
24 KiB
Go
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)
|
|
}
|