fix(gemini,antigravity): align parallel tool results with preceding tool calls

- Add `AlignClaudeToolResults` to order `tool_result` blocks to match the preceding `tool_use` IDs while preserving other content parts.
- Apply tool result alignment in Claude-to-Gemini and Claude-to-Antigravity request translators.
- Preserve mixed non-response parts when normalizing and reordering parallel function responses in Antigravity executor.

Closes: #5199
This commit is contained in:
Luis Pater
2026-08-25 14:57:46 +08:00
parent 80de901550
commit f2b1996b3f
10 changed files with 264 additions and 19 deletions

View File

@@ -388,7 +388,7 @@ func normalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byte {
}
var calls, responses []functionRef
var responseParts []json.RawMessage
var responseParts, otherParts []json.RawMessage
partCount := 0
hasOtherPart := false
parts.ForEach(func(_, part gjson.Result) bool {
@@ -401,6 +401,7 @@ func normalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byte {
responseParts = append(responseParts, json.RawMessage(part.Raw))
default:
hasOtherPart = true
otherParts = append(otherParts, json.RawMessage(part.Raw))
}
return true
})
@@ -418,7 +419,7 @@ func normalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byte {
}
return true
}
if hasOtherPart || len(calls) > 0 {
if len(calls) > 0 {
pending = nil
return true
}
@@ -426,7 +427,7 @@ func normalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byte {
var contentJSON []byte
contentChanged := false
if len(pending) == len(responses) {
ordered := make([]json.RawMessage, 0, len(responseParts))
ordered := make([]json.RawMessage, 0, partCount)
used := make([]bool, len(responses))
for _, call := range pending {
matched := -1
@@ -447,6 +448,7 @@ func normalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byte {
ordered = append(ordered, responseParts[matched])
}
if len(ordered) == len(responseParts) {
ordered = append(ordered, otherParts...)
encoded, errMarshal := json.Marshal(ordered)
if errMarshal == nil && !bytes.Equal(encoded, []byte(parts.Raw)) {
contentJSON = []byte(content.Raw)
@@ -458,7 +460,7 @@ func normalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byte {
}
}
pending = nil
if content.Get("role").String() != "model" {
if !hasOtherPart && content.Get("role").String() != "model" {
if contentJSON == nil {
contentJSON = []byte(content.Raw)
}

View File

@@ -800,12 +800,34 @@ func TestAntigravityExecutorCountTokensReconstructsCompactedClaudeToolCall(t *te
}
}
func TestNormalizeAntigravityGeminiFunctionResponseRolesLeavesMixedUserContent(t *testing.T) {
payload := []byte(`{"request":{"contents":[{"role":"user","parts":[{"functionResponse":{"name":"run","response":{"result":"ok"}}},{"text":"user follow-up"}]}]}}`)
func TestNormalizeAntigravityGeminiFunctionResponseRolesOrdersMixedParallelResponses(t *testing.T) {
payload := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"read","args":{"file":"one"}}},{"functionCall":{"id":"call-2","name":"read","args":{"file":"two"}}}]},{"role":"user","parts":[{"text":"results follow"},{"functionResponse":{"id":"call-2","name":"read","response":{"result":"two"}}},{"text":"continue"},{"functionResponse":{"id":"call-1","name":"read","response":{"result":"one"}}}]}]}}`)
if errValidate := internalsignature.ValidateGeminiFunctionCallPairing(payload); errValidate == nil {
t.Fatal("permuted input unexpectedly passed function call pairing validation")
}
output := normalizeAntigravityGeminiFunctionResponseRoles(payload)
if got := gjson.GetBytes(output, "request.contents.0.role").String(); got != "user" {
if got := gjson.GetBytes(output, "request.contents.1.role").String(); got != "user" {
t.Fatalf("mixed functionResponse/user content role = %q, want user; output=%s", got, output)
}
parts := gjson.GetBytes(output, "request.contents.1.parts").Array()
if len(parts) != 4 {
t.Fatalf("mixed response parts = %d, want 4; output=%s", len(parts), output)
}
if got := parts[0].Get("functionResponse.id").String(); got != "call-1" {
t.Fatalf("first functionResponse.id = %q, want call-1; output=%s", got, output)
}
if got := parts[1].Get("functionResponse.id").String(); got != "call-2" {
t.Fatalf("second functionResponse.id = %q, want call-2; output=%s", got, output)
}
if got := parts[2].Get("text").String(); got != "results follow" {
t.Fatalf("first trailing text = %q; output=%s", got, output)
}
if got := parts[3].Get("text").String(); got != "continue" {
t.Fatalf("second trailing text = %q; output=%s", got, output)
}
if errValidate := internalsignature.ValidateGeminiFunctionCallPairing(output); errValidate != nil {
t.Fatalf("normalized mixed parallel responses are invalid: %v; output=%s", errValidate, output)
}
}
func TestNormalizeAntigravityGeminiFunctionResponseRolesOrdersParallelResponses(t *testing.T) {

View File

@@ -95,10 +95,6 @@ func randomAntigravityFunctionHistory(randomSource *rand.Rand) []byte {
case 2:
responseContent["role"] = " Model "
}
if randomSource.Intn(12) == 0 {
orderedResponses = append(orderedResponses, map[string]any{"text": "mixed"})
responseContent["parts"] = orderedResponses
}
contents = append(contents, responseContent)
}
payload, errMarshal := json.Marshal(map[string]any{"request": map[string]any{"contents": contents}})

View File

@@ -1552,7 +1552,7 @@ func TestPrepareAntigravityGeminiReasoningReplayRestoresParallelClaudeToolProven
const args2 = `{"file_path":"/tmp/b"}`
clientID1 := util.GeminiClaudeToolUseID("native-read-1", "Read", args1)
clientID2 := util.GeminiClaudeToolUseID("native-read-2", "Read", args2)
payload := []byte(`{"sessionId":"sess-parallel-provenance","request":{"contents":[{"role":"model","parts":[{"thoughtSignature":"skip_thought_signature_validator","functionCall":{"id":"` + clientID1 + `","name":"Read","args":{"file_path":"/tmp/a","offset":0}}},{"functionCall":{"id":"` + clientID2 + `","name":"Read","args":{"file_path":"/tmp/b","offset":0}}}]},{"role":"user","parts":[{"functionResponse":{"id":"` + clientID2 + `","name":"Read","response":{"result":"b"}}},{"functionResponse":{"id":"` + clientID1 + `","name":"Read","response":{"result":"a"}}}]}]}}`)
payload := []byte(`{"sessionId":"sess-parallel-provenance","request":{"contents":[{"role":"model","parts":[{"thoughtSignature":"skip_thought_signature_validator","functionCall":{"id":"` + clientID1 + `","name":"Read","args":{"file_path":"/tmp/a","offset":0}}},{"functionCall":{"id":"` + clientID2 + `","name":"Read","args":{"file_path":"/tmp/b","offset":0}}}]},{"role":"user","parts":[{"text":"results follow"},{"functionResponse":{"id":"` + clientID2 + `","name":"Read","response":{"result":"b"}}},{"functionResponse":{"id":"` + clientID1 + `","name":"Read","response":{"result":"a"}}},{"text":"continue"}]}]}}`)
items := [][]byte{
[]byte(`{"type":"function_call_part","contentIndex":0,"partIndex":0,"targetOccurrence":0,"name":"Read","call_id":"native-read-1","args":` + args1 + `,"thoughtSignature":"EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg"}`),
[]byte(`{"type":"function_call_part","contentIndex":0,"partIndex":1,"targetOccurrence":0,"name":"Read","call_id":"native-read-2","args":` + args2 + `}`),
@@ -1576,9 +1576,15 @@ func TestPrepareAntigravityGeminiReasoningReplayRestoresParallelClaudeToolProven
t.Fatalf("signed/unsigned parallel provenance changed: %s", gjson.GetBytes(out, "request.contents.0").Raw)
}
responses := gjson.GetBytes(out, "request.contents.1.parts").Array()
if len(responses) != 2 || responses[0].Get("functionResponse.id").String() != "native-read-1" || responses[1].Get("functionResponse.id").String() != "native-read-2" {
if len(responses) != 4 || responses[0].Get("functionResponse.id").String() != "native-read-1" || responses[1].Get("functionResponse.id").String() != "native-read-2" {
t.Fatalf("parallel responses were not normalized to native order: %s", gjson.GetBytes(out, "request.contents.1").Raw)
}
if responses[2].Get("text").String() != "results follow" || responses[3].Get("text").String() != "continue" {
t.Fatalf("mixed user parts were not retained after parallel responses: %s", gjson.GetBytes(out, "request.contents.1").Raw)
}
if got := gjson.GetBytes(out, "request.contents.1.role").String(); got != "user" {
t.Fatalf("mixed response role = %q, want user; output=%s", got, out)
}
if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(out); errPairing != nil {
t.Fatalf("parallel restored history is invalid: %v", errPairing)
}