diff --git a/internal/runtime/executor/claude_executor_cloaking.go b/internal/runtime/executor/claude_executor_cloaking.go index 071f069ff..96124f58a 100644 --- a/internal/runtime/executor/claude_executor_cloaking.go +++ b/internal/runtime/executor/claude_executor_cloaking.go @@ -296,7 +296,7 @@ func buildTextBlock(text string, cacheControl map[string]string) string { return string(block) } -// prependToFirstUserMessage prepends text content to the first user message. +// prependToFirstUserMessage injects text content into the first user message. // This avoids putting non-Claude-Code system instructions in system[] which // triggers Anthropic's extra usage billing for OAuth-proxied requests. func prependToFirstUserMessage(payload []byte, text string) []byte { @@ -333,9 +333,19 @@ IMPORTANT: this context may or may not be relevant to your tasks. You should not if content.IsArray() { newBlock := fmt.Sprintf(`{"type":"text","text":%q}`, prefixBlock) var newArray string - if content.Raw == "[]" || content.Raw == "" { + switch { + case content.Raw == "[]" || content.Raw == "": newArray = "[" + newBlock + "]" - } else { + case leadsWithToolResult(content): + // Anthropic requires the user message that immediately follows an + // assistant tool_use turn to lead with its tool_result blocks. + // Append the reminder so those blocks stay at the head. + if trimmed := strings.TrimRight(content.Raw, " \t\r\n"); strings.HasSuffix(trimmed, "]") { + newArray = trimmed[:len(trimmed)-1] + "," + newBlock + "]" + } else { + newArray = "[" + newBlock + "," + content.Raw[1:] + } + default: newArray = "[" + newBlock + "," + content.Raw[1:] } payload, _ = sjson.SetRawBytes(payload, contentPath, []byte(newArray)) @@ -347,6 +357,14 @@ IMPORTANT: this context may or may not be relevant to your tasks. You should not return payload } +// leadsWithToolResult reports whether a message content array starts with a +// tool_result block. Such a message answers a preceding assistant tool_use turn, +// and Anthropic requires its tool_result blocks to remain first. +func leadsWithToolResult(content gjson.Result) bool { + first := content.Get("0") + return first.Exists() && first.Get("type").String() == "tool_result" +} + // applyCloaking applies cloaking transformations to the payload based on config and client. // Cloaking includes: system prompt injection, fake user ID, and sensitive word obfuscation. func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, payload []byte, model string, apiKey string) ([]byte, error) { diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index ccb9e7df4..b78d1fc36 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -2979,3 +2979,44 @@ func TestEnsureClaudeThinkingDisplay_SkipsWhenThinkingMissing(t *testing.T) { t.Fatalf("thinking should remain absent: %s", out) } } + +func TestPrependToFirstUserMessage_KeepsToolResultBlocksFirst(t *testing.T) { + // A conversation that opens on an assistant tool_use makes the first user + // message a tool_result carrier. Anthropic requires those blocks to stay at + // the head of the message, so the reminder must be appended, not prepended. + payload := []byte(`{"messages":[` + + `{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Read","input":{}}]},` + + `{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"}]}` + + `]}`) + + out := prependToFirstUserMessage(payload, "guidance") + + blocks := gjson.GetBytes(out, "messages.1.content") + if got := blocks.Get("0.type").String(); got != "tool_result" { + t.Fatalf("first block type = %q, want tool_result: %s", got, out) + } + if got := blocks.Get("0.tool_use_id").String(); got != "toolu_1" { + t.Fatalf("tool_use_id = %q, want toolu_1: %s", got, out) + } + last := blocks.Array()[len(blocks.Array())-1] + if last.Get("type").String() != "text" || !strings.Contains(last.Get("text").String(), "guidance") { + t.Fatalf("reminder should be appended last: %s", out) + } +} + +func TestPrependToFirstUserMessage_PrependsWhenNoLeadingToolResult(t *testing.T) { + payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`) + + out := prependToFirstUserMessage(payload, "guidance") + + blocks := gjson.GetBytes(out, "messages.0.content") + if got := blocks.Get("0.type").String(); got != "text" { + t.Fatalf("first block type = %q, want text: %s", got, out) + } + if !strings.Contains(blocks.Get("0.text").String(), "guidance") { + t.Fatalf("reminder should be prepended first: %s", out) + } + if got := blocks.Get("1.text").String(); got != "hello" { + t.Fatalf("original block should follow, got %q: %s", got, out) + } +}