Merge fix/claude-cli-system-prompt-stdin: pass system prompt via stdin
This commit is contained in:
commit
f0670248fb
3 changed files with 81 additions and 12 deletions
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue