From 25feceb78341a195e40237fad290e896683fb5fd Mon Sep 17 00:00:00 2001 From: sususu98 Date: Mon, 30 Mar 2026 15:09:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(antigravity):=20reorder=20model=20parts=20t?= =?UTF-8?q?o=20prevent=20tool=5Fuse=E2=86=94tool=5Fresult=20pairing=20brea?= =?UTF-8?q?kage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #989 --- .../claude/antigravity_claude_request.go | 53 +++--- .../claude/antigravity_claude_request_test.go | 161 ++++++++++++++++++ 2 files changed, 194 insertions(+), 20 deletions(-) diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index 9e504d3f..243550c0 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -330,32 +330,45 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ } } - // Reorder parts for 'model' role to ensure thinking block is first + // Reorder parts for 'model' role: + // 1. Thinking parts first (Antigravity API requirement) + // 2. Regular parts (text, inlineData, etc.) + // 3. FunctionCall parts last + // + // Moving functionCall parts to the end prevents tool_use↔tool_result + // pairing breakage: the Antigravity API internally splits model messages + // at functionCall boundaries. If a text part follows a functionCall, the + // split creates an extra assistant turn between tool_use and tool_result, + // which Claude rejects with "tool_use ids were found without tool_result + // blocks immediately after". if role == "model" { partsResult := gjson.GetBytes(clientContentJSON, "parts") if partsResult.IsArray() { parts := partsResult.Array() - var thinkingParts []gjson.Result - var otherParts []gjson.Result - for _, part := range parts { - if part.Get("thought").Bool() { - thinkingParts = append(thinkingParts, part) - } else { - otherParts = append(otherParts, part) - } - } - if len(thinkingParts) > 0 { - firstPartIsThinking := parts[0].Get("thought").Bool() - if !firstPartIsThinking || len(thinkingParts) > 1 { - var newParts []interface{} - for _, p := range thinkingParts { - newParts = append(newParts, p.Value()) + if len(parts) > 1 { + 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) } - for _, p := range otherParts { - newParts = append(newParts, p.Value()) - } - 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) } } } diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go index df84ac54..cad61ca3 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go @@ -361,6 +361,167 @@ func TestConvertClaudeRequestToAntigravity_ReorderThinking(t *testing.T) { } } +func TestConvertClaudeRequestToAntigravity_ReorderTextAfterFunctionCall(t *testing.T) { + // Bug: text part after tool_use in an assistant message causes Antigravity + // to split at functionCall boundary, creating an extra assistant turn that + // breaks tool_use↔tool_result adjacency (upstream issue #989). + // Fix: reorder parts so functionCall comes last. + inputJSON := []byte(`{ + "model": "claude-sonnet-4-5", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "text", "text": "Let me check..."}, + { + "type": "tool_use", + "id": "call_abc", + "name": "Read", + "input": {"file": "test.go"} + }, + {"type": "text", "text": "Reading the file now"} + ] + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_abc", + "content": "file content" + } + ] + } + ] + }`) + + output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false) + outputStr := string(output) + + parts := gjson.Get(outputStr, "request.contents.0.parts").Array() + if len(parts) != 3 { + t.Fatalf("Expected 3 parts, got %d", len(parts)) + } + + // Text parts should come before functionCall + if parts[0].Get("text").String() != "Let me check..." { + t.Errorf("Expected first text part first, got %s", parts[0].Raw) + } + if parts[1].Get("text").String() != "Reading the file now" { + t.Errorf("Expected second text part second, got %s", parts[1].Raw) + } + if !parts[2].Get("functionCall").Exists() { + t.Errorf("Expected functionCall last, got %s", parts[2].Raw) + } + if parts[2].Get("functionCall.name").String() != "Read" { + t.Errorf("Expected functionCall name 'Read', got '%s'", parts[2].Get("functionCall.name").String()) + } +} + +func TestConvertClaudeRequestToAntigravity_ReorderParallelFunctionCalls(t *testing.T) { + inputJSON := []byte(`{ + "model": "claude-sonnet-4-5", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "text", "text": "Reading both files."}, + { + "type": "tool_use", + "id": "call_1", + "name": "Read", + "input": {"file": "a.go"} + }, + {"type": "text", "text": "And this one too."}, + { + "type": "tool_use", + "id": "call_2", + "name": "Read", + "input": {"file": "b.go"} + } + ] + } + ] + }`) + + output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false) + outputStr := string(output) + + parts := gjson.Get(outputStr, "request.contents.0.parts").Array() + if len(parts) != 4 { + t.Fatalf("Expected 4 parts, got %d", len(parts)) + } + + if parts[0].Get("text").String() != "Reading both files." { + t.Errorf("Expected first text, got %s", parts[0].Raw) + } + if parts[1].Get("text").String() != "And this one too." { + t.Errorf("Expected second text, got %s", parts[1].Raw) + } + if parts[2].Get("functionCall.name").String() != "Read" || parts[2].Get("functionCall.id").String() != "call_1" { + t.Errorf("Expected fc1 third, got %s", parts[2].Raw) + } + if parts[3].Get("functionCall.name").String() != "Read" || parts[3].Get("functionCall.id").String() != "call_2" { + t.Errorf("Expected fc2 fourth, got %s", parts[3].Raw) + } +} + +func TestConvertClaudeRequestToAntigravity_ReorderThinkingAndTextBeforeFunctionCall(t *testing.T) { + cache.ClearSignatureCache("") + + validSignature := "abc123validSignature1234567890123456789012345678901234567890" + thinkingText := "Let me think about this..." + + inputJSON := []byte(`{ + "model": "claude-sonnet-4-5-thinking", + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "Hello"}] + }, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "Before thinking"}, + {"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"}, + { + "type": "tool_use", + "id": "call_xyz", + "name": "Bash", + "input": {"command": "ls"} + }, + {"type": "text", "text": "After tool call"} + ] + } + ] + }`) + + cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, validSignature) + + output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false) + outputStr := string(output) + + // contents.1 = assistant message (contents.0 = user) + parts := gjson.Get(outputStr, "request.contents.1.parts").Array() + if len(parts) != 4 { + t.Fatalf("Expected 4 parts, got %d", len(parts)) + } + + // Order: thinking → text → text → functionCall + if !parts[0].Get("thought").Bool() { + t.Error("First part should be thinking") + } + if parts[1].Get("functionCall").Exists() || parts[1].Get("thought").Bool() { + t.Errorf("Second part should be text, got %s", parts[1].Raw) + } + if parts[2].Get("functionCall").Exists() || parts[2].Get("thought").Bool() { + t.Errorf("Third part should be text, got %s", parts[2].Raw) + } + if !parts[3].Get("functionCall").Exists() { + t.Errorf("Last part should be functionCall, got %s", parts[3].Raw) + } +} + func TestConvertClaudeRequestToAntigravity_ToolResult(t *testing.T) { inputJSON := []byte(`{ "model": "claude-3-5-sonnet-20240620",