Skip to content

[Bug] Antigravity clean EOF manufactures successful final text completion without upstream finish (v8.0.23) #6533

Closed as not planned

Description

@shengyy

This is separate from #6532 (tool_choice: none). It concerns response completion semantics. The behavior below is reproduced entirely against a local fake SSE server; it is not a claim that this caused the earlier reported production incident.

On v8.0.23, the Antigravity executor turns a clean HTTP EOF after partial text into a successful OpenAI Chat Completions terminal chunk, even though the upstream response contains neither finishReason nor a terminal event. A client then cannot distinguish this response from a completed answer.

This follows the separate completion-semantics discussion explicitly left out of #6357 / #6359. I also checked #5226: its compatibility case involves missing finish reasons on completed thinking/tool turns. The request here is to define and enforce a supported final-text completion contract without breaking that documented compatibility case.

Reproduction

Use AntigravityExecutor.ExecuteStream with OpenAI input/output formats and a local httptest.Server. Give it a synthetic, unexpired token and set auth.Attributes["base_url"] to that server. The server returns HTTP 200, Content-Type: text/event-stream, writes exactly this frame, and ends the HTTP response normally:

data: {"response":{"candidates":[{"content":{"parts":[{"text":"The answer is "}]}}],"modelVersion":"gemini-3.7-flash"}}

The upstream does not send [DONE], finishReason, or usage. The actual executor emits the partial content followed by:

{"id":"","object":"chat.completion.chunk","created":0,"model":"gemini-3.7-flash","choices":[{"index":0,"delta":{},"finish_reason":"stop","native_finish_reason":"stop"}]}

Collected result: text="The answer is ", finish_reason="stop", stream error nil.

Four local executor cases were run on v8.0.23:

Fake upstream behavior Actual downstream completion Stream error
Partial text, no finish/usage, clean HTTP EOF stop None
Partial text and usage, no finish, clean HTTP EOF stop None
Complete text with finishReason: STOP stop None
Same partial text, connection cut before the HTTP body ends No finish reason unexpected EOF

The test transport allowed only the localhost server origin. No real account or provider request is needed.

Source evidence

The executor file is byte-identical in v8.0.21 and v8.0.23. The translator difference between those releases changes token usage accounting only; the completion logic above is unchanged.

Expected behavior and proposed direction

Clean transport EOF should not manufacture successful text completion in the absence of a supported upstream terminal signal. Emit an explicit incomplete-stream error, or otherwise preserve the absence of completion so a strict client can reject the partial result. A recorded upstream finish reason can still be flushed when usage arrives separately or is omitted.

The terminal decision belongs in the upstream stream/translation owner. Keep authoritative upstream completion distinct from downstream delivery completion and generic SawResponse; do not rely on text inspection by clients. Usage metadata alone should not prove text completion.

There is an existing fixture described as a real tool-call response with usage but no finish reason. If that is an intentional Antigravity contract, preserve and document that specific contract while fixing partial final-text completion. Do not indiscriminately reject valid tool calls, or treat every non-empty upstream response as a successful final text answer.

Suggested regression coverage: partial text with clean EOF (with and without usage), normal finish with split/missing usage, empty body, transport read error, user cancellation, and the supported tool-call terminal contract. No client-side answer guessing or extra completion request is necessary.

Complete offline executor reproduction

Save as internal/runtime/executor/eof_public_repro_test.go in tag v8.0.23, then run:

go test ./internal/runtime/executor -run TestPublicReproCleanEOFWithoutUpstreamFinish -count=1 -v

This test characterizes the current behavior; a fixed implementation should change the two partial-text expectations.

package executor

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
	cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
	cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
	sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
	"github.com/tidwall/gjson"
)

type eofReproLocalTransport struct {
	baseURL string
	base    http.RoundTripper
}

func (r eofReproLocalTransport) RoundTrip(request *http.Request) (*http.Response, error) {
	if request.URL.Scheme+"://"+request.URL.Host != r.baseURL {
		return nil, fmt.Errorf("repro forbids non-local destination")
	}
	return r.base.RoundTrip(request)
}

