From 86b8f510c80a685213c97b99671834512574ef53 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 11 Aug 2026 08:22:54 +0000 Subject: [PATCH 1/4] fix(advisor): accepted silent Gemini reviews Allowed advisor streams to treat Google STOP responses without visible content as successful silence while preserving the default retry behavior for interactive agents. Added provider and advisor-path regressions covering retry counts, system instructions, and the advise declaration. Fixes #8223 --- packages/ai/CHANGELOG.md | 4 + .../ai/src/providers/google-gemini-cli.ts | 4 +- packages/ai/src/providers/google-shared.ts | 8 +- packages/ai/src/stream.ts | 1 + packages/ai/src/types.ts | 7 ++ .../test/google-empty-response-retry.test.ts | 38 +++++++++ packages/coding-agent/CHANGELOG.md | 4 + .../src/session/session-advisors.ts | 24 ++++-- .../test/issue-8223-repro.test.ts | 81 +++++++++++++++++++ 9 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 packages/coding-agent/test/issue-8223-repro.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 0e26ffbf0..58bfacbc0 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Allowed passive Google callers to accept empty or thinking-only `STOP` responses as successful silence instead of exhausting the provider's empty-response retry budget. ([#8223](https://github.com/can1357/oh-my-pi/issues/8223)) + ## [17.2.12] - 2026-08-08 ### Fixed diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index 8631e9900..64a733127 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -998,8 +998,8 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( } const streamed = await streamResponse(currentResponse); - if (output.stopReason !== "stop" || streamed) { - receivedContent = streamed; + if (output.stopReason !== "stop" || streamed || options?.acceptEmptyResponse === true) { + receivedContent = streamed || options?.acceptEmptyResponse === true; break; } diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index a05eec3f0..eef5a3d68 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -1031,7 +1031,13 @@ export function streamGoogleGenAI= MAX_EMPTY_STREAM_RETRIES) { throw new AIError.ProviderResponseError( `Google API returned an empty response (finishReason STOP with no content) after ${MAX_EMPTY_STREAM_RETRIES + 1} attempts`, diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 776de5a94..14195d738 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -1508,6 +1508,7 @@ function mapOptionsForApi( execHandlers: options?.execHandlers, fetch: options?.fetch, fallbacks: options?.fallbacks, + acceptEmptyResponse: options?.acceptEmptyResponse, ...simpleProviderOptions, }; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 7361ff772..601b39f7a 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -552,6 +552,13 @@ export interface StreamOptions { * Optional retry delay hook for tests and transports that need custom scheduling. */ providerRetryWait?: (delayMs: number, signal?: AbortSignal) => Promise; + /** + * Accept a Google `STOP` response with no visible text or tool call as a + * successful completion. Passive callers such as advisors use this because + * silence is a valid result; interactive agent turns retain empty-response + * retries by default. Ignored by non-Google providers. + */ + acceptEmptyResponse?: boolean; /** * Optional `fetch` implementation override. Providers route every HTTP * request — direct calls, SDK clients, and retry helpers — through this diff --git a/packages/ai/test/google-empty-response-retry.test.ts b/packages/ai/test/google-empty-response-retry.test.ts index 70a0e1d67..3e8e29d7f 100644 --- a/packages/ai/test/google-empty-response-retry.test.ts +++ b/packages/ai/test/google-empty-response-retry.test.ts @@ -134,6 +134,25 @@ describe("Google empty-response retry (public + Vertex path)", () => { expect(result.errorMessage).toContain("empty response"); }); + it("accepts an empty STOP when silence is a valid caller result", async () => { + let calls = 0; + const fetchMock: FetchImpl = async () => { + calls += 1; + return sse(genaiChunk("")); + }; + + const stream = streamGoogle(genaiModel, context, { + apiKey: "k", + fetch: fetchMock, + acceptEmptyResponse: true, + }); + const result = await stream.result(); + + expect(calls).toBe(1); + expect(result.stopReason).toBe("stop"); + expect(result.errorMessage).toBeUndefined(); + }); + it("filters out empty text parts at stream end but preserves terminal thought signatures", async () => { const chunks = [ { candidates: [{ content: { parts: [{ text: "Hello" }] } }] }, @@ -254,6 +273,25 @@ describe("Google empty-response retry (Cloud Code Assist path)", () => { void events; }); + it("accepts an empty STOP when silence is a valid caller result", async () => { + let calls = 0; + const fetchMock: FetchImpl = async () => { + calls += 1; + return sse(ccaChunk("")); + }; + + const stream = streamGoogleGeminiCli(cliModel, context, { + apiKey: JSON.stringify({ token: "token", projectId: "proj-123" }), + fetch: fetchMock, + acceptEmptyResponse: true, + }); + const result = await stream.result(); + + expect(calls).toBe(1); + expect(result.stopReason).toBe("stop"); + expect(result.errorMessage).toBeUndefined(); + }); + it("retries after discarding a planning leak and delivers one structured function call", async () => { let calls = 0; const fetchMock: FetchImpl = async () => { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a4d66e8d2..c6507d790 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Gemini advisors treating a valid silent review as an empty-response failure, repeatedly retrying the turn and eventually dropping the advisor backlog. ([#8223](https://github.com/can1357/oh-my-pi/issues/8223)) + ## [17.2.12] - 2026-08-08 ### Fixed diff --git a/packages/coding-agent/src/session/session-advisors.ts b/packages/coding-agent/src/session/session-advisors.ts index 6693a2c35..ed1100be2 100644 --- a/packages/coding-agent/src/session/session-advisors.ts +++ b/packages/coding-agent/src/session/session-advisors.ts @@ -785,14 +785,22 @@ export class SessionAdvisors { mcpResources: this.#advisorMcpResources, }); const baseAdvisorStreamFn = this.#advisorStreamFn ?? streamSimple; - const advisorStreamFn: StreamFn = (requestModel, context, options) => - baseAdvisorStreamFn( - requestModel, - context, - requestModel.api === "openai-codex-responses" - ? { ...options, codexSseMaxAttempts: ADVISOR_CODEX_SSE_MAX_ATTEMPTS } - : options, - ); + const advisorStreamFn: StreamFn = (requestModel, context, options) => { + if (requestModel.api === "openai-codex-responses") { + return baseAdvisorStreamFn(requestModel, context, { + ...options, + codexSseMaxAttempts: ADVISOR_CODEX_SSE_MAX_ATTEMPTS, + }); + } + if ( + requestModel.api === "google-generative-ai" || + requestModel.api === "google-gemini-cli" || + requestModel.api === "google-vertex" + ) { + return baseAdvisorStreamFn(requestModel, context, { ...options, acceptEmptyResponse: true }); + } + return baseAdvisorStreamFn(requestModel, context, options); + }; const advisorAgent = new Agent({ initialState: { systemPrompt, diff --git a/packages/coding-agent/test/issue-8223-repro.test.ts b/packages/coding-agent/test/issue-8223-repro.test.ts new file mode 100644 index 000000000..dca3eb145 --- /dev/null +++ b/packages/coding-agent/test/issue-8223-repro.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from "bun:test"; +import * as path from "node:path"; +import { Agent, type StreamFn } from "@oh-my-pi/pi-agent-core"; +import { type FetchImpl, streamSimple } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +test("keeps Gemini 3.6 advisor context and accepts a silent review", async () => { + const temp = TempDir.createSync("@issue-8223-"); + const auth = await AuthStorage.create(path.join(temp.path(), "auth.db")); + auth.setRuntimeApiKey("google", "test-key"); + const registry = new ModelRegistry(auth); + const model = getBundledModel("google", "gemini-3.6-flash"); + if (!model) throw new Error("missing bundled model"); + const bodies: unknown[] = []; + const fetchMock: FetchImpl = async (_input, init) => { + bodies.push(JSON.parse(String(init?.body))); + const chunk = { + candidates: [ + { + content: { role: "model", parts: [{ thought: true, text: "Analyzing only" }] }, + finishReason: "STOP", + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 5, + thoughtsTokenCount: 5, + totalTokenCount: 15, + }, + }; + return new Response(`data: ${JSON.stringify(chunk)}\n\n`, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }; + const advisorStreamFn: StreamFn = (requestModel, context, options) => + streamSimple(requestModel, context, { ...options, fetch: fetchMock }); + const agent = new Agent({ initialState: { model, systemPrompt: ["Primary"], tools: [] } }); + const session = new AgentSession({ + agent, + sessionManager: SessionManager.create(temp.path(), temp.path()), + settings: Settings.isolated({ "compaction.enabled": false }), + modelRegistry: registry, + advisorTools: [], + advisorStreamFn, + }); + try { + session.settings.setModelRole("advisor", "google/gemini-3.6-flash"); + expect(session.setAdvisorEnabled(true)).toBe(true); + const advisor = session.getAdvisorAgent(); + if (!advisor) throw new Error("advisor did not start"); + await advisor.prompt("### Session update [in progress — more steps follow]\nImplement an order book."); + expect(advisor.state.error).toBeUndefined(); + expect(bodies).toHaveLength(1); + expect(bodies[0]).toMatchObject({ + systemInstruction: { + parts: [{ text: expect.stringContaining("You bring a different angle") }], + }, + tools: [ + { + functionDeclarations: [ + { + name: "advise", + description: expect.stringContaining("Send one concrete"), + }, + ], + }, + ], + }); + } finally { + await session.dispose(); + auth.close(); + await temp.remove(); + } +}); From c396e387269b401bd891ebd2e2afc3a27fd4ea5c Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 11 Aug 2026 08:32:09 +0000 Subject: [PATCH 2/4] fix(ai): preserved advisor planning leak retries Tracked whether Cloud Code Assist removed a planning-leak payload so advisor silence acceptance cannot consume it as a valid empty review. Updated the planning-leak regression to exercise acceptEmptyResponse and require the recovered function call. --- packages/ai/src/providers/google-gemini-cli.ts | 17 +++++++++++++---- .../ai/test/google-empty-response-retry.test.ts | 3 ++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index 64a733127..1de86521b 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -653,7 +653,9 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( sawFinishReason = false; }; - const streamResponse = async (activeResponse: Response): Promise => { + const streamResponse = async ( + activeResponse: Response, + ): Promise<{ meaningful: boolean; strippedPlanningLeak: boolean }> => { if (!activeResponse.body) { throw new AIError.ProviderResponseError("No response body", { provider: model.provider, @@ -673,6 +675,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( let isBuffering = false; let textBuffer = ""; let bufferedTextSignature: string | undefined; + let strippedPlanningLeak = false; const endCurrentBlock = (): void => { if (!currentBlock) return; @@ -815,6 +818,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( if (isBuffering) { const buffered = consumePlanningBuffer(textBuffer, toolNames); if (buffered.kind !== "incomplete") { + if (buffered.kind === "leak") strippedPlanningLeak = true; const visibleSignature = bufferedTextSignature; isBuffering = false; textBuffer = ""; @@ -895,6 +899,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( const buffered = consumePlanningBuffer(textBuffer, toolNames, true); if (buffered.kind !== "incomplete") { + if (buffered.kind === "leak") strippedPlanningLeak = true; feedVisibleText(buffered.visibleText, bufferedTextSignature); } bufferedTextSignature = undefined; @@ -905,7 +910,10 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( flushVisibleText(bufferedTextSignature); endCurrentBlock(); - return hasMeaningfulGoogleContent(output); + return { + meaningful: hasMeaningfulGoogleContent(output), + strippedPlanningLeak, + }; }; let receivedContent = false; @@ -998,8 +1006,9 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( } const streamed = await streamResponse(currentResponse); - if (output.stopReason !== "stop" || streamed || options?.acceptEmptyResponse === true) { - receivedContent = streamed || options?.acceptEmptyResponse === true; + const acceptedSilence = options?.acceptEmptyResponse === true && !streamed.strippedPlanningLeak; + if (output.stopReason !== "stop" || streamed.meaningful || acceptedSilence) { + receivedContent = streamed.meaningful || acceptedSilence; break; } diff --git a/packages/ai/test/google-empty-response-retry.test.ts b/packages/ai/test/google-empty-response-retry.test.ts index 3e8e29d7f..6a44d1012 100644 --- a/packages/ai/test/google-empty-response-retry.test.ts +++ b/packages/ai/test/google-empty-response-retry.test.ts @@ -292,7 +292,7 @@ describe("Google empty-response retry (Cloud Code Assist path)", () => { expect(result.errorMessage).toBeUndefined(); }); - it("retries after discarding a planning leak and delivers one structured function call", async () => { + it("retries a stripped planning leak when empty STOPs are accepted", async () => { let calls = 0; const fetchMock: FetchImpl = async () => { calls += 1; @@ -318,6 +318,7 @@ describe("Google empty-response retry (Cloud Code Assist path)", () => { const stream = streamGoogleGeminiCli(cliModel, context, { apiKey: JSON.stringify({ token: "token", projectId: "proj-123" }), fetch: fetchMock, + acceptEmptyResponse: true, }); const { events, starts } = await drain(stream); const result = await stream.result(); From 0a64b6d965dbb91fc6802a4756f130d25828f595 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 11 Aug 2026 08:39:08 +0000 Subject: [PATCH 3/4] fix(ai): kept antigravity failover before advisor silence Gated empty-STOP silence acceptance on the last Cloud Code Assist endpoint so an earlier endpoint returning only empty streams still fails over instead of being recorded as a valid silent review. Added an Antigravity auto-mode regression covering failover exhaustion before silence. --- .../ai/src/providers/google-gemini-cli.ts | 7 ++++- .../test/google-empty-response-retry.test.ts | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index 1de86521b..249e30bde 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -1006,7 +1006,12 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( } const streamed = await streamResponse(currentResponse); - const acceptedSilence = options?.acceptEmptyResponse === true && !streamed.strippedPlanningLeak; + // Only accept an empty STOP as valid silence once every fallback + // endpoint is exhausted: an earlier endpoint returning empty + // successful streams must still fail over (Antigravity auto mode) + // rather than be recorded as a real silent review. + const acceptedSilence = + options?.acceptEmptyResponse === true && !streamed.strippedPlanningLeak && isLastEndpoint; if (output.stopReason !== "stop" || streamed.meaningful || acceptedSilence) { receivedContent = streamed.meaningful || acceptedSilence; break; diff --git a/packages/ai/test/google-empty-response-retry.test.ts b/packages/ai/test/google-empty-response-retry.test.ts index 6a44d1012..03f1f37d0 100644 --- a/packages/ai/test/google-empty-response-retry.test.ts +++ b/packages/ai/test/google-empty-response-retry.test.ts @@ -364,6 +364,34 @@ describe("Google empty-response retry (Cloud Code Assist path)", () => { expect(textOf(result)).toBe("Recovered."); }); + it("exhausts Antigravity auto failover before accepting silence", async () => { + const requestedEndpoints: string[] = []; + const fetchMock: FetchImpl = async input => { + const endpoint = endpointFromInput(input); + requestedEndpoints.push(endpoint); + return withResponseUrl(sse(ccaChunk("")), 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 still burns its empty-response budget and fails over; only the + // last (sandbox) endpoint records the empty STOP as valid 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 }, From 433ba7df1f4d75772317efc445daa58031f273ee Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 11 Aug 2026 08:46:35 +0000 Subject: [PATCH 4/4] fix(ai): forwarded advisor silence flag over pi-native Added acceptEmptyResponse to the pi-native gateway option allow-list so a Google advisor on the auth-gateway transport still accepts a silent STOP server-side instead of exhausting the empty-response retries. Added a pi-native parseRequest regression asserting the option survives the wire. --- packages/ai/src/providers/pi-native-server.ts | 1 + packages/ai/test/auth-gateway-pi-native.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/packages/ai/src/providers/pi-native-server.ts b/packages/ai/src/providers/pi-native-server.ts index b8eddd2c7..7fc3878af 100644 --- a/packages/ai/src/providers/pi-native-server.ts +++ b/packages/ai/src/providers/pi-native-server.ts @@ -78,6 +78,7 @@ const ALLOWED_OPTION_KEYS: ReadonlySet = new Set([ "preferWebsockets", "openrouterVariant", "loopGuard", + "acceptEmptyResponse", ] as const satisfies readonly (keyof SimpleStreamOptions)[]); // --------------------------------------------------------------------------- diff --git a/packages/ai/test/auth-gateway-pi-native.test.ts b/packages/ai/test/auth-gateway-pi-native.test.ts index 330fc0b71..df2439d19 100644 --- a/packages/ai/test/auth-gateway-pi-native.test.ts +++ b/packages/ai/test/auth-gateway-pi-native.test.ts @@ -145,6 +145,15 @@ describe("pi-native parseRequest", () => { expect(parsed.options.loopGuard).toEqual({ enabled: false }); }); + it("forwards acceptEmptyResponse so a passive Google advisor can accept silence server-side", () => { + const parsed = parseRequest({ + modelId: "google/gemini-3.6-flash", + context: baseContext, + options: { acceptEmptyResponse: true }, + }); + expect(parsed.options.acceptEmptyResponse).toBe(true); + }); + it("forwards an explicit statefulResponses disablement to the native stream", () => { const parsed = parseRequest({ modelId: "openai/gpt-5",