diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go index 40b581490..9ce7004c9 100644 --- a/pkg/providers/claude_cli_provider.go +++ b/pkg/providers/claude_cli_provider.go @@ -27,13 +27,9 @@ func NewClaudeCliProvider(workspace string) *ClaudeCliProvider { func (p *ClaudeCliProvider) Chat( ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any, ) (*LLMResponse, error) { - systemPrompt := p.buildSystemPrompt(messages, tools) - prompt := p.messagesToPrompt(messages) + prompt := p.buildStdinPrompt(messages, tools) args := []string{"-p", "--output-format", "json", "--dangerously-skip-permissions", "--no-chrome"} - if systemPrompt != "" { - args = append(args, "--system-prompt", systemPrompt) - } if model != "" && model != "claude-code" { args = append(args, "--model", model) } @@ -72,14 +68,26 @@ func (p *ClaudeCliProvider) GetDefaultModel() string { return "claude-code" } -// messagesToPrompt converts messages to a CLI-compatible prompt string. +// buildStdinPrompt combines the system context and conversation into a single stdin payload. +// Passing system instructions via stdin avoids exposing them in the process argument list and +// sidesteps operating-system ARG_MAX limits when many tools are registered. +func (p *ClaudeCliProvider) buildStdinPrompt(messages []Message, tools []ToolDefinition) string { + system := p.buildSystemPrompt(messages, tools) + conversation := p.messagesToPrompt(messages) + if system == "" { + return conversation + } + return system + "\n\n---\n\n" + conversation +} + +// messagesToPrompt converts non-system messages to a CLI-compatible prompt string. func (p *ClaudeCliProvider) messagesToPrompt(messages []Message) string { var parts []string for _, msg := range messages { switch msg.Role { case "system": - // handled via --system-prompt flag + // included in system context block; see buildStdinPrompt case "user": parts = append(parts, "User: "+msg.Content) case "assistant": diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/claude_cli_provider_test.go index d4d648f5a..55e21da11 100644 --- a/pkg/providers/claude_cli_provider_test.go +++ b/pkg/providers/claude_cli_provider_test.go @@ -300,9 +300,27 @@ func TestChat_ContextCancellation(t *testing.T) { } } -func TestChat_PassesSystemPromptFlag(t *testing.T) { - argsFile := filepath.Join(t.TempDir(), "args.txt") - script := createArgCaptureCLI(t, argsFile) +func TestChat_SystemPromptInStdinNotArgs(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("mock CLI scripts not supported on Windows") + } + + dir := t.TempDir() + argsFile := filepath.Join(dir, "args.txt") + stdinFile := filepath.Join(dir, "stdin.txt") + + // Script captures both args and stdin content. + script := filepath.Join(dir, "claude") + scriptContent := fmt.Sprintf(`#!/bin/sh +echo "$@" > '%s' +cat - > '%s' +cat <<'EOFMOCK' +{"type":"result","result":"ok","session_id":"test"} +EOFMOCK +`, argsFile, stdinFile) + if err := os.WriteFile(script, []byte(scriptContent), 0o755); err != nil { + t.Fatal(err) + } p := NewClaudeCliProvider(t.TempDir()) p.command = script @@ -320,8 +338,20 @@ func TestChat_PassesSystemPromptFlag(t *testing.T) { t.Fatalf("failed to read args file: %v", err) } args := string(argsBytes) - if !strings.Contains(args, "--system-prompt") { - t.Errorf("CLI args missing --system-prompt, got: %s", args) + if strings.Contains(args, "--system-prompt") { + t.Errorf("CLI args must not contain --system-prompt, got: %s", args) + } + + stdinBytes, err := os.ReadFile(stdinFile) + if err != nil { + t.Fatalf("failed to read stdin file: %v", err) + } + stdin := string(stdinBytes) + if !strings.Contains(stdin, "Be helpful.") { + t.Errorf("stdin must contain system prompt content, got: %s", stdin) + } + if !strings.Contains(stdin, "Hi") { + t.Errorf("stdin must contain user message, got: %s", stdin) } } @@ -657,6 +687,36 @@ func TestBuildSystemPrompt_ToolsOnlyNoSystem(t *testing.T) { } } +// --- buildStdinPrompt tests --- + +func TestBuildStdinPrompt_NoSystem(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + messages := []Message{{Role: "user", Content: "Hello"}} + got := p.buildStdinPrompt(messages, nil) + if got != "Hello" { + t.Errorf("buildStdinPrompt() without system = %q, want %q", got, "Hello") + } +} + +func TestBuildStdinPrompt_WithSystem(t *testing.T) { + p := NewClaudeCliProvider("/workspace") + messages := []Message{ + {Role: "system", Content: "Be concise."}, + {Role: "user", Content: "Hello"}, + } + got := p.buildStdinPrompt(messages, nil) + if !strings.Contains(got, "Be concise.") { + t.Error("buildStdinPrompt() must include system content") + } + if !strings.Contains(got, "Hello") { + t.Error("buildStdinPrompt() must include user content") + } + // System section must precede user section. + if strings.Index(got, "Be concise.") > strings.Index(got, "Hello") { + t.Error("buildStdinPrompt() system content must appear before user content") + } +} + // --- buildToolsPrompt tests --- func TestBuildToolsPrompt_SkipsNonFunction(t *testing.T) { diff --git a/pkg/providers/gemini_cli_provider_test.go b/pkg/providers/gemini_cli_provider_test.go index d513d8380..ddd22f2bc 100644 --- a/pkg/providers/gemini_cli_provider_test.go +++ b/pkg/providers/gemini_cli_provider_test.go @@ -261,6 +261,7 @@ func TestCreateProvider_GeminiCliWithModel(t *testing.T) { func TestCreateProvider_GeminiCliDefaultWorkspace(t *testing.T) { cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = "" // clear default so the "." fallback is exercised cfg.ModelList = []config.ModelConfig{ {ModelName: "gemini-cli", Model: "gemini-cli/gemini-cli"}, }