fix(antigravity): reorder model parts to prevent tool_use↔tool_result pairing breakage#2409
Conversation
… pairing breakage When a Claude assistant message contains [text, tool_use, text], the Antigravity API internally splits the model message at functionCall boundaries, creating an extra assistant turn between tool_use and the following tool_result. Claude then rejects with: tool_use ids were found without tool_result blocks immediately after Fix: extend the existing 2-way part reordering (thinking-first) to a 3-way partition: thinking → regular → functionCall. This ensures functionCall parts are always last, so Antigravity's split cannot insert an extra assistant turn before the user's tool_result. Fixes router-for-me#989
There was a problem hiding this comment.
Code Review
This pull request ensures that message parts for the 'model' role are ordered with thinking blocks first, regular content second, and function calls last to prevent API message splitting that breaks tool usage. New tests verify this logic across several scenarios. Feedback was provided to optimize the reordering logic by skipping unnecessary work when parts are already in the correct order and pre-allocating the output slice.
| var thinkingParts []gjson.Result | ||
| var regularParts []gjson.Result | ||
| var functionCallParts []gjson.Result | ||
| for _, part := range parts { | ||
| if part.Get("thought").Bool() { | ||
| thinkingParts = append(thinkingParts, part) | ||
| } else if part.Get("functionCall").Exists() { | ||
| functionCallParts = append(functionCallParts, part) | ||
| } else { | ||
| regularParts = append(regularParts, part) | ||
| } | ||
| clientContentJSON, _ = sjson.SetBytes(clientContentJSON, "parts", newParts) | ||
| } | ||
| var newParts []interface{} | ||
| for _, p := range thinkingParts { | ||
| newParts = append(newParts, p.Value()) | ||
| } | ||
| for _, p := range regularParts { | ||
| newParts = append(newParts, p.Value()) | ||
| } | ||
| for _, p := range functionCallParts { | ||
| newParts = append(newParts, p.Value()) | ||
| } | ||
| clientContentJSON, _ = sjson.SetBytes(clientContentJSON, "parts", newParts) |
There was a problem hiding this comment.
While the current implementation is correct, it always re-partitions and re-serializes the parts array if it contains more than one element. This can be optimized by first checking if a reorder is necessary, avoiding unnecessary work for already-sorted parts. Additionally, pre-allocating the newParts slice with make improves performance by preventing reallocations during appends.
// Check if reordering is needed to avoid unnecessary work.
// The correct order is: thinking -> regular -> functionCall.
var needsReorder bool
var seenRegular, seenFunctionCall bool
for _, part := range parts {
isThinking := part.Get("thought").Bool()
isFunctionCall := part.Get("functionCall").Exists()
if isThinking {
if seenRegular || seenFunctionCall {
needsReorder = true
break
}
} else if isFunctionCall {
seenFunctionCall = true
} else { // regular part
if seenFunctionCall {
needsReorder = true
break
}
seenRegular = true
}
}
if needsReorder {
var thinkingParts, regularParts, functionCallParts []gjson.Result
for _, part := range parts {
if part.Get("thought").Bool() {
thinkingParts = append(thinkingParts, part)
} else if part.Get("functionCall").Exists() {
functionCallParts = append(functionCallParts, part)
} else {
regularParts = append(regularParts, part)
}
}
newParts := make([]interface{}, 0, len(parts))
for _, p := range thinkingParts {
newParts = append(newParts, p.Value())
}
for _, p := range regularParts {
newParts = append(newParts, p.Value())
}
for _, p := range functionCallParts {
newParts = append(newParts, p.Value())
}
clientContentJSON, _ = sjson.SetBytes(clientContentJSON, "parts", newParts)
}There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 25feceb783
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| var newParts []interface{} | ||
| for _, p := range thinkingParts { | ||
| newParts = append(newParts, p.Value()) |
There was a problem hiding this comment.
Preserve raw JSON when reordering model parts
The new len(parts) > 1 path now runs for any multi-part assistant message and rebuilds each part via p.Value() before sjson.SetBytes(...). gjson.Result.Value() converts JSON numbers to Go float64, so tool arguments containing 64-bit integers (IDs, timestamps, snowflakes, etc.) can be rounded during replay (for example in [text, tool_use] messages that previously skipped this rewrite). This changes tool-call payloads and can cause downstream tool mismatches; use raw JSON (p.Raw/SetRawBytes) or only rewrite when an actual reorder is required.
Useful? React with 👍 / 👎.
Summary
tool_useids were found withouttool_resultblocks immediately #989 —tool_useids were found withouttool_resultblocks immediately afterthinking → regular → functionCallRoot Cause
When a Claude assistant message contains
[text, tool_use, text], the Antigravity translator converts it to Gemini format[text_part, functionCall, text_part]. The Antigravity API internally splits model messages atfunctionCallboundaries, creating an extra assistant turn:Claude rejects because
tool_resultmust immediately follow thetool_usemessage.Why this structure appears
The response translator (
antigravity_claude_response.go) faithfully preserves part order from the backend. When the backend returns[text, functionCall, text], the response translator converts it to[text, tool_use, text]in Claude format. The client stores this as conversation history and replays it in the next request — triggering the bug.Fix
Reorder model parts into 3 groups before sending to Antigravity:
This ensures that even when Antigravity splits at
functionCallboundaries, no extra assistant turn appears betweentool_useand the followingtool_result.Tests Added
ReorderTextAfterFunctionCall[text, tool_use, text]→[text, text, functionCall]ReorderParallelFunctionCalls[text, fc1, text, fc2]→[text, text, fc1, fc2](preserves fc order)ReorderThinkingAndTextBeforeFunctionCall[text, thinking, tool_use, text]→[thinking, text, text, functionCall]Verification
tool_useids were found withouttool_resultblocks immediately #989 error) tested end-to-end: 400 → 200 streaming response[text, fc1, text, fc2]) tested end-to-end: 200 with correct model response