From 861537c9bd77fb3016578b78ad1216ad83741109 Mon Sep 17 00:00:00 2001 From: Aikins Laryea Date: Thu, 12 Mar 2026 00:00:38 +0000 Subject: [PATCH] fix: backfill empty functionResponse.name from preceding functionCall when Amp or Claude Code sends functionResponse with an empty name in Gemini conversation history, the Gemini API rejects the request with 400 "Name cannot be empty". this fix backfills empty names from the corresponding preceding functionCall parts using positional matching. covers all three Gemini translator paths: - gemini/gemini (direct API key) - antigravity/gemini (OAuth) - gemini-cli/gemini (Gemini CLI) also switches fixCLIToolResponse pending group matching from LIFO to FIFO to correctly handle multiple sequential tool call groups. fixes #1903 --- .../gemini/antigravity_gemini_request.go | 79 +++--- .../gemini/antigravity_gemini_request_test.go | 254 ++++++++++++++++++ .../gemini/gemini-cli_gemini_request.go | 70 +++-- .../gemini/gemini/gemini_gemini_request.go | 67 +++++ .../gemini/gemini_gemini_request_test.go | 193 +++++++++++++ 5 files changed, 604 insertions(+), 59 deletions(-) create mode 100644 internal/translator/gemini/gemini/gemini_gemini_request_test.go diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request.go b/internal/translator/antigravity/gemini/antigravity_gemini_request.go index 1d044740..2c8ff402 100644 --- a/internal/translator/antigravity/gemini/antigravity_gemini_request.go +++ b/internal/translator/antigravity/gemini/antigravity_gemini_request.go @@ -138,20 +138,31 @@ func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _ // FunctionCallGroup represents a group of function calls and their responses type FunctionCallGroup struct { ResponsesNeeded int + CallNames []string // ordered function call names for backfilling empty response names } // parseFunctionResponseRaw attempts to normalize a function response part into a JSON object string. // Falls back to a minimal "functionResponse" object when parsing fails. -func parseFunctionResponseRaw(response gjson.Result) string { +// fallbackName is used when the response's own name is empty. +func parseFunctionResponseRaw(response gjson.Result, fallbackName string) string { if response.IsObject() && gjson.Valid(response.Raw) { - return response.Raw + raw := response.Raw + name := response.Get("functionResponse.name").String() + if strings.TrimSpace(name) == "" && fallbackName != "" { + raw, _ = sjson.Set(raw, "functionResponse.name", fallbackName) + } + return raw } log.Debugf("parse function response failed, using fallback") funcResp := response.Get("functionResponse") if funcResp.Exists() { fr := `{"functionResponse":{"name":"","response":{"result":""}}}` - fr, _ = sjson.Set(fr, "functionResponse.name", funcResp.Get("name").String()) + name := funcResp.Get("name").String() + if strings.TrimSpace(name) == "" { + name = fallbackName + } + fr, _ = sjson.Set(fr, "functionResponse.name", name) fr, _ = sjson.Set(fr, "functionResponse.response.result", funcResp.Get("response").String()) if id := funcResp.Get("id").String(); id != "" { fr, _ = sjson.Set(fr, "functionResponse.id", id) @@ -159,7 +170,12 @@ func parseFunctionResponseRaw(response gjson.Result) string { return fr } - fr := `{"functionResponse":{"name":"unknown","response":{"result":""}}}` + useName := fallbackName + if useName == "" { + useName = "unknown" + } + fr := `{"functionResponse":{"name":"","response":{"result":""}}}` + fr, _ = sjson.Set(fr, "functionResponse.name", useName) fr, _ = sjson.Set(fr, "functionResponse.response.result", response.String()) return fr } @@ -211,30 +227,26 @@ func fixCLIToolResponse(input string) (string, error) { if len(responsePartsInThisContent) > 0 { collectedResponses = append(collectedResponses, responsePartsInThisContent...) - // Check if any pending groups can be satisfied - for i := len(pendingGroups) - 1; i >= 0; i-- { - group := pendingGroups[i] - if len(collectedResponses) >= group.ResponsesNeeded { - // Take the needed responses for this group - groupResponses := collectedResponses[:group.ResponsesNeeded] - collectedResponses = collectedResponses[group.ResponsesNeeded:] + // Check if pending groups can be satisfied (FIFO: oldest group first) + for len(pendingGroups) > 0 && len(collectedResponses) >= pendingGroups[0].ResponsesNeeded { + group := pendingGroups[0] + pendingGroups = pendingGroups[1:] - // Create merged function response content - functionResponseContent := `{"parts":[],"role":"function"}` - for _, response := range groupResponses { - partRaw := parseFunctionResponseRaw(response) - if partRaw != "" { - functionResponseContent, _ = sjson.SetRaw(functionResponseContent, "parts.-1", partRaw) - } + // Take the needed responses for this group + groupResponses := collectedResponses[:group.ResponsesNeeded] + collectedResponses = collectedResponses[group.ResponsesNeeded:] + + // Create merged function response content + functionResponseContent := `{"parts":[],"role":"function"}` + for ri, response := range groupResponses { + partRaw := parseFunctionResponseRaw(response, group.CallNames[ri]) + if partRaw != "" { + functionResponseContent, _ = sjson.SetRaw(functionResponseContent, "parts.-1", partRaw) } + } - if gjson.Get(functionResponseContent, "parts.#").Int() > 0 { - contentsWrapper, _ = sjson.SetRaw(contentsWrapper, "contents.-1", functionResponseContent) - } - - // Remove this group as it's been satisfied - pendingGroups = append(pendingGroups[:i], pendingGroups[i+1:]...) - break + if gjson.Get(functionResponseContent, "parts.#").Int() > 0 { + contentsWrapper, _ = sjson.SetRaw(contentsWrapper, "contents.-1", functionResponseContent) } } @@ -243,15 +255,15 @@ func fixCLIToolResponse(input string) (string, error) { // If this is a model with function calls, create a new group if role == "model" { - functionCallsCount := 0 + var callNames []string parts.ForEach(func(_, part gjson.Result) bool { if part.Get("functionCall").Exists() { - functionCallsCount++ + callNames = append(callNames, part.Get("functionCall.name").String()) } return true }) - if functionCallsCount > 0 { + if len(callNames) > 0 { // Add the model content if !value.IsObject() { log.Warnf("failed to parse model content") @@ -261,7 +273,8 @@ func fixCLIToolResponse(input string) (string, error) { // Create a new group for tracking responses group := &FunctionCallGroup{ - ResponsesNeeded: functionCallsCount, + ResponsesNeeded: len(callNames), + CallNames: callNames, } pendingGroups = append(pendingGroups, group) } else { @@ -291,8 +304,12 @@ func fixCLIToolResponse(input string) (string, error) { collectedResponses = collectedResponses[group.ResponsesNeeded:] functionResponseContent := `{"parts":[],"role":"function"}` - for _, response := range groupResponses { - partRaw := parseFunctionResponseRaw(response) + for ri, response := range groupResponses { + fallbackName := "" + if ri < len(group.CallNames) { + fallbackName = group.CallNames[ri] + } + partRaw := parseFunctionResponseRaw(response, fallbackName) if partRaw != "" { functionResponseContent, _ = sjson.SetRaw(functionResponseContent, "parts.-1", partRaw) } diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go b/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go index da581d1a..7e9e3bba 100644 --- a/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go +++ b/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go @@ -171,3 +171,257 @@ func TestFixCLIToolResponse_PreservesFunctionResponseParts(t *testing.T) { t.Errorf("Expected response.result 'Screenshot taken', got '%s'", funcResp.Get("response.result").String()) } } + +func TestFixCLIToolResponse_BackfillsEmptyFunctionResponseName(t *testing.T) { + // When the Amp client sends functionResponse with an empty name, + // fixCLIToolResponse should backfill it from the corresponding functionCall. + input := `{ + "model": "gemini-3-pro-preview", + "request": { + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "Bash", "args": {"cmd": "ls"}}} + ] + }, + { + "role": "function", + "parts": [ + {"functionResponse": {"name": "", "response": {"output": "file1.txt"}}} + ] + } + ] + } + }` + + result, err := fixCLIToolResponse(input) + if err != nil { + t.Fatalf("fixCLIToolResponse failed: %v", err) + } + + contents := gjson.Get(result, "request.contents").Array() + var funcContent gjson.Result + for _, c := range contents { + if c.Get("role").String() == "function" { + funcContent = c + break + } + } + if !funcContent.Exists() { + t.Fatal("function role content should exist in output") + } + + name := funcContent.Get("parts.0.functionResponse.name").String() + if name != "Bash" { + t.Errorf("Expected backfilled name 'Bash', got '%s'", name) + } +} + +func TestFixCLIToolResponse_BackfillsMultipleEmptyNames(t *testing.T) { + // Parallel function calls: both responses have empty names. + input := `{ + "model": "gemini-3-pro-preview", + "request": { + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "Read", "args": {"path": "/a"}}}, + {"functionCall": {"name": "Grep", "args": {"pattern": "x"}}} + ] + }, + { + "role": "function", + "parts": [ + {"functionResponse": {"name": "", "response": {"result": "content a"}}}, + {"functionResponse": {"name": "", "response": {"result": "match x"}}} + ] + } + ] + } + }` + + result, err := fixCLIToolResponse(input) + if err != nil { + t.Fatalf("fixCLIToolResponse failed: %v", err) + } + + contents := gjson.Get(result, "request.contents").Array() + var funcContent gjson.Result + for _, c := range contents { + if c.Get("role").String() == "function" { + funcContent = c + break + } + } + if !funcContent.Exists() { + t.Fatal("function role content should exist in output") + } + + parts := funcContent.Get("parts").Array() + if len(parts) != 2 { + t.Fatalf("Expected 2 function response parts, got %d", len(parts)) + } + + name0 := parts[0].Get("functionResponse.name").String() + name1 := parts[1].Get("functionResponse.name").String() + if name0 != "Read" { + t.Errorf("Expected first response name 'Read', got '%s'", name0) + } + if name1 != "Grep" { + t.Errorf("Expected second response name 'Grep', got '%s'", name1) + } +} + +func TestFixCLIToolResponse_PreservesExistingName(t *testing.T) { + // When functionResponse already has a valid name, it should be preserved. + input := `{ + "model": "gemini-3-pro-preview", + "request": { + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "Bash", "args": {}}} + ] + }, + { + "role": "function", + "parts": [ + {"functionResponse": {"name": "Bash", "response": {"result": "ok"}}} + ] + } + ] + } + }` + + result, err := fixCLIToolResponse(input) + if err != nil { + t.Fatalf("fixCLIToolResponse failed: %v", err) + } + + contents := gjson.Get(result, "request.contents").Array() + var funcContent gjson.Result + for _, c := range contents { + if c.Get("role").String() == "function" { + funcContent = c + break + } + } + if !funcContent.Exists() { + t.Fatal("function role content should exist in output") + } + + name := funcContent.Get("parts.0.functionResponse.name").String() + if name != "Bash" { + t.Errorf("Expected preserved name 'Bash', got '%s'", name) + } +} + +func TestFixCLIToolResponse_MoreResponsesThanCalls(t *testing.T) { + // If there are more function responses than calls, unmatched extras are discarded by grouping. + input := `{ + "model": "gemini-3-pro-preview", + "request": { + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "Bash", "args": {}}} + ] + }, + { + "role": "function", + "parts": [ + {"functionResponse": {"name": "", "response": {"result": "ok"}}}, + {"functionResponse": {"name": "", "response": {"result": "extra"}}} + ] + } + ] + } + }` + + result, err := fixCLIToolResponse(input) + if err != nil { + t.Fatalf("fixCLIToolResponse failed: %v", err) + } + + contents := gjson.Get(result, "request.contents").Array() + var funcContent gjson.Result + for _, c := range contents { + if c.Get("role").String() == "function" { + funcContent = c + break + } + } + if !funcContent.Exists() { + t.Fatal("function role content should exist in output") + } + + // First response should be backfilled from the call + name0 := funcContent.Get("parts.0.functionResponse.name").String() + if name0 != "Bash" { + t.Errorf("Expected first response name 'Bash', got '%s'", name0) + } +} + +func TestFixCLIToolResponse_MultipleGroupsFIFO(t *testing.T) { + // Two sequential function call groups should be matched FIFO. + input := `{ + "model": "gemini-3-pro-preview", + "request": { + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "Read", "args": {}}} + ] + }, + { + "role": "function", + "parts": [ + {"functionResponse": {"name": "", "response": {"result": "file content"}}} + ] + }, + { + "role": "model", + "parts": [ + {"functionCall": {"name": "Grep", "args": {}}} + ] + }, + { + "role": "function", + "parts": [ + {"functionResponse": {"name": "", "response": {"result": "match"}}} + ] + } + ] + } + }` + + result, err := fixCLIToolResponse(input) + if err != nil { + t.Fatalf("fixCLIToolResponse failed: %v", err) + } + + contents := gjson.Get(result, "request.contents").Array() + var funcContents []gjson.Result + for _, c := range contents { + if c.Get("role").String() == "function" { + funcContents = append(funcContents, c) + } + } + if len(funcContents) != 2 { + t.Fatalf("Expected 2 function contents, got %d", len(funcContents)) + } + + name0 := funcContents[0].Get("parts.0.functionResponse.name").String() + name1 := funcContents[1].Get("parts.0.functionResponse.name").String() + if name0 != "Read" { + t.Errorf("Expected first group name 'Read', got '%s'", name0) + } + if name1 != "Grep" { + t.Errorf("Expected second group name 'Grep', got '%s'", name1) + } +} diff --git a/internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go b/internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go index 15ff8b98..c6039088 100644 --- a/internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go +++ b/internal/translator/gemini-cli/gemini/gemini-cli_gemini_request.go @@ -7,6 +7,7 @@ package gemini import ( "fmt" + "strings" "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" @@ -116,6 +117,17 @@ func ConvertGeminiRequestToGeminiCLI(_ string, inputRawJSON []byte, _ bool) []by // FunctionCallGroup represents a group of function calls and their responses type FunctionCallGroup struct { ResponsesNeeded int + CallNames []string // ordered function call names for backfilling empty response names +} + +// backfillFunctionResponseName ensures that a functionResponse JSON object has a non-empty name, +// falling back to fallbackName if the original is empty. +func backfillFunctionResponseName(raw string, fallbackName string) string { + name := gjson.Get(raw, "functionResponse.name").String() + if strings.TrimSpace(name) == "" && fallbackName != "" { + raw, _ = sjson.Set(raw, "functionResponse.name", fallbackName) + } + return raw } // fixCLIToolResponse performs sophisticated tool response format conversion and grouping. @@ -165,31 +177,28 @@ func fixCLIToolResponse(input string) (string, error) { if len(responsePartsInThisContent) > 0 { collectedResponses = append(collectedResponses, responsePartsInThisContent...) - // Check if any pending groups can be satisfied - for i := len(pendingGroups) - 1; i >= 0; i-- { - group := pendingGroups[i] - if len(collectedResponses) >= group.ResponsesNeeded { - // Take the needed responses for this group - groupResponses := collectedResponses[:group.ResponsesNeeded] - collectedResponses = collectedResponses[group.ResponsesNeeded:] + // Check if pending groups can be satisfied (FIFO: oldest group first) + for len(pendingGroups) > 0 && len(collectedResponses) >= pendingGroups[0].ResponsesNeeded { + group := pendingGroups[0] + pendingGroups = pendingGroups[1:] - // Create merged function response content - functionResponseContent := `{"parts":[],"role":"function"}` - for _, response := range groupResponses { - if !response.IsObject() { - log.Warnf("failed to parse function response") - continue - } - functionResponseContent, _ = sjson.SetRaw(functionResponseContent, "parts.-1", response.Raw) + // Take the needed responses for this group + groupResponses := collectedResponses[:group.ResponsesNeeded] + collectedResponses = collectedResponses[group.ResponsesNeeded:] + + // Create merged function response content + functionResponseContent := `{"parts":[],"role":"function"}` + for ri, response := range groupResponses { + if !response.IsObject() { + log.Warnf("failed to parse function response") + continue } + raw := backfillFunctionResponseName(response.Raw, group.CallNames[ri]) + functionResponseContent, _ = sjson.SetRaw(functionResponseContent, "parts.-1", raw) + } - if gjson.Get(functionResponseContent, "parts.#").Int() > 0 { - contentsWrapper, _ = sjson.SetRaw(contentsWrapper, "contents.-1", functionResponseContent) - } - - // Remove this group as it's been satisfied - pendingGroups = append(pendingGroups[:i], pendingGroups[i+1:]...) - break + if gjson.Get(functionResponseContent, "parts.#").Int() > 0 { + contentsWrapper, _ = sjson.SetRaw(contentsWrapper, "contents.-1", functionResponseContent) } } @@ -198,15 +207,15 @@ func fixCLIToolResponse(input string) (string, error) { // If this is a model with function calls, create a new group if role == "model" { - functionCallsCount := 0 + var callNames []string parts.ForEach(func(_, part gjson.Result) bool { if part.Get("functionCall").Exists() { - functionCallsCount++ + callNames = append(callNames, part.Get("functionCall.name").String()) } return true }) - if functionCallsCount > 0 { + if len(callNames) > 0 { // Add the model content if !value.IsObject() { log.Warnf("failed to parse model content") @@ -216,7 +225,8 @@ func fixCLIToolResponse(input string) (string, error) { // Create a new group for tracking responses group := &FunctionCallGroup{ - ResponsesNeeded: functionCallsCount, + ResponsesNeeded: len(callNames), + CallNames: callNames, } pendingGroups = append(pendingGroups, group) } else { @@ -246,12 +256,16 @@ func fixCLIToolResponse(input string) (string, error) { collectedResponses = collectedResponses[group.ResponsesNeeded:] functionResponseContent := `{"parts":[],"role":"function"}` - for _, response := range groupResponses { + for ri, response := range groupResponses { if !response.IsObject() { log.Warnf("failed to parse function response") continue } - functionResponseContent, _ = sjson.SetRaw(functionResponseContent, "parts.-1", response.Raw) + raw := response.Raw + if ri < len(group.CallNames) { + raw = backfillFunctionResponseName(raw, group.CallNames[ri]) + } + functionResponseContent, _ = sjson.SetRaw(functionResponseContent, "parts.-1", raw) } if gjson.Get(functionResponseContent, "parts.#").Int() > 0 { diff --git a/internal/translator/gemini/gemini/gemini_gemini_request.go b/internal/translator/gemini/gemini/gemini_gemini_request.go index 8024e9e3..abc176b2 100644 --- a/internal/translator/gemini/gemini/gemini_gemini_request.go +++ b/internal/translator/gemini/gemini/gemini_gemini_request.go @@ -5,9 +5,11 @@ package gemini import ( "fmt" + "strings" "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common" "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -95,6 +97,71 @@ func ConvertGeminiRequestToGemini(_ string, inputRawJSON []byte, _ bool) []byte out = []byte(strJson) } + // Backfill empty functionResponse.name from the preceding functionCall.name. + // Amp may send function responses with empty names; the Gemini API rejects these. + out = backfillEmptyFunctionResponseNames(out) + out = common.AttachDefaultSafetySettings(out, "safetySettings") return out } + +// backfillEmptyFunctionResponseNames walks the contents array and for each +// model turn containing functionCall parts, records the call names in order. +// For the immediately following user/function turn containing functionResponse +// parts, any empty name is replaced with the corresponding call name. +func backfillEmptyFunctionResponseNames(data []byte) []byte { + contents := gjson.GetBytes(data, "contents") + if !contents.Exists() { + return data + } + + out := data + var pendingCallNames []string + + contents.ForEach(func(contentIdx, content gjson.Result) bool { + role := content.Get("role").String() + + // Collect functionCall names from model turns + if role == "model" { + var names []string + content.Get("parts").ForEach(func(_, part gjson.Result) bool { + if part.Get("functionCall").Exists() { + names = append(names, part.Get("functionCall.name").String()) + } + return true + }) + if len(names) > 0 { + pendingCallNames = names + } else { + pendingCallNames = nil + } + return true + } + + // Backfill empty functionResponse names from pending call names + if len(pendingCallNames) > 0 { + ri := 0 + content.Get("parts").ForEach(func(partIdx, part gjson.Result) bool { + if part.Get("functionResponse").Exists() { + name := part.Get("functionResponse.name").String() + if strings.TrimSpace(name) == "" { + if ri < len(pendingCallNames) { + out, _ = sjson.SetBytes(out, + fmt.Sprintf("contents.%d.parts.%d.functionResponse.name", contentIdx.Int(), partIdx.Int()), + pendingCallNames[ri]) + } else { + log.Debugf("more function responses than calls at contents[%d], skipping name backfill", contentIdx.Int()) + } + } + ri++ + } + return true + }) + pendingCallNames = nil + } + + return true + }) + + return out +} diff --git a/internal/translator/gemini/gemini/gemini_gemini_request_test.go b/internal/translator/gemini/gemini/gemini_gemini_request_test.go new file mode 100644 index 00000000..5eb88fa5 --- /dev/null +++ b/internal/translator/gemini/gemini/gemini_gemini_request_test.go @@ -0,0 +1,193 @@ +package gemini + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestBackfillEmptyFunctionResponseNames_Single(t *testing.T) { + input := []byte(`{ + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "Bash", "args": {"cmd": "ls"}}} + ] + }, + { + "role": "user", + "parts": [ + {"functionResponse": {"name": "", "response": {"output": "file1.txt"}}} + ] + } + ] + }`) + + out := backfillEmptyFunctionResponseNames(input) + + name := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String() + if name != "Bash" { + t.Errorf("Expected backfilled name 'Bash', got '%s'", name) + } +} + +func TestBackfillEmptyFunctionResponseNames_Parallel(t *testing.T) { + input := []byte(`{ + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "Read", "args": {"path": "/a"}}}, + {"functionCall": {"name": "Grep", "args": {"pattern": "x"}}} + ] + }, + { + "role": "user", + "parts": [ + {"functionResponse": {"name": "", "response": {"result": "content a"}}}, + {"functionResponse": {"name": "", "response": {"result": "match x"}}} + ] + } + ] + }`) + + out := backfillEmptyFunctionResponseNames(input) + + name0 := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String() + name1 := gjson.GetBytes(out, "contents.1.parts.1.functionResponse.name").String() + if name0 != "Read" { + t.Errorf("Expected first name 'Read', got '%s'", name0) + } + if name1 != "Grep" { + t.Errorf("Expected second name 'Grep', got '%s'", name1) + } +} + +func TestBackfillEmptyFunctionResponseNames_PreservesExisting(t *testing.T) { + input := []byte(`{ + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "Bash", "args": {}}} + ] + }, + { + "role": "user", + "parts": [ + {"functionResponse": {"name": "Bash", "response": {"result": "ok"}}} + ] + } + ] + }`) + + out := backfillEmptyFunctionResponseNames(input) + + name := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String() + if name != "Bash" { + t.Errorf("Expected preserved name 'Bash', got '%s'", name) + } +} + +func TestConvertGeminiRequestToGemini_BackfillsEmptyName(t *testing.T) { + input := []byte(`{ + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "Bash", "args": {"cmd": "ls"}}} + ] + }, + { + "role": "user", + "parts": [ + {"functionResponse": {"name": "", "response": {"output": "file1.txt"}}} + ] + } + ] + }`) + + out := ConvertGeminiRequestToGemini("", input, false) + + name := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String() + if name != "Bash" { + t.Errorf("Expected backfilled name 'Bash', got '%s'", name) + } +} + +func TestBackfillEmptyFunctionResponseNames_MoreResponsesThanCalls(t *testing.T) { + // Extra responses beyond the call count should not panic and should be left unchanged. + input := []byte(`{ + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "Bash", "args": {}}} + ] + }, + { + "role": "user", + "parts": [ + {"functionResponse": {"name": "", "response": {"result": "ok"}}}, + {"functionResponse": {"name": "", "response": {"result": "extra"}}} + ] + } + ] + }`) + + out := backfillEmptyFunctionResponseNames(input) + + name0 := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String() + if name0 != "Bash" { + t.Errorf("Expected first name 'Bash', got '%s'", name0) + } + // Second response has no matching call, should remain empty + name1 := gjson.GetBytes(out, "contents.1.parts.1.functionResponse.name").String() + if name1 != "" { + t.Errorf("Expected second name to remain empty, got '%s'", name1) + } +} + +func TestBackfillEmptyFunctionResponseNames_MultipleGroups(t *testing.T) { + // Two sequential call/response groups should each get correct names. + input := []byte(`{ + "contents": [ + { + "role": "model", + "parts": [ + {"functionCall": {"name": "Read", "args": {}}} + ] + }, + { + "role": "user", + "parts": [ + {"functionResponse": {"name": "", "response": {"result": "content"}}} + ] + }, + { + "role": "model", + "parts": [ + {"functionCall": {"name": "Grep", "args": {}}} + ] + }, + { + "role": "user", + "parts": [ + {"functionResponse": {"name": "", "response": {"result": "match"}}} + ] + } + ] + }`) + + out := backfillEmptyFunctionResponseNames(input) + + name0 := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String() + name1 := gjson.GetBytes(out, "contents.3.parts.0.functionResponse.name").String() + if name0 != "Read" { + t.Errorf("Expected first group name 'Read', got '%s'", name0) + } + if name1 != "Grep" { + t.Errorf("Expected second group name 'Grep', got '%s'", name1) + } +}