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
This commit is contained in:
roboomp
2026-08-13 21:31:11 +00:00
committed by can1357
parent 58f319912e
commit e1d02c3b58
3 changed files with 94 additions and 1 deletions
+1
View File
@@ -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
@@ -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") &&
@@ -27,6 +27,24 @@ function ccaChunk(text: string): Record<string, unknown> {
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<string, unknown> {
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<AssistantMessageEvent>) {
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 },