// This characterization test reproduces the current false-success behavior;
// it is not a proposed regression expectation for the fixed implementation.
func TestPublicReproCleanEOFWithoutUpstreamFinish(t *testing.T) {
	for _, tc := range []struct {
		name      string
		body      string
		abrupt    bool
		wantStop  bool
		wantError bool
	}{
		{
			name:     "partial_text_then_clean_eof_no_finish_no_usage",
			body:     `data: {"response":{"candidates":[{"content":{"parts":[{"text":"The answer is "}]}}],"modelVersion":"gemini-3.7-flash"}}` + "\n\n",
			wantStop: true,
		},
		{
			name:     "partial_text_then_clean_eof_with_usage_no_finish",
			body:     `data: {"response":{"candidates":[{"content":{"parts":[{"text":"The answer is "}]}}],"modelVersion":"gemini-3.7-flash","usageMetadata":{"promptTokenCount":4,"candidatesTokenCount":3,"totalTokenCount":7}}}` + "\n\n",
			wantStop: true,
		},
		{
			name:     "normal_upstream_stop",
			body:     `data: {"response":{"candidates":[{"content":{"parts":[{"text":"The answer is 42."}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.7-flash","usageMetadata":{"promptTokenCount":4,"candidatesTokenCount":5,"totalTokenCount":9}}}` + "\n\n",
			wantStop: true,
		},
		{
			name:      "partial_text_then_transport_error",
			body:      `data: {"response":{"candidates":[{"content":{"parts":[{"text":"The answer is "}]}}],"modelVersion":"gemini-3.7-flash"}}` + "\n\n",
			abrupt:    true,
			wantError: true,
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
				w.Header().Set("Content-Type", "text/event-stream")
				_, _ = io.WriteString(w, tc.body)
				w.(http.Flusher).Flush()
				if tc.abrupt {
					conn, _, err := w.(http.Hijacker).Hijack()
					if err == nil {
						_ = conn.Close()
					}
				}
			}))
			defer server.Close()
			ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
			defer cancel()
			ctx = context.WithValue(ctx, "cliproxy.roundtripper", eofReproLocalTransport{
				baseURL: server.URL, base: &http.Transport{Proxy: nil},
			})
			executor := NewAntigravityExecutor(&config.Config{})
			result, err := executor.ExecuteStream(ctx, &cliproxyauth.Auth{
				Metadata: map[string]any{
					"access_token": "synthetic-local-test-token",
					"expired":      time.Now().Add(24 * time.Hour).Format(time.RFC3339),
					"project_id":   "synthetic-local-test-project",
				},
				Attributes: map[string]string{"base_url": server.URL},
			}, cliproxyexecutor.Request{
				Model:   "gemini-3.7-flash",
				Payload: []byte(`{"model":"gemini-3.7-flash","messages":[{"role":"user","content":"What is the answer?"}],"stream":true}`),
			}, cliproxyexecutor.Options{
				SourceFormat:   sdktranslator.FormatOpenAI,
				ResponseFormat: sdktranslator.FormatOpenAI,
				Stream:         true,
			})
			if err != nil {
				t.Fatal(err)
			}
			var text strings.Builder
			var finishReason string
			var streamError error
			for chunk := range result.Chunks {
				if chunk.Err != nil {
					streamError = chunk.Err
					continue
				}
				text.WriteString(gjson.GetBytes(chunk.Payload, "choices.0.delta.content").String())
				if value := gjson.GetBytes(chunk.Payload, "choices.0.finish_reason").String(); value != "" {
					finishReason = value
				}
				t.Logf("downstream=%s", chunk.Payload)
			}
			t.Logf("RESULT text=%q finish_reason=%q stream_error=%v", text.String(), finishReason, streamError)
			if (finishReason == "stop") != tc.wantStop {
				t.Fatalf("finish_reason=%q", finishReason)
			}
			if (streamError != nil) != tc.wantError {
				t.Fatalf("stream_error=%v", streamError)
			}
		})
	}
}

Activity

  1. luispater commented on Oct 12, 2026

    @luispater
    Collaborator

    Thanks for the detailed report and reproduction test.

    The current clean-EOF synthesis behavior was intentionally introduced to maintain compatibility with upstream Antigravity streams that close cleanly without emitting an explicit finishReason. We currently have no plans to change this logic.

    Closing this issue as not planned.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions