feat(translator): handle empty response content for Claude and adjust JSON structure

- Added conditional logic to append `content_block_start` for empty responses, ensuring correct event flow.
- Updated default `responseJSON` structure to use an empty array (`[]`) for the `content` field instead of `null`.

Fixed: #4431
This commit is contained in:
Luis Pater
2026-07-19 17:32:54 +08:00
parent b7299a3d86
commit cd98e9d74d
2 changed files with 74 additions and 2 deletions
@@ -115,7 +115,11 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR
if bytes.Equal(rawJSON, []byte("[DONE]")) {
output := make([]byte, 0, 256)
// Only send final events if we have actually output content
if params.HasFirstResponse && !params.HasContent {
output = translatorcommon.AppendSSEEventString(output, "content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, params.ResponseIndex), 3)
params.ResponseType = 1
params.HasContent = true
}
if params.HasContent {
appendFinalEvents(params, &output, true)
output = translatorcommon.AppendSSEEventString(output, "message_stop", `{"type":"message_stop"}`, 3)
@@ -450,7 +454,7 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or
}
}
responseJSON := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":null,"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
responseJSON := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
responseJSON, _ = sjson.SetBytes(responseJSON, "id", root.Get("response.responseId").String())
responseJSON, _ = sjson.SetBytes(responseJSON, "model", root.Get("response.modelVersion").String())
responseJSON, _ = sjson.SetBytes(responseJSON, "usage.input_tokens", promptTokens)
@@ -180,6 +180,74 @@ func TestConvertAntigravityResponseToClaudeStream_WebSearchMessageStartOutputTok
}
}
func TestConvertAntigravityResponseToClaudeNonStream_EmptyCandidateReturnsContentArray(t *testing.T) {
requestJSON := []byte(`{"model":"gemini-3-flash-agent"}`)
output := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3-flash-agent", requestJSON, requestJSON, testEmptyAntigravityResponse(), nil)
content := gjson.GetBytes(output, "content")
if !content.IsArray() || len(content.Array()) != 0 {
t.Fatalf("content = %s, want empty array: %s", content.Raw, output)
}
if got := gjson.GetBytes(output, "stop_reason").String(); got != "end_turn" {
t.Fatalf("stop_reason = %q, want end_turn: %s", got, output)
}
}
func TestConvertAntigravityResponseToClaudeStream_EmptyCandidateClosesMessage(t *testing.T) {
requestJSON := []byte(`{"model":"gemini-3-flash-agent"}`)
responseJSON := testEmptyAntigravityResponse()
var param any
output := bytes.Join(ConvertAntigravityResponseToClaude(context.Background(), "gemini-3-flash-agent", requestJSON, requestJSON, responseJSON, &param), nil)
output = append(output, bytes.Join(ConvertAntigravityResponseToClaude(context.Background(), "gemini-3-flash-agent", requestJSON, requestJSON, []byte("[DONE]"), &param), nil)...)
outputText := string(output)
lastIndex := -1
for _, eventName := range []string{"message_start", "content_block_start", "content_block_stop", "message_delta", "message_stop"} {
index := strings.Index(outputText, "event: "+eventName+"\n")
if index < 0 {
t.Fatalf("event %q not found in:\n%s", eventName, outputText)
}
if index <= lastIndex {
t.Fatalf("event %q is out of order in:\n%s", eventName, outputText)
}
lastIndex = index
}
contentBlockStart := sseDataForEvent(t, outputText, "content_block_start")
if got := gjson.Get(contentBlockStart, "content_block.type").String(); got != "text" {
t.Fatalf("empty content block type = %q, want text: %s", got, contentBlockStart)
}
if text := gjson.Get(contentBlockStart, "content_block.text"); !text.Exists() || text.String() != "" {
t.Fatalf("empty content block text = %s, want empty string: %s", text.Raw, contentBlockStart)
}
messageDelta := sseDataForEvent(t, outputText, "message_delta")
if got := gjson.Get(messageDelta, "delta.stop_reason").String(); got != "end_turn" {
t.Fatalf("stop_reason = %q, want end_turn: %s", got, messageDelta)
}
if got := gjson.Get(messageDelta, "usage.input_tokens").Int(); got != 64214 {
t.Fatalf("input_tokens = %d, want 64214: %s", got, messageDelta)
}
if got := gjson.Get(messageDelta, "usage.output_tokens").Int(); got != 0 {
t.Fatalf("output_tokens = %d, want 0: %s", got, messageDelta)
}
}
func testEmptyAntigravityResponse() []byte {
return []byte(`{
"response": {
"candidates": [{
"content": {"role": "model", "parts": [{"text": ""}]},
"finishReason": "STOP"
}],
"usageMetadata": {"promptTokenCount": 64214, "totalTokenCount": 64214},
"modelVersion": "gemini-3-flash-a",
"responseId": "eBNcat8X5evPsg_lhqyQAg"
}
}`)
}
func TestWebSearchResultsFromGrounding_DeduplicatesAndSkipsEmptyURLs(t *testing.T) {
groundingMetadata := gjson.Parse(`{
"groundingChunks": [