fix(claude): preserve non-result block positions during tool result alignment

- Maintain non-tool-result content blocks at their original indices in `AlignClaudeToolResults` instead of moving them after results.
- Reorder tool results in-place into their original slot indices based on preceding tool use IDs.

Closes: #5484
This commit is contained in:
Luis Pater
2026-09-07 01:09:18 +08:00
parent 5dc428f392
commit 8564142fb0
4 changed files with 63 additions and 30 deletions

View File

@@ -2253,19 +2253,25 @@ func TestConvertClaudeRequestToAntigravity_AlignsPermutedParallelToolResultsWith
output := ConvertClaudeRequestToAntigravity("gemini-3.7-flash-high", inputJSON, false)
parts := gjson.GetBytes(output, "request.contents.1.parts").Array()
if len(parts) != 8 {
t.Fatalf("parts = %d, want six responses followed by two text parts; output=%s", len(parts), output)
t.Fatalf("parts = %d, want eight parts; output=%s", len(parts), output)
}
for index := 0; index < 6; index++ {
if got := parts[0].Get("text").String(); got != "Tool results follow." {
t.Fatalf("leading text = %q; output=%s", got, output)
}
for index := 0; index < 3; index++ {
wantID := fmt.Sprintf("call_162060%d", index+3)
if gotID := parts[index].Get("functionResponse.id").String(); gotID != wantID {
t.Fatalf("functionResponse[%d].id = %q, want %q; output=%s", index, gotID, wantID, output)
if gotID := parts[index+1].Get("functionResponse.id").String(); gotID != wantID {
t.Fatalf("functionResponse[%d].id = %q, want %q; output=%s", index+1, gotID, wantID, output)
}
}
if got := parts[6].Get("text").String(); got != "Tool results follow." {
t.Fatalf("first trailing text = %q; output=%s", got, output)
if got := parts[4].Get("text").String(); got != "Continue after reading." {
t.Fatalf("middle text = %q; output=%s", got, output)
}
if got := parts[7].Get("text").String(); got != "Continue after reading." {
t.Fatalf("second trailing text = %q; output=%s", got, output)
for index := 3; index < 6; index++ {
wantID := fmt.Sprintf("call_162060%d", index+3)
if gotID := parts[index+2].Get("functionResponse.id").String(); gotID != wantID {
t.Fatalf("functionResponse[%d].id = %q, want %q; output=%s", index+2, gotID, wantID, output)
}
}
if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(output); errPairing != nil {
t.Fatalf("translated parallel tool history is invalid: %v; output=%s", errPairing, output)

View File

@@ -76,9 +76,9 @@ func (a *ClaudeMessageAccumulator) Messages() [][]byte {
return a.messages
}
// AlignClaudeToolResults orders tool_result blocks by the preceding tool_use IDs.
// Other content blocks retain their relative order after the tool results. If a
// complete one-to-one match is unavailable, the original content is returned.
// AlignClaudeToolResults orders tool_result blocks by the preceding tool_use IDs,
// preserving non-result content blocks at their existing indexes. If a complete
// one-to-one match is unavailable, the original content is returned.
func AlignClaudeToolResults(content gjson.Result, toolUseIDs []string) gjson.Result {
if !content.IsArray() || len(toolUseIDs) == 0 {
return content
@@ -86,19 +86,18 @@ func AlignClaudeToolResults(content gjson.Result, toolUseIDs []string) gjson.Res
parts := content.Array()
toolResults := make([]gjson.Result, 0, len(toolUseIDs))
otherParts := make([]gjson.Result, 0, len(parts))
for _, part := range parts {
toolResultIndices := make([]int, 0, len(toolUseIDs))
for i, part := range parts {
if part.Get("type").String() == "tool_result" {
toolResults = append(toolResults, part)
continue
toolResultIndices = append(toolResultIndices, i)
}
otherParts = append(otherParts, part)
}
if len(toolResults) != len(toolUseIDs) {
return content
}
ordered := make([][]byte, 0, len(parts))
reorderedResults := make([]gjson.Result, 0, len(toolUseIDs))
used := make([]bool, len(toolResults))
for _, toolUseID := range toolUseIDs {
matched := -1
@@ -112,10 +111,15 @@ func AlignClaudeToolResults(content gjson.Result, toolUseIDs []string) gjson.Res
return content
}
used[matched] = true
ordered = append(ordered, []byte(toolResults[matched].Raw))
reorderedResults = append(reorderedResults, toolResults[matched])
}
for _, part := range otherParts {
ordered = append(ordered, []byte(part.Raw))
ordered := make([][]byte, len(parts))
for i, part := range parts {
ordered[i] = []byte(part.Raw)
}
for i, slotIndex := range toolResultIndices {
ordered[slotIndex] = []byte(reorderedResults[i].Raw)
}
return gjson.ParseBytes(JoinRawArray(ordered))
}

View File

@@ -110,7 +110,7 @@ func TestClaudeMessageAccumulatorPreservesBlockCacheControl(t *testing.T) {
}
func TestAlignClaudeToolResults(t *testing.T) {
t.Run("reorders permuted results and keeps other parts after", func(t *testing.T) {
t.Run("reorders permuted results while preserving non-result slots", func(t *testing.T) {
input := gjson.Parse(`[
{"type":"tool_result","tool_use_id":"call_2","content":"two"},
{"type":"text","text":"extra user text"},
@@ -124,11 +124,31 @@ func TestAlignClaudeToolResults(t *testing.T) {
if parts[0].Get("tool_use_id").String() != "call_1" {
t.Fatalf("parts[0].tool_use_id = %q, want call_1", parts[0].Get("tool_use_id").String())
}
if parts[1].Get("tool_use_id").String() != "call_2" {
t.Fatalf("parts[1].tool_use_id = %q, want call_2", parts[1].Get("tool_use_id").String())
if parts[1].Get("type").String() != "text" || parts[1].Get("text").String() != "extra user text" {
t.Fatalf("parts[1] = %s, want extra user text", parts[1].Raw)
}
if parts[2].Get("type").String() != "text" || parts[2].Get("text").String() != "extra user text" {
t.Fatalf("parts[2] = %s, want extra user text", parts[2].Raw)
if parts[2].Get("tool_use_id").String() != "call_2" {
t.Fatalf("parts[2].tool_use_id = %q, want call_2", parts[2].Get("tool_use_id").String())
}
leadingTextInput := gjson.Parse(`[
{"type":"text","text":"leading text"},
{"type":"tool_result","tool_use_id":"call_2","content":"two"},
{"type":"tool_result","tool_use_id":"call_1","content":"one"}
]`)
leadingAligned := AlignClaudeToolResults(leadingTextInput, []string{"call_1", "call_2"})
leadingParts := leadingAligned.Array()
if len(leadingParts) != 3 {
t.Fatalf("len(leadingParts) = %d, want 3", len(leadingParts))
}
if leadingParts[0].Get("type").String() != "text" || leadingParts[0].Get("text").String() != "leading text" {
t.Fatalf("leadingParts[0] = %s, want leading text", leadingParts[0].Raw)
}
if leadingParts[1].Get("tool_use_id").String() != "call_1" {
t.Fatalf("leadingParts[1].tool_use_id = %q, want call_1", leadingParts[1].Get("tool_use_id").String())
}
if leadingParts[2].Get("tool_use_id").String() != "call_2" {
t.Fatalf("leadingParts[2].tool_use_id = %q, want call_2", leadingParts[2].Get("tool_use_id").String())
}
})

View File

@@ -389,18 +389,21 @@ func TestConvertClaudeRequestToGemini_AlignsPermutedParallelToolResultsWithMixed
if gotID := callParts[index].Get("functionCall.id").String(); gotID != wantID {
t.Fatalf("functionCall[%d].id = %q, want %q; output=%s", index, gotID, wantID, output)
}
if gotID := responseParts[index].Get("functionResponse.id").String(); gotID != wantID {
}
if got := responseParts[0].Get("text").String(); got != "Results arrived." {
t.Fatalf("leading text = %q; output=%s", got, output)
}
for index, wantID := range []string{"call_1", "call_2", "call_3"} {
responsePart := responseParts[index+1]
if gotID := responsePart.Get("functionResponse.id").String(); gotID != wantID {
t.Fatalf("functionResponse[%d].id = %q, want %q; output=%s", index, gotID, wantID, output)
}
if gotName := responseParts[index].Get("functionResponse.name").String(); gotName != "Read" {
if gotName := responsePart.Get("functionResponse.name").String(); gotName != "Read" {
t.Fatalf("functionResponse[%d].name = %q, want Read; output=%s", index, gotName, output)
}
}
if got := responseParts[3].Get("text").String(); got != "Results arrived." {
t.Fatalf("first trailing text = %q; output=%s", got, output)
}
if got := responseParts[4].Get("text").String(); got != "Continue." {
t.Fatalf("second trailing text = %q; output=%s", got, output)
t.Fatalf("trailing text = %q; output=%s", got, output)
}
if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(output); errPairing != nil {
t.Fatalf("translated parallel tool history is invalid: %v; output=%s", errPairing, output)