Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,74 @@ func logAntigravityClaudeGeminiSignatureSanitize(modelName, action, reason strin
log.WithFields(fields).Debug("antigravity gemini translator: sanitized Claude target thoughtSignature before upstream")
}

func normalizeAntigravityInlineDataPart(part gjson.Result) ([]byte, bool) {
inline := part.Get("inlineData")
if !inline.Exists() {
inline = part.Get("inline_data")
}
if !inline.Exists() {
return nil, false
}
data := inline.Get("data").String()
if data == "" {
return nil, false
}
mimeType := inline.Get("mimeType").String()
if mimeType == "" {
mimeType = inline.Get("mime_type").String()
}
if mimeType == "" {
// Cloud Code Assist ignores inlineData without mimeType.
mimeType = "image/png"
}
out := []byte(`{"inlineData":{"mimeType":"","data":""}}`)
out, _ = sjson.SetBytes(out, "inlineData.mimeType", mimeType)
out, _ = sjson.SetBytes(out, "inlineData.data", data)
return out, true
}

func attachInlineDataToFunctionResponse(response gjson.Result, images [][]byte) gjson.Result {
if len(images) == 0 {
return response
}
target := []byte(response.Raw)
for _, img := range images {
target, _ = sjson.SetRawBytes(target, "functionResponse.parts.-1", img)
}
return gjson.ParseBytes(target)
}

// collectFunctionResponsesWithSiblingInlineData keeps functionResponse parts and
// moves sibling inline_data/inlineData onto the nearest preceding functionResponse.
// Leading images before the first functionResponse attach to that first response.
func collectFunctionResponsesWithSiblingInlineData(parts gjson.Result) []gjson.Result {
responses := make([]gjson.Result, 0)
leadingImages := make([][]byte, 0)
current := -1
parts.ForEach(func(_, part gjson.Result) bool {
if part.Get("functionResponse").Exists() {
responses = append(responses, part)
current = len(responses) - 1
if len(leadingImages) > 0 {
responses[current] = attachInlineDataToFunctionResponse(responses[current], leadingImages)
leadingImages = nil
}
return true
}
imagePart, ok := normalizeAntigravityInlineDataPart(part)
if !ok {
return true
}
if current >= 0 {
responses[current] = attachInlineDataToFunctionResponse(responses[current], [][]byte{imagePart})
return true
}
leadingImages = append(leadingImages, imagePart)
return true
})
return responses
}

// FunctionCallGroup represents a group of function calls and their responses
type FunctionCallGroup struct {
ResponsesNeeded int
Expand Down Expand Up @@ -749,14 +817,8 @@ func fixCLIToolResponse(input []byte) ([]byte, error) {
role := value.Get("role").String()
parts := value.Get("parts")

// Check if this content has function responses
var responsePartsInThisContent []gjson.Result
parts.ForEach(func(_, part gjson.Result) bool {
if part.Get("functionResponse").Exists() {
responsePartsInThisContent = append(responsePartsInThisContent, part)
}
return true
})
// Collect function responses and attach sibling inlineData to the nearest one.
responsePartsInThisContent := collectFunctionResponsesWithSiblingInlineData(parts)

// If this content has function responses, collect them
if len(responsePartsInThisContent) > 0 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,177 @@ func TestSanitizeAntigravityClaudeGeminiRequestSignatures_StringValueNotTreatedA
}
}

func TestFixCLIToolResponse_AttachesSiblingInlineDataToNearestFunctionResponse(t *testing.T) {
type wantImage struct {
id string
mime string
data string
}
tests := []struct {
name string
modelCalls string
parts string
want []wantImage
extraChecks func(t *testing.T, gotByID map[string][]gjson.Result)
}{
{
name: "snake_case sibling after single response",
modelCalls: `{"functionCall":{"name":"read","id":"call_1"}}`,
parts: `{"functionResponse":{"name":"read","response":{"result":"Read image file [image/png]"},"id":"call_1"}},` +
`{"inline_data":{"mime_type":"image/png","data":"QUJD"}}`,
want: []wantImage{{id: "call_1", mime: "image/png", data: "QUJD"}},
},
{
name: "camelCase sibling after single response",
modelCalls: `{"functionCall":{"name":"read","id":"call_1"}}`,
parts: `{"functionResponse":{"name":"read","response":{"result":"ok"},"id":"call_1"}},` +
`{"inlineData":{"mimeType":"image/webp","data":"NEW"}}`,
want: []wantImage{{id: "call_1", mime: "image/webp", data: "NEW"}},
},
{
name: "append sibling onto existing functionResponse.parts",
modelCalls: `{"functionCall":{"name":"read","id":"call_1"}}`,
parts: `{"functionResponse":{"name":"read","response":{"result":"ok"},"id":"call_1","parts":[{"inlineData":{"mimeType":"image/gif","data":"OLD"}}]}},` +
`{"inlineData":{"mimeType":"image/webp","data":"NEW"}}`,
want: []wantImage{
{id: "call_1", mime: "image/gif", data: "OLD"},
},
extraChecks: func(t *testing.T, gotByID map[string][]gjson.Result) {
images := gotByID["call_1"]
if len(images) != 2 {
t.Fatalf("existing+sibling parts = %d, want 2", len(images))
}
if images[1].Get("inlineData.data").String() != "NEW" {
t.Fatalf("appended sibling data = %q, want NEW", images[1].Get("inlineData.data").String())
}
},
},
{
name: "interleaved siblings attach to nearest response",
modelCalls: `{"functionCall":{"name":"read","id":"call_a"}},{"functionCall":{"name":"read","id":"call_b"}}`,
parts: `{"functionResponse":{"name":"read","response":{"result":"A"},"id":"call_a"}},` +
`{"inline_data":{"mime_type":"image/png","data":"AAA"}},` +
`{"functionResponse":{"name":"read","response":{"result":"B"},"id":"call_b"}},` +
`{"inline_data":{"mime_type":"image/jpeg","data":"BBB"}}`,
want: []wantImage{
{id: "call_a", mime: "image/png", data: "AAA"},
{id: "call_b", mime: "image/jpeg", data: "BBB"},
},
extraChecks: func(t *testing.T, gotByID map[string][]gjson.Result) {
if len(gotByID["call_a"]) != 1 || len(gotByID["call_b"]) != 1 {
t.Fatalf("nearest attribution failed: A=%d B=%d", len(gotByID["call_a"]), len(gotByID["call_b"]))
}
},
},
{
name: "leading sibling attaches to first response",
modelCalls: `{"functionCall":{"name":"read","id":"call_a"}},{"functionCall":{"name":"read","id":"call_b"}}`,
parts: `{"inline_data":{"mime_type":"image/png","data":"LEAD"}},` +
`{"functionResponse":{"name":"read","response":{"result":"A"},"id":"call_a"}},` +
`{"functionResponse":{"name":"read","response":{"result":"B"},"id":"call_b"}}`,
want: []wantImage{
{id: "call_a", mime: "image/png", data: "LEAD"},
},
extraChecks: func(t *testing.T, gotByID map[string][]gjson.Result) {
if len(gotByID["call_b"]) != 0 {
t.Fatalf("leading image leaked onto call_b")
}
},
},
{
name: "missing mimeType defaults to image/png",
modelCalls: `{"functionCall":{"name":"read","id":"call_1"}}`,
parts: `{"functionResponse":{"name":"read","response":{"result":"ok"},"id":"call_1"}},` +
`{"inlineData":{"data":"QUJD"}}`,
want: []wantImage{{id: "call_1", mime: "image/png", data: "QUJD"}},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := `{"request":{"contents":[` +
`{"role":"model","parts":[` + tt.modelCalls + `]},` +
`{"role":"user","parts":[` + tt.parts + `]}` +
`]}}`
result, err := fixCLIToolResponse([]byte(input))
if err != nil {
t.Fatalf("fixCLIToolResponse failed: %v", err)
}
contents := gjson.GetBytes(result, "request.contents").Array()
if len(contents) != 2 {
t.Fatalf("contents = %d, want 2. Output: %s", len(contents), result)
}
funcParts := contents[1].Get("parts").Array()
gotByID := map[string][]gjson.Result{}
for _, part := range funcParts {
fr := part.Get("functionResponse")
gotByID[fr.Get("id").String()] = fr.Get("parts").Array()
}
for _, want := range tt.want {
images := gotByID[want.id]
found := false
for _, img := range images {
if img.Get("inlineData.data").String() == want.data && img.Get("inlineData.mimeType").String() == want.mime {
found = true
break
}
}
if !found {
t.Fatalf("id=%s missing inlineData mime=%s data=%s. Output: %s", want.id, want.mime, want.data, result)
}
}
if tt.extraChecks != nil {
tt.extraChecks(t, gotByID)
}
})
}
}

func TestConvertGeminiRequestToAntigravity_PreservesSiblingToolImageOnUserRole(t *testing.T) {
input := []byte(`{
"contents": [
{"role":"user","parts":[{"text":"read file"}]},
{"role":"model","parts":[{"functionCall":{"name":"read","args":{},"id":"call_1"}}]},
{"role":"user","parts":[
{"functionResponse":{"name":"read","response":{"result":"Read image file [image/png]"},"id":"call_1"}},
{"inline_data":{"mime_type":"image/png","data":"QUJD"}}
]}
]
}`)
out := ConvertGeminiRequestToAntigravity("gemini-3-flash", input, false)
contents := gjson.GetBytes(out, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("contents = %d, want 3. Output: %s", len(contents), out)
}
funcContent := contents[2]
if got := funcContent.Get("role").String(); got != "user" {
t.Fatalf("role = %q, want user after Antigravity normalization. Output: %s", got, out)
}
funcResp := funcContent.Get("parts.0.functionResponse")
if !funcResp.Exists() {
t.Fatalf("functionResponse missing. Output: %s", out)
}
if got := funcResp.Get("id").String(); got != "call_1" {
t.Fatalf("id = %q, want call_1", got)
}
if got := funcResp.Get("response.result").String(); got != "Read image file [image/png]" {
t.Fatalf("result = %q", got)
}
inlineData := funcResp.Get("parts.0.inlineData")
if !inlineData.Exists() {
t.Fatalf("functionResponse.parts.0.inlineData missing. Output: %s", out)
}
if got := inlineData.Get("mimeType").String(); got != "image/png" {
t.Fatalf("mimeType = %q, want image/png", got)
}
if got := inlineData.Get("data").String(); got != "QUJD" {
t.Fatalf("data = %q, want QUJD", got)
}
if funcContent.Get("parts.1.inline_data").Exists() || funcContent.Get("parts.1.inlineData").Exists() {
t.Fatalf("sibling inline data should be absorbed into functionResponse.parts. Output: %s", out)
}
}

