diff --git a/backend/internal/service/antigravity_gateway_service_test.go b/backend/internal/service/antigravity_gateway_service_test.go index dfca59619c..79ce35f596 100644 --- a/backend/internal/service/antigravity_gateway_service_test.go +++ b/backend/internal/service/antigravity_gateway_service_test.go @@ -1387,6 +1387,49 @@ func TestHandleClaudeStreamingResponse_NormalComplete(t *testing.T) { // TestHandleGeminiStreamingResponse_ThoughtsTokenCount // 验证:Gemini 流式转发时 thoughtsTokenCount 被计入 OutputTokens +// 回归:上游事件之间的空分隔行不能再透传,否则下游看到的是 "data: ...\n\n\n"。 +// google-genai Go SDK 按 "\n\n" 切事件,多出的 "\n" 会让第二个事件的前缀变成 +// "\ndata" 而报 invalid stream chunk(Antigravity CLI 每条流都在第二个事件中断)。 +func TestHandleGeminiStreamingResponse_EventSeparatorIsExactlyOneBlankLine(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newAntigravityTestService(&config.Config{ + Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}, + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + + pr, pw := io.Pipe() + resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}} + + first := `{"candidates":[{"content":{"role":"model","parts":[{"text":"Hello"}]}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":3}}` + second := `{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"sig","text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":3,"thoughtsTokenCount":5}}` + + go func() { + defer func() { _ = pw.Close() }() + // 上游原样:每个 data 行后跟一个空分隔行(第二个事件用 CRLF,覆盖两种换行) + fmt.Fprintf(pw, "data: %s\n\n", first) + fmt.Fprintf(pw, "data: %s\r\n\r\n", second) + }() + + result, err := svc.handleGeminiStreamingResponse(c, resp, time.Now()) + _ = pr.Close() + + require.NoError(t, err) + require.NotNil(t, result) + + body := rec.Body.String() + require.Equal(t, "data: "+first+"\n\ndata: "+second+"\n\n", body) + require.NotContains(t, body, "\n\n\n", "events must be separated by exactly one blank line") + + // 模拟 google-genai 的切帧方式:按 "\n\n" 切,每个非空 token 都必须以 "data" 为前缀 + for _, token := range strings.Split(strings.TrimSuffix(body, "\n\n"), "\n\n") { + prefix, _, _ := strings.Cut(token, ":") + require.Equal(t, "data", prefix, "token %q would be rejected by a \\n\\n-delimited SSE parser", token) + } +} + func TestHandleGeminiStreamingResponse_ThoughtsTokenCount(t *testing.T) { gin.SetMode(gin.TestMode) svc := newAntigravityTestService(&config.Config{ diff --git a/backend/internal/service/antigravity_gateway_streaming.go b/backend/internal/service/antigravity_gateway_streaming.go index 002543f9ec..5922554207 100644 --- a/backend/internal/service/antigravity_gateway_streaming.go +++ b/backend/internal/service/antigravity_gateway_streaming.go @@ -288,6 +288,14 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context continue } + // 上游每个 data 事件后面跟一个空行作为事件分隔。上面已经把 data 行写成 + // "data: ...\n\n",若再把这个空行透传出去,事件之间就会变成 "\n\n\n"。 + // google-genai 的 Go SDK(Antigravity CLI 在用)按 "\n\n" 切事件,多出的 + // "\n" 会粘到下一个事件开头,前缀变成 "\ndata" 而被判成 invalid stream chunk。 + if trimmed == "" { + continue + } + cw.Fprintf("%s\n", line) case <-intervalCh: