Merge PR #4573: preserve tool_result ordering

This commit is contained in:
Luis Pater
2026-07-26 14:43:28 +08:00
2 changed files with 62 additions and 3 deletions

View File

@@ -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) {

View File

@@ -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)
}
}