diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 348776bbe..b8ac93088 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -100,6 +100,9 @@ - Fixed Streamable HTTP MCP sessions being invalidated by opening the optional GET SSE stream before sending `notifications/initialized`, which prevented Figma Dev Mode MCP from connecting ([#8514](https://github.com/can1357/oh-my-pi/issues/8514)). - Fixed the `/hotkeys` table describing Ctrl+D (`app.exit`) as "Exit (when editor is empty)" when it actually exits unconditionally and saves the current prompt as a resumable draft ([#8530](https://github.com/can1357/oh-my-pi/issues/8530)). - Fixed Ctrl+G external editors failing to launch on Windows because Bun re-quoted the embedded `cmd.exe /c` command line ([#8544](https://github.com/can1357/oh-my-pi/issues/8544)). +### Fixed + +- Fixed the capped empty-stop failure always naming the context/`/shake images` hint even when the provider billed output tokens. A zero-block `stop` (no content blocks) with `usage.output > 0` means content was generated and dropped downstream (a filter/refusal flattened to `finish_reason: "stop"` by a proxy, or a lossy API translation), so the message now reports the billed output-token count and points at a provider-side filter/translation instead of a context problem, and logs `outputTokens` alongside the existing warning fields. Thinking-only stops keep a thinking block (and bill output for it), so they retain the context hint ([#8511](https://github.com/can1357/oh-my-pi/issues/8511)). ## [17.3.3] - 2026-08-14 diff --git a/packages/coding-agent/src/session/turn-recovery.ts b/packages/coding-agent/src/session/turn-recovery.ts index 617931b93..1e8a692cc 100644 --- a/packages/coding-agent/src/session/turn-recovery.ts +++ b/packages/coding-agent/src/session/turn-recovery.ts @@ -677,15 +677,29 @@ export class TurnRecovery { this.#emptyStopRetryCount++; if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) { const attempts = this.#emptyStopRetryCount - 1; - const finalError = providerEmptyOutput - ? "Assistant returned no final output after retry cap; try switching models" - : "Assistant returned empty stop after retry cap; try switching models or `/shake images` to remove archived frames"; + const outputTokens = assistantMessage.usage.output; + let finalError: string; + if (providerEmptyOutput) { + finalError = "Assistant returned no final output after retry cap; try switching models"; + } else if (outputTokens > 0 && assistantMessage.content.length === 0) { + // Billed output on a truly zero-block stop means content was generated and + // then dropped downstream (a filter/refusal flattened to + // `finish_reason: "stop"` by a proxy, or a lossy API translation) — the + // context/`/shake images` hint is wrong here, so name the billed output + // instead. Thinking-only stops keep a thinking block (and bill output for + // it), so they fall through to the context hint rather than this path. + finalError = `Assistant returned an empty stop after retry cap, but the provider billed ${outputTokens} output token${outputTokens === 1 ? "" : "s"} for it; content was generated and then dropped before delivery, which usually points to a provider-side content filter or a lossy API translation rather than a context problem`; + } else { + finalError = + "Assistant returned empty stop after retry cap; try switching models or `/shake images` to remove archived frames"; + } assistantMessage.errorMessage = finalError; if (providerEmptyOutput) assistantMessage.errorId = AIError.create(); logger.warn(finalError, { attempts, model: assistantMessage.model, provider: assistantMessage.provider, + outputTokens, }); await this.#host.emitSessionEvent({ type: "auto_retry_end", diff --git a/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts b/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts index 579f2d51d..ef1b51e0d 100644 --- a/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts +++ b/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts @@ -57,7 +57,18 @@ function emptyStop(): MockResponse { return { content: [], stopReason: "stop", - usage: { output: 1, cacheRead: 100 }, + usage: { output: 0, cacheRead: 100 }, + }; +} + +// A zero-block `stop` for which the provider still billed output tokens: content +// was generated and dropped downstream (e.g. a filter/refusal flattened to +// `finish_reason: "stop"` by a proxy), so the context/`/shake images` hint is wrong. +function filteredEmptyStop(): MockResponse { + return { + content: [], + stopReason: "stop", + usage: { output: 126, cacheRead: 100 }, }; } @@ -463,6 +474,56 @@ describe("AgentSession empty stop guard", () => { expect(retryEndEvents[0]?.finalError).toContain("/shake images"); }); + it("names billed output tokens instead of the context hint when a capped empty stop billed output", async () => { + const { session, mock } = await createHarness([ + filteredEmptyStop(), + filteredEmptyStop(), + filteredEmptyStop(), + filteredEmptyStop(), + ]); + const retryEndEvents: Array> = []; + session.subscribe(event => { + if (event.type === "auto_retry_end") { + retryEndEvents.push(event); + } + }); + + await expectPromptCompletes(session.prompt("answer that gets filtered")); + await session.waitForIdle(); + + expect(mock.calls).toHaveLength(4); + expect(retryEndEvents).toHaveLength(1); + expect(retryEndEvents[0]?.success).toBe(false); + const finalError = retryEndEvents[0]?.finalError ?? ""; + expect(finalError).toContain("billed 126 output tokens"); + expect(finalError).not.toContain("/shake images"); + }); + + it("keeps the context hint for a capped thinking-only stop even though it billed output", async () => { + const { session, mock } = await createHarness([ + thinkingOnlyStop(), + thinkingOnlyStop(), + thinkingOnlyStop(), + thinkingOnlyStop(), + ]); + const retryEndEvents: Array> = []; + session.subscribe(event => { + if (event.type === "auto_retry_end") { + retryEndEvents.push(event); + } + }); + + await expectPromptCompletes(session.prompt("think without answering")); + await session.waitForIdle(); + + expect(mock.calls).toHaveLength(4); + expect(retryEndEvents).toHaveLength(1); + expect(retryEndEvents[0]?.success).toBe(false); + const finalError = retryEndEvents[0]?.finalError ?? ""; + expect(finalError).toContain("/shake images"); + expect(finalError).not.toContain("billed"); + }); + it("ends auto-retry state when empty stop retries hit the cap", async () => { vi.spyOn(scheduler, "wait").mockResolvedValue(undefined); const { session, mock } = await createHarness(