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
428 lines
11 KiB
Go
428 lines
11 KiB
Go
package gemini
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
func TestConvertGeminiRequestToAntigravity_PreserveValidSignature(t *testing.T) {
|
|
// Valid signature on functionCall should be preserved
|
|
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
|
inputJSON := []byte(fmt.Sprintf(`{
|
|
"model": "gemini-3-pro-preview",
|
|
"contents": [
|
|
{
|
|
"role": "model",
|
|
"parts": [
|
|
{"functionCall": {"name": "test_tool", "args": {}}, "thoughtSignature": "%s"}
|
|
]
|
|
}
|
|
]
|
|
}`, validSignature))
|
|
|
|
output := ConvertGeminiRequestToAntigravity("gemini-3-pro-preview", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// Check that valid thoughtSignature is preserved
|
|
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
|
|
if len(parts) != 1 {
|
|
t.Fatalf("Expected 1 part, got %d", len(parts))
|
|
}
|
|
|
|
sig := parts[0].Get("thoughtSignature").String()
|
|
if sig != validSignature {
|
|
t.Errorf("Expected thoughtSignature '%s', got '%s'", validSignature, sig)
|
|
}
|
|
}
|
|
|
|
func TestConvertGeminiRequestToAntigravity_AddSkipSentinelToFunctionCall(t *testing.T) {
|
|
// functionCall without signature should get skip_thought_signature_validator
|
|
inputJSON := []byte(`{
|
|
"model": "gemini-3-pro-preview",
|
|
"contents": [
|
|
{
|
|
"role": "model",
|
|
"parts": [
|
|
{"functionCall": {"name": "test_tool", "args": {}}}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertGeminiRequestToAntigravity("gemini-3-pro-preview", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// Check that skip_thought_signature_validator is added to functionCall
|
|
sig := gjson.Get(outputStr, "request.contents.0.parts.0.thoughtSignature").String()
|
|
expectedSig := "skip_thought_signature_validator"
|
|
if sig != expectedSig {
|
|
t.Errorf("Expected skip sentinel '%s', got '%s'", expectedSig, sig)
|
|
}
|
|
}
|
|
|
|
func TestConvertGeminiRequestToAntigravity_ParallelFunctionCalls(t *testing.T) {
|
|
// Multiple functionCalls should all get skip_thought_signature_validator
|
|
inputJSON := []byte(`{
|
|
"model": "gemini-3-pro-preview",
|
|
"contents": [
|
|
{
|
|
"role": "model",
|
|
"parts": [
|
|
{"functionCall": {"name": "tool_one", "args": {"a": "1"}}},
|
|
{"functionCall": {"name": "tool_two", "args": {"b": "2"}}}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertGeminiRequestToAntigravity("gemini-3-pro-preview", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
|
|
if len(parts) != 2 {
|
|
t.Fatalf("Expected 2 parts, got %d", len(parts))
|
|
}
|
|
|
|
expectedSig := "skip_thought_signature_validator"
|
|
for i, part := range parts {
|
|
sig := part.Get("thoughtSignature").String()
|
|
if sig != expectedSig {
|
|
t.Errorf("Part %d: Expected '%s', got '%s'", i, expectedSig, sig)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFixCLIToolResponse_PreservesFunctionResponseParts(t *testing.T) {
|
|
// When functionResponse contains a "parts" field with inlineData (from Claude
|
|
// translator's image embedding), fixCLIToolResponse should preserve it as-is.
|
|
// parseFunctionResponseRaw returns response.Raw for valid JSON objects,
|
|
// so extra fields like "parts" survive the pipeline.
|
|
input := `{
|
|
"model": "claude-opus-4-6-thinking",
|
|
"request": {
|
|
"contents": [
|
|
{
|
|
"role": "model",
|
|
"parts": [
|
|
{
|
|
"functionCall": {"name": "screenshot", "args": {}}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"role": "function",
|
|
"parts": [
|
|
{
|
|
"functionResponse": {
|
|
"id": "tool-001",
|
|
"name": "screenshot",
|
|
"response": {"result": "Screenshot taken"},
|
|
"parts": [
|
|
{"inlineData": {"mimeType": "image/png", "data": "iVBOR"}}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}`
|
|
|
|
result, err := fixCLIToolResponse(input)
|
|
if err != nil {
|
|
t.Fatalf("fixCLIToolResponse failed: %v", err)
|
|
}
|
|
|
|
// Find the function response content (role=function)
|
|
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")
|
|
}
|
|
|
|
// The functionResponse should be preserved with its parts field
|
|
funcResp := funcContent.Get("parts.0.functionResponse")
|
|
if !funcResp.Exists() {
|
|
t.Fatal("functionResponse should exist in output")
|
|
}
|
|
|
|
// Verify the parts field with inlineData is preserved
|
|
inlineParts := funcResp.Get("parts").Array()
|
|
if len(inlineParts) != 1 {
|
|
t.Fatalf("Expected 1 inlineData part in functionResponse.parts, got %d", len(inlineParts))
|
|
}
|
|
if inlineParts[0].Get("inlineData.mimeType").String() != "image/png" {
|
|
t.Errorf("Expected mimeType 'image/png', got '%s'", inlineParts[0].Get("inlineData.mimeType").String())
|
|
}
|
|
if inlineParts[0].Get("inlineData.data").String() != "iVBOR" {
|
|
t.Errorf("Expected data 'iVBOR', got '%s'", inlineParts[0].Get("inlineData.data").String())
|
|
}
|
|
|
|
// Verify response.result is also preserved
|
|
if funcResp.Get("response.result").String() != "Screenshot taken" {
|
|
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)
|
|
}
|
|
}
|