diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index 123913a10..a020709e4 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -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) } diff --git a/internal/runtime/executor/antigravity_executor_signature_test.go b/internal/runtime/executor/antigravity_executor_signature_test.go index 98d9fa4ad..7b5d69387 100644 --- a/internal/runtime/executor/antigravity_executor_signature_test.go +++ b/internal/runtime/executor/antigravity_executor_signature_test.go @@ -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) { diff --git a/internal/runtime/executor/antigravity_preupstream_rewrite_differential_test.go b/internal/runtime/executor/antigravity_preupstream_rewrite_differential_test.go index 33999aa97..1f4da536f 100644 --- a/internal/runtime/executor/antigravity_preupstream_rewrite_differential_test.go +++ b/internal/runtime/executor/antigravity_preupstream_rewrite_differential_test.go @@ -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}}) diff --git a/internal/runtime/executor/antigravity_reasoning_replay_test.go b/internal/runtime/executor/antigravity_reasoning_replay_test.go index 29e2f5de9..62a0c14a7 100644 --- a/internal/runtime/executor/antigravity_reasoning_replay_test.go +++ b/internal/runtime/executor/antigravity_reasoning_replay_test.go @@ -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) } diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index 4cb8cefda..69de15555 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -355,6 +355,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ // tool_use_id → tool_name lookup, populated incrementally during the main loop. // Claude's tool_result references tool_use by ID; Gemini requires functionResponse.name. toolNameByID := make(map[string]string) + var pendingToolUseIDs []string messagesResult := gjson.GetBytes(rawJSON, "messages") if messagesResult.IsArray() { @@ -367,6 +368,8 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ continue } originalRole := roleResult.String() + precedingToolUseIDs := pendingToolUseIDs + pendingToolUseIDs = nil role := originalRole if role == "assistant" { role = "model" @@ -403,6 +406,9 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ continue } if contentsResult.IsArray() { + if originalRole == "user" { + contentsResult = translatorcommon.AlignClaudeToolResults(contentsResult, precedingToolUseIDs) + } contentResults := contentsResult.Array() numContents := len(contentResults) for j := 0; j < numContents; j++ { @@ -620,6 +626,9 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ partJSON, _ = sjson.SetBytes(partJSON, "functionCall.name", functionName) partJSON, _ = sjson.SetRawBytes(partJSON, "functionCall.args", []byte(argsRaw)) partItems = append(partItems, partJSON) + if originalRole == "assistant" { + pendingToolUseIDs = append(pendingToolUseIDs, functionID) + } } } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_result" { toolCallID := contentResult.Get("tool_use_id").String() diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go index 7344ff612..d64b36e51 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go @@ -9,6 +9,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + internalsignature "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" log "github.com/sirupsen/logrus" "github.com/sirupsen/logrus/hooks/test" "github.com/tidwall/gjson" @@ -2007,6 +2008,53 @@ func TestConvertClaudeRequestToAntigravity_ReorderParallelFunctionCalls(t *testi } } +func TestConvertClaudeRequestToAntigravity_AlignsPermutedParallelToolResultsWithMixedText(t *testing.T) { + inputJSON := []byte(`{ + "model":"gemini-3.7-flash-high", + "messages":[ + {"role":"assistant","content":[ + {"type":"tool_use","id":"call_1620603","name":"Read","input":{"file_path":"/tmp/1"}}, + {"type":"tool_use","id":"call_1620604","name":"Read","input":{"file_path":"/tmp/2"}}, + {"type":"tool_use","id":"call_1620605","name":"Read","input":{"file_path":"/tmp/3"}}, + {"type":"tool_use","id":"call_1620606","name":"Read","input":{"file_path":"/tmp/4"}}, + {"type":"tool_use","id":"call_1620607","name":"Read","input":{"file_path":"/tmp/5"}}, + {"type":"tool_use","id":"call_1620608","name":"Read","input":{"file_path":"/tmp/6"}} + ]}, + {"role":"user","content":[ + {"type":"text","text":"Tool results follow."}, + {"type":"tool_result","tool_use_id":"call_1620608","content":"six"}, + {"type":"tool_result","tool_use_id":"call_1620605","content":"three"}, + {"type":"tool_result","tool_use_id":"call_1620603","content":"one"}, + {"type":"text","text":"Continue after reading."}, + {"type":"tool_result","tool_use_id":"call_1620607","content":"five"}, + {"type":"tool_result","tool_use_id":"call_1620604","content":"two"}, + {"type":"tool_result","tool_use_id":"call_1620606","content":"four"} + ]} + ] + }`) + + 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) + } + for index := 0; index < 6; 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 got := parts[6].Get("text").String(); got != "Tool results follow." { + t.Fatalf("first trailing 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) + } + if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(output); errPairing != nil { + t.Fatalf("translated parallel tool history is invalid: %v; output=%s", errPairing, output) + } +} + func TestConvertClaudeRequestToAntigravity_ReorderThinkingAndTextBeforeFunctionCall(t *testing.T) { cache.ClearSignatureCache("") diff --git a/internal/translator/common/claude_messages.go b/internal/translator/common/claude_messages.go index dfc4460c6..36e930200 100644 --- a/internal/translator/common/claude_messages.go +++ b/internal/translator/common/claude_messages.go @@ -76,6 +76,50 @@ 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. +func AlignClaudeToolResults(content gjson.Result, toolUseIDs []string) gjson.Result { + if !content.IsArray() || len(toolUseIDs) == 0 { + return content + } + + parts := content.Array() + toolResults := make([]gjson.Result, 0, len(toolUseIDs)) + otherParts := make([]gjson.Result, 0, len(parts)) + for _, part := range parts { + if part.Get("type").String() == "tool_result" { + toolResults = append(toolResults, part) + continue + } + otherParts = append(otherParts, part) + } + if len(toolResults) != len(toolUseIDs) { + return content + } + + ordered := make([][]byte, 0, len(parts)) + used := make([]bool, len(toolResults)) + for _, toolUseID := range toolUseIDs { + matched := -1 + for resultIndex, toolResult := range toolResults { + if !used[resultIndex] && toolUseID != "" && toolResult.Get("tool_use_id").String() == toolUseID { + matched = resultIndex + break + } + } + if matched < 0 { + return content + } + used[matched] = true + ordered = append(ordered, []byte(toolResults[matched].Raw)) + } + for _, part := range otherParts { + ordered = append(ordered, []byte(part.Raw)) + } + return gjson.ParseBytes(JoinRawArray(ordered)) +} + func claudeMessageContentParts(content gjson.Result) [][]byte { if !content.Exists() || content.Type == gjson.Null { return nil diff --git a/internal/translator/common/claude_messages_test.go b/internal/translator/common/claude_messages_test.go index 9ff318eff..042b35265 100644 --- a/internal/translator/common/claude_messages_test.go +++ b/internal/translator/common/claude_messages_test.go @@ -108,3 +108,56 @@ func TestClaudeMessageAccumulatorPreservesBlockCacheControl(t *testing.T) { t.Fatalf("second block should not have cache_control: %s", string(messages[0])) } } + +func TestAlignClaudeToolResults(t *testing.T) { + t.Run("reorders permuted results and keeps other parts after", func(t *testing.T) { + input := gjson.Parse(`[ + {"type":"tool_result","tool_use_id":"call_2","content":"two"}, + {"type":"text","text":"extra user text"}, + {"type":"tool_result","tool_use_id":"call_1","content":"one"} + ]`) + aligned := AlignClaudeToolResults(input, []string{"call_1", "call_2"}) + parts := aligned.Array() + if len(parts) != 3 { + t.Fatalf("len(parts) = %d, want 3", len(parts)) + } + 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[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) + } + }) + + t.Run("returns original content when count mismatches", func(t *testing.T) { + input := gjson.Parse(`[ + {"type":"tool_result","tool_use_id":"call_1","content":"one"} + ]`) + aligned := AlignClaudeToolResults(input, []string{"call_1", "call_2"}) + if aligned.Raw != input.Raw { + t.Fatalf("aligned = %s, want %s", aligned.Raw, input.Raw) + } + }) + + t.Run("returns original content when id not found", func(t *testing.T) { + input := gjson.Parse(`[ + {"type":"tool_result","tool_use_id":"call_unknown","content":"unknown"}, + {"type":"tool_result","tool_use_id":"call_1","content":"one"} + ]`) + aligned := AlignClaudeToolResults(input, []string{"call_1", "call_2"}) + if aligned.Raw != input.Raw { + t.Fatalf("aligned = %s, want %s", aligned.Raw, input.Raw) + } + }) + + t.Run("returns original content for non-array", func(t *testing.T) { + input := gjson.Parse(`"text content"`) + aligned := AlignClaudeToolResults(input, []string{"call_1"}) + if aligned.Raw != input.Raw { + t.Fatalf("aligned = %s, want %s", aligned.Raw, input.Raw) + } + }) +} diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index 0cf9afe0f..71da65d2d 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -79,12 +79,17 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool, // contents if messagesResult := gjson.GetBytes(rawJSON, "messages"); messagesResult.IsArray() { contentItems := translatorcommon.NewRawArrayItems(messagesResult.Get("#").Int()) + toolNameByID := make(map[string]string) + var pendingToolUseIDs []string messagesResult.ForEach(func(_, messageResult gjson.Result) bool { roleResult := messageResult.Get("role") if roleResult.Type != gjson.String { return true } - role := roleResult.String() + originalRole := roleResult.String() + precedingToolUseIDs := pendingToolUseIDs + pendingToolUseIDs = nil + role := originalRole if role == "assistant" { role = "model" } else if role == "system" { @@ -103,6 +108,9 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool, return true } if contentsResult.IsArray() { + if originalRole == "user" { + contentsResult = translatorcommon.AlignClaudeToolResults(contentsResult, precedingToolUseIDs) + } contentsResult.ForEach(func(_, contentResult gjson.Result) bool { switch contentResult.Get("type").String() { case "text": @@ -126,10 +134,9 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool, case "tool_use": functionName := contentResult.Get("name").String() - if toolUseID := contentResult.Get("id").String(); toolUseID != "" { - if derived := toolNameFromClaudeToolUseID(toolUseID); derived != "" { - functionName = derived - } + toolUseID := contentResult.Get("id").String() + if toolUseID != "" && functionName != "" { + toolNameByID[toolUseID] = functionName } functionName = util.SanitizeFunctionName(functionName) functionArgs := contentResult.Get("input").String() @@ -137,9 +144,15 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool, if argsResult.IsObject() && gjson.Valid(functionArgs) { part := []byte(`{"thoughtSignature":"","functionCall":{"name":"","args":{}}}`) part, _ = sjson.SetBytes(part, "thoughtSignature", geminiClaudeThoughtSignature) + if toolUseID != "" { + part, _ = sjson.SetBytes(part, "functionCall.id", toolUseID) + } part, _ = sjson.SetBytes(part, "functionCall.name", functionName) part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(functionArgs)) partItems = append(partItems, part) + if originalRole == "assistant" { + pendingToolUseIDs = append(pendingToolUseIDs, toolUseID) + } } case "tool_result": @@ -147,13 +160,17 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool, if toolCallID == "" { return true } - funcName := toolNameFromClaudeToolUseID(toolCallID) + funcName := toolNameByID[toolCallID] + if funcName == "" { + funcName = toolNameFromClaudeToolUseID(toolCallID) + } if funcName == "" { funcName = toolCallID } funcName = util.SanitizeFunctionName(funcName) toolResult := util.ConvertClaudeToolResultContent(contentResult.Get("content")) part := []byte(`{"functionResponse":{"name":"","response":{"result":""}}}`) + part, _ = sjson.SetBytes(part, "functionResponse.id", toolCallID) part, _ = sjson.SetBytes(part, "functionResponse.name", funcName) if toolResult.ResultIsRaw { part, _ = sjson.SetRawBytes(part, "functionResponse.response.result", []byte(toolResult.Result)) diff --git a/internal/translator/gemini/claude/gemini_claude_request_test.go b/internal/translator/gemini/claude/gemini_claude_request_test.go index 64a56a627..185a66698 100644 --- a/internal/translator/gemini/claude/gemini_claude_request_test.go +++ b/internal/translator/gemini/claude/gemini_claude_request_test.go @@ -3,6 +3,7 @@ package claude import ( "testing" + internalsignature "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" "github.com/tidwall/gjson" ) @@ -245,6 +246,53 @@ func TestConvertClaudeRequestToGemini_StructuredToolResult(t *testing.T) { } } +func TestConvertClaudeRequestToGemini_AlignsPermutedParallelToolResultsWithMixedText(t *testing.T) { + inputJSON := []byte(`{ + "model":"gemini-3.7-flash-high", + "messages":[ + {"role":"assistant","content":[ + {"type":"tool_use","id":"call_1","name":"Read","input":{"file_path":"/tmp/1"}}, + {"type":"tool_use","id":"call_2","name":"Read","input":{"file_path":"/tmp/2"}}, + {"type":"tool_use","id":"call_3","name":"Read","input":{"file_path":"/tmp/3"}} + ]}, + {"role":"user","content":[ + {"type":"text","text":"Results arrived."}, + {"type":"tool_result","tool_use_id":"call_3","content":"three"}, + {"type":"tool_result","tool_use_id":"call_1","content":"one"}, + {"type":"tool_result","tool_use_id":"call_2","content":"two"}, + {"type":"text","text":"Continue."} + ]} + ] + }`) + + output := ConvertClaudeRequestToGemini("gemini-3.7-flash-high", inputJSON, false) + callParts := gjson.GetBytes(output, "contents.0.parts").Array() + responseParts := gjson.GetBytes(output, "contents.1.parts").Array() + if len(callParts) != 3 || len(responseParts) != 5 { + t.Fatalf("translated parts = %d calls and %d response-turn parts; output=%s", len(callParts), len(responseParts), output) + } + for index, wantID := range []string{"call_1", "call_2", "call_3"} { + 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 { + t.Fatalf("functionResponse[%d].id = %q, want %q; output=%s", index, gotID, wantID, output) + } + if gotName := responseParts[index].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) + } + if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(output); errPairing != nil { + t.Fatalf("translated parallel tool history is invalid: %v; output=%s", errPairing, output) + } +} + func TestConvertClaudeRequestToGemini_StringToolResult(t *testing.T) { inputJSON := []byte(`{ "model": "gemini-3-flash-preview",