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:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user