From e1d02c3b588fc3d99c14bc526e0954b2bcdda20e Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 13 Aug 2026 21:31:11 +0000 Subject: [PATCH] fix(ai): fail over antigravity thinking-only STOP to sandbox A thinking-only STOP on the daily endpoint flipped `started` via `ensureStarted`, and the endpoint-failover catch guard gated on `!started`, so Antigravity auto mode never requested the sandbox endpoint and recorded a false empty-response failure for silent Advisor turns. Guard failover on a new `emittedVisibleContent` flag (set only when a visible text delta or tool call is pushed) instead of `started`, so hidden thinking no longer blocks the fallback while genuine partial output still does. Fixes #8480 --- packages/ai/CHANGELOG.md | 1 + .../ai/src/providers/google-gemini-cli.ts | 10 ++- .../test/google-empty-response-retry.test.ts | 84 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index eafbc3ccb..521111a5c 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -11,6 +11,7 @@ - Removed the Antigravity identity-prompt injection (`ANTIGRAVITY_SYSTEM_INSTRUCTION` and `shouldInjectAntigravitySystemInstruction`): Cloud Code Assist accepts arbitrary system instructions on gemini-3.x and Claude routes (verified live), and the injected stub never matched the real client's system prompt anyway. User system prompts are now sent unmodified (still tagged `role: "user"`). +- Fixed Antigravity `auto` mode not failing over to the sandbox endpoint when the daily endpoint returned a thinking-only `STOP`, which caused Advisor turns to be falsely recorded as empty-response failures ([#8480](https://github.com/can1357/oh-my-pi/issues/8480)). ## [17.3.0] - 2026-08-13 diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index 9d830517a..befcd3dfd 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -622,6 +622,11 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( const isFlashLeakModel = model.id.includes("flash"); let started = false; + // Tracks whether *visible* content (text delta or tool call) has been + // pushed downstream. `started` alone is a poor failover guard because a + // hidden thought part also flips it (via `ensureStarted`); a thinking-only + // STOP must still fail over to the alternate Antigravity endpoint (#8480). + let emittedVisibleContent = false; let sawFinishReason = false; let lastResponseId: string | undefined; const ensureStarted = () => { @@ -700,6 +705,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( const emitVisibleText = (delta: string, thoughtSignature?: string): void => { if (!delta) return; + emittedVisibleContent = true; const block = startTextBlock(); block.text += delta; block.textSignature = retainThoughtSignature(block.textSignature, thoughtSignature); @@ -858,6 +864,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( }; output.content.push(toolCall); + emittedVisibleContent = true; ensureStarted(); pushToolCallEvents(toolCall, blockIndex(), output, stream); } @@ -931,6 +938,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( const isLastEndpoint = i === endpoints.length - 1; try { started = false; + emittedVisibleContent = false; resetOutput(); // Per attempt: arm a pre-response (TTFT) timer, cleared the instant @@ -1073,7 +1081,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( const status = extractHttpStatusFromError(error); if ( !isLastEndpoint && - !started && + !emittedVisibleContent && (AIError.isTransientStatus(status) || (status === undefined && !(error instanceof AIError.ProviderResponseError && error.kind === "output") && diff --git a/packages/ai/test/google-empty-response-retry.test.ts b/packages/ai/test/google-empty-response-retry.test.ts index 03f1f37d0..2ee1c3515 100644 --- a/packages/ai/test/google-empty-response-retry.test.ts +++ b/packages/ai/test/google-empty-response-retry.test.ts @@ -27,6 +27,24 @@ function ccaChunk(text: string): Record { return { response: genaiChunk(text) }; } +/** + * `{ response: { candidates } }` envelope carrying only a thinking part with `finishReason: STOP` — + * the intentional-silence Advisor case (#8480): no visible text and no tool call. + */ +function ccaThinkingOnlyChunk(thinking: string): Record { + return { + response: { + candidates: [{ content: { parts: [{ text: thinking, thought: true }] }, finishReason: "STOP" }], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 0, + thoughtsTokenCount: 5, + totalTokenCount: 15, + }, + }, + }; +} + async function drain(stream: AsyncIterable) { const events: AssistantMessageEvent[] = []; for await (const event of stream) events.push(event); @@ -392,6 +410,72 @@ describe("Google empty-response retry (Cloud Code Assist path)", () => { expect(result.errorMessage).toBeUndefined(); }); + it("fails over before accepting Advisor silence when daily returns a thinking-only STOP", async () => { + const requestedEndpoints: string[] = []; + const fetchMock: FetchImpl = async input => { + const endpoint = endpointFromInput(input); + requestedEndpoints.push(endpoint); + const response = + endpoint === ANTIGRAVITY_SANDBOX_ENDPOINT + ? sse(ccaChunk("Recovered.")) + : sse(ccaThinkingOnlyChunk("No concrete risk. I will stay silent.")); + return withResponseUrl(response, endpoint); + }; + + const stream = streamGoogleGeminiCli(antigravityModel, context, { + apiKey: JSON.stringify({ token: "token", projectId: "proj-123" }), + antigravityEndpointMode: "auto", + acceptEmptyResponse: true, + fetch: fetchMock, + }); + const result = await stream.result(); + + expect({ + requestedEndpoints, + stopReason: result.stopReason, + errorMessage: result.errorMessage, + text: textOf(result), + }).toEqual({ + requestedEndpoints: [ + ANTIGRAVITY_DAILY_ENDPOINT, + ANTIGRAVITY_DAILY_ENDPOINT, + ANTIGRAVITY_DAILY_ENDPOINT, + ANTIGRAVITY_SANDBOX_ENDPOINT, + ], + stopReason: "stop", + errorMessage: undefined, + text: "Recovered.", + }); + }); + + it("accepts thinking-only silence on the final endpoint when both endpoints stay silent", async () => { + const requestedEndpoints: string[] = []; + const fetchMock: FetchImpl = async input => { + const endpoint = endpointFromInput(input); + requestedEndpoints.push(endpoint); + return withResponseUrl(sse(ccaThinkingOnlyChunk("Nothing to add. Staying silent.")), endpoint); + }; + + const stream = streamGoogleGeminiCli(antigravityModel, context, { + apiKey: JSON.stringify({ token: "token", projectId: "proj-123" }), + antigravityEndpointMode: "auto", + acceptEmptyResponse: true, + fetch: fetchMock, + }); + const result = await stream.result(); + + // Daily burns its empty budget and fails over; the sandbox (final) endpoint + // records the thinking-only STOP as valid Advisor silence. + expect(requestedEndpoints).toEqual([ + ANTIGRAVITY_DAILY_ENDPOINT, + ANTIGRAVITY_DAILY_ENDPOINT, + ANTIGRAVITY_DAILY_ENDPOINT, + ANTIGRAVITY_SANDBOX_ENDPOINT, + ]); + expect(result.stopReason).toBe("stop"); + expect(result.errorMessage).toBeUndefined(); + }); + for (const { mode, endpoint } of [ { mode: "production", endpoint: ANTIGRAVITY_DAILY_ENDPOINT }, { mode: "sandbox", endpoint: ANTIGRAVITY_SANDBOX_ENDPOINT },