func TestSanitizeAntigravityClaudeGeminiRequestSignatures_LargeNumberDoesNotHaltKeyScan(t *testing.T) {
// A part with numbers outside float64 range should not break token scanning
inputJSON := []byte(`{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,96 @@ func TestConvertOpenAIResponsesRequestToAntigravity_EmptyClaudeReasoningBeforeFu
}
}

func TestConvertOpenAIResponsesRequestToAntigravity_PreservesToolResultImage(t *testing.T) {
inputJSON := `{
"model": "gemini-3-flash",
"input": [
{"role": "user", "content": [{"type": "input_text", "text": "请帮我读取分析这张图片"}]},
{"type": "function_call", "id": "fc_read", "call_id": "call_read_1", "name": "read", "arguments": "{\"path\":\"/path/to/image.png\"}"},
{
"type": "function_call_output",
"call_id": "call_read_1",
"output": [
{"type": "input_text", "text": "Read image file [image/png]"},
{"type": "input_image", "detail": "auto", "image_url": "data:image/png;base64,QUJD"}
]
}
]
}`
out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
contents := gjson.GetBytes(out, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("expected 3 contents, got %d. Output: %s", len(contents), out)
}
funcContent := contents[2]
if got := funcContent.Get("role").String(); got != "user" {
t.Fatalf("role = %q, want user. Output: %s", got, out)
}
funcResp := funcContent.Get("parts.0.functionResponse")
if !funcResp.Exists() {
t.Fatalf("functionResponse should exist. Output: %s", out)
}
if got := funcResp.Get("id").String(); got != "call_read_1" {
t.Fatalf("id = %q, want call_read_1", got)
}
if got := funcResp.Get("name").String(); got != "read" {
t.Fatalf("name = %q, want read", got)
}
inlineData := funcResp.Get("parts.0.inlineData")
if !inlineData.Exists() {
t.Fatalf("expected functionResponse.parts.0.inlineData to exist, got: %s", out)
}
if got := inlineData.Get("mimeType").String(); got != "image/png" {
t.Errorf("expected mimeType image/png, got %q", got)
}
if got := inlineData.Get("data").String(); got != "QUJD" {
t.Errorf("expected data QUJD, got %q", got)
}
}

func TestConvertOpenAIResponsesRequestToAntigravity_AttachesParallelToolImagesToNearestResponse(t *testing.T) {
inputJSON := `{
"model": "gemini-3-flash",
"input": [
{"role": "user", "content": [{"type": "input_text", "text": "read both"}]},
{"type": "function_call", "id": "fc_a", "call_id": "call_a", "name": "read", "arguments": "{\"path\":\"/tmp/a.png\"}"},
{"type": "function_call", "id": "fc_b", "call_id": "call_b", "name": "read", "arguments": "{\"path\":\"/tmp/b.png\"}"},
{
"type": "function_call_output",
"call_id": "call_a",
"output": [
{"type": "input_text", "text": "file A"},
{"type": "input_image", "image_url": "data:image/png;base64,AAA"}
]
},
{
"type": "function_call_output",
"call_id": "call_b",
"output": [
{"type": "input_text", "text": "file B"},
{"type": "input_image", "image_url": "data:image/jpeg;base64,BBB"}
]
}
]
}`
out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
parts := gjson.GetBytes(out, "request.contents.2.parts").Array()
if len(parts) != 2 {
t.Fatalf("function parts = %d, want 2. Output: %s", len(parts), out)
}
got := map[string]string{}
for _, part := range parts {
fr := part.Get("functionResponse")
got[fr.Get("id").String()] = fr.Get("parts.0.inlineData.data").String()
}
if got["call_a"] != "AAA" {
t.Fatalf("call_a image = %q, want AAA. Output: %s", got["call_a"], out)
}
if got["call_b"] != "BBB" {
t.Fatalf("call_b image = %q, want BBB. Output: %s", got["call_b"], out)
}
}

func TestConvertOpenAIResponsesRequestToAntigravity_GeminiReasoningUsesNativeThoughtSignaturePlacement(t *testing.T) {
sig := "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
raw := []byte(`{"model":"gemini-3.5-flash","input":[{"type":"reasoning","encrypted_content":"gemini#` + sig + `","summary":[{"type":"summary_text","text":"reasoning summary"}]}]}`)
Expand Down
Loading
Loading