diff --git a/internal/runtime/executor/antigravity_reasoning_replay.go b/internal/runtime/executor/antigravity_reasoning_replay.go index 79d2ccc1..8276eadb 100644 --- a/internal/runtime/executor/antigravity_reasoning_replay.go +++ b/internal/runtime/executor/antigravity_reasoning_replay.go @@ -368,10 +368,41 @@ func insertAntigravityModelFunctionCallBeforeContent(payload []byte, beforeIndex func antigravityRequestHasThoughtSignatureAt(payload []byte, itemResult gjson.Result) bool { ci := int(itemResult.Get("contentIndex").Int()) pi := int(itemResult.Get("partIndex").Int()) - path := fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", ci, pi) + partPath, ok := antigravityExistingReplayPartPath(payload, ci, pi) + if !ok { + return false + } + path := partPath + ".thoughtSignature" return strings.TrimSpace(gjson.GetBytes(payload, path).String()) != "" } +func antigravityExistingReplayPartPath(payload []byte, contentIndex int, partIndex int) (string, bool) { + if contentIndex < 0 || partIndex < 0 { + return "", false + } + partsPath := fmt.Sprintf("request.contents.%d.parts", contentIndex) + parts := gjson.GetBytes(payload, partsPath) + if !parts.IsArray() { + return "", false + } + arr := parts.Array() + if partIndex >= len(arr) || arr[partIndex].Type == gjson.Null { + return "", false + } + return fmt.Sprintf("%s.%d", partsPath, partIndex), true +} + +func antigravityReplayPartWritePath(payload []byte, contentIndex int, partIndex int) string { + if path, ok := antigravityExistingReplayPartPath(payload, contentIndex, partIndex); ok { + return path + } + partsPath := fmt.Sprintf("request.contents.%d.parts", contentIndex) + if gjson.GetBytes(payload, partsPath).IsArray() { + return partsPath + ".-1" + } + return partsPath + ".0" +} + func insertAntigravityReasoningReplayItems(payload []byte, items [][]byte) ([]byte, bool) { out := payload changed := false @@ -385,10 +416,14 @@ func insertAntigravityReasoningReplayItems(payload []byte, items [][]byte) ([]by if sig == "" { continue } - path := fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", ci, pi) - if strings.TrimSpace(gjson.GetBytes(out, path).String()) != "" { - continue + partPath, exists := antigravityExistingReplayPartPath(out, ci, pi) + if exists { + path := partPath + ".thoughtSignature" + if strings.TrimSpace(gjson.GetBytes(out, path).String()) != "" { + continue + } } + path := antigravityReplayPartWritePath(out, ci, pi) + ".thoughtSignature" updated, err := sjson.SetBytes(out, path, sig) if err != nil { continue @@ -433,16 +468,41 @@ func mergeAntigravityFunctionCallPartReplay(payload []byte, itemResult gjson.Res ci := antigravityReasoningReplayResolveContentIndex(payload, int(itemResult.Get("contentIndex").Int())) pi := int(itemResult.Get("partIndex").Int()) - pathSig := fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", ci, pi) out := payload changed := false + + partPath, exists := antigravityExistingReplayPartPath(out, ci, pi) + if !exists { + fc := map[string]any{"name": name} + if callID != "" { + fc["id"] = callID + } + if args.Type == gjson.String { + fc["args"] = args.String() + } else { + var parsed any + if json.Unmarshal([]byte(args.Raw), &parsed) == nil { + fc["args"] = parsed + } + } + part := map[string]any{"functionCall": fc} + if sig != "" { + part["thoughtSignature"] = sig + } + if updated, err := sjson.SetBytes(out, antigravityReplayPartWritePath(out, ci, pi), part); err == nil { + return updated, true + } + return payload, false + } + + pathSig := partPath + ".thoughtSignature" if sig != "" && strings.TrimSpace(gjson.GetBytes(out, pathSig).String()) == "" { if updated, err := sjson.SetBytes(out, pathSig, sig); err == nil { out = updated changed = true } } - pathFC := fmt.Sprintf("request.contents.%d.parts.%d.functionCall", ci, pi) + pathFC := partPath + ".functionCall" if !gjson.GetBytes(out, pathFC).Exists() { fc := map[string]any{"name": name} if callID != "" { diff --git a/internal/runtime/executor/antigravity_reasoning_replay_test.go b/internal/runtime/executor/antigravity_reasoning_replay_test.go index cc53da27..b11f4f97 100644 --- a/internal/runtime/executor/antigravity_reasoning_replay_test.go +++ b/internal/runtime/executor/antigravity_reasoning_replay_test.go @@ -114,6 +114,36 @@ func TestMergeAntigravityFunctionCallPartReplayMergesSignatureIntoExistingFuncti } } +func TestPrepareAntigravityGeminiReasoningReplayPayloadAppendsStaleThoughtSignatureWithoutNullParts(t *testing.T) { + internalcache.ClearAntigravityReasoningReplayCache() + t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) + + item := []byte(`{"type":"thought_signature","contentIndex":8,"partIndex":3,"thoughtSignature":"sig-text"}`) + internalcache.CacheAntigravityReasoningReplayItems("gemini-3-flash-agent", "session:sess-stale-text", [][]byte{item}) + + payload := []byte(`{"sessionId":"sess-stale-text","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]},{"role":"model","parts":[{"text":"visible answer"}]},{"role":"user","parts":[{"text":"next"}]}]}}`) + out, _, err := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3-flash-agent", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload) + if err != nil { + t.Fatal(err) + } + + parts := gjson.GetBytes(out, "request.contents.1.parts").Array() + if len(parts) != 2 { + t.Fatalf("parts length = %d, want 2; body=%s", len(parts), out) + } + for i, part := range parts { + if part.Type == gjson.Null { + t.Fatalf("parts.%d is null; body=%s", i, out) + } + } + if got := parts[0].Get("text").String(); got != "visible answer" { + t.Fatalf("text part = %q, want visible answer; body=%s", got, out) + } + if got := parts[1].Get("thoughtSignature").String(); got != "sig-text" { + t.Fatalf("thoughtSignature = %q, want sig-text; body=%s", got, out) + } +} + func TestAntigravityReasoningReplayScopeUsesStableSessionWithoutSessionId(t *testing.T) { payload := []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"stable-user-text"}]}]}}`) scope := antigravityReasoningReplayScopeFromPayload("gemini-3-flash-agent", payload) diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go index 65c9790c..00b5f8c0 100644 --- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go @@ -183,8 +183,8 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ text := item.Get("text").String() if text != "" { node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".text", text) + p++ } - p++ case "image_url": imageURL := item.Get("image_url.url").String() if len(imageURL) > 5 { @@ -257,8 +257,8 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _ text := item.Get("text").String() if text != "" { node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".text", text) + p++ } - p++ case "image_url": // If the assistant returned an inline data URL, preserve it for history fidelity. imageURL := item.Get("image_url.url").String() diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go new file mode 100644 index 00000000..9a06cbc6 --- /dev/null +++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go @@ -0,0 +1,56 @@ +package chat_completions + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertOpenAIRequestToAntigravitySkipsEmptyTextPartsWithoutNulls(t *testing.T) { + inputJSON := `{ + "model": "gemini-3-flash", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": ""}, + {"type": "input_audio", "input_audio": {"data": "SUQzBA==", "format": "mp3"}} + ] + }, + { + "role": "assistant", + "content": [{"type": "text", "text": ""}], + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": {"name": "read_file", "arguments": "{\"path\":\"a.txt\"}"} + }] + }, + {"role": "tool", "tool_call_id": "call_1", "content": "{\"output\":\"ok\"}"}, + {"role": "user", "content": "done"} + ] + }` + + result := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false) + userParts := gjson.GetBytes(result, "request.contents.0.parts").Array() + if len(userParts) != 1 { + t.Fatalf("user parts length = %d, want 1. Output: %s", len(userParts), result) + } + if userParts[0].Type == gjson.Null { + t.Fatalf("user parts.0 is null. Output: %s", result) + } + if got := userParts[0].Get("inlineData.mime_type").String(); got != "audio/mpeg" { + t.Fatalf("audio mime_type = %q, want audio/mpeg. Output: %s", got, result) + } + + assistantParts := gjson.GetBytes(result, "request.contents.1.parts").Array() + if len(assistantParts) != 1 { + t.Fatalf("assistant parts length = %d, want 1. Output: %s", len(assistantParts), result) + } + if assistantParts[0].Type == gjson.Null { + t.Fatalf("assistant parts.0 is null. Output: %s", result) + } + if !assistantParts[0].Get("functionCall").Exists() { + t.Fatalf("functionCall missing. Output: %s", result) + } +} diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go index 28086f52..bfe2ca13 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go @@ -181,8 +181,8 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) text := item.Get("text").String() if text != "" { node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".text", text) + p++ } - p++ case "image_url": imageURL := item.Get("image_url.url").String() if len(imageURL) > 5 { @@ -249,8 +249,8 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) text := item.Get("text").String() if text != "" { node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".text", text) + p++ } - p++ case "image_url": // If the assistant returned an inline data URL, preserve it for history fidelity. imageURL := item.Get("image_url.url").String() diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go index ad79869c..f29221f2 100644 --- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go +++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go @@ -91,6 +91,55 @@ func TestConvertOpenAIRequestToGeminiPreservesVideoURL(t *testing.T) { } } +func TestConvertOpenAIRequestToGeminiSkipsEmptyTextPartsWithoutNulls(t *testing.T) { + inputJSON := `{ + "model": "gemini-3-flash", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": ""}, + {"type": "input_audio", "input_audio": {"data": "SUQzBA==", "format": "mp3"}} + ] + }, + { + "role": "assistant", + "content": [{"type": "text", "text": ""}], + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": {"name": "read_file", "arguments": "{\"path\":\"a.txt\"}"} + }] + }, + {"role": "tool", "tool_call_id": "call_1", "content": "{\"output\":\"ok\"}"}, + {"role": "user", "content": "done"} + ] + }` + + result := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), false) + userParts := gjson.GetBytes(result, "contents.0.parts").Array() + if len(userParts) != 1 { + t.Fatalf("user parts length = %d, want 1. Output: %s", len(userParts), result) + } + if userParts[0].Type == gjson.Null { + t.Fatalf("user parts.0 is null. Output: %s", result) + } + if got := userParts[0].Get("inlineData.mime_type").String(); got != "audio/mpeg" { + t.Fatalf("audio mime_type = %q, want audio/mpeg. Output: %s", got, result) + } + + assistantParts := gjson.GetBytes(result, "contents.1.parts").Array() + if len(assistantParts) != 1 { + t.Fatalf("assistant parts length = %d, want 1. Output: %s", len(assistantParts), result) + } + if assistantParts[0].Type == gjson.Null { + t.Fatalf("assistant parts.0 is null. Output: %s", result) + } + if !assistantParts[0].Get("functionCall").Exists() { + t.Fatalf("functionCall missing. Output: %s", result) + } +} + func TestConvertOpenAIRequestToGeminiCleansToolSchemaRequiredFields(t *testing.T) { inputJSON := `{ "model": "gemini-2.0-flash",