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
194 lines
4.6 KiB
Go
194 lines
4.6 KiB
Go
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)
|
|
}
|
|
}
|