diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request.go b/internal/translator/antigravity/gemini/antigravity_gemini_request.go index 1d044740..e5ce0c31 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,8 @@ 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 { + partRaw := parseFunctionResponseRaw(response, group.CallNames[ri]) 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..a2af6f83 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,13 @@ 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 := backfillFunctionResponseName(response.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) + } +}