From d256367b7220a350b3c8f062bd35dbdc5d7f9eaa Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 14 Aug 2026 04:50:52 +0000 Subject: [PATCH] fix(agent): name billed output tokens on capped empty stops An empty assistant `stop` that exhausts the retry cap always reported the context/`/shake images` hint, even when the provider billed output tokens for the turn. Billed output on a 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), so the images/context advice is actively misleading there. Branch the capped `finalError` in `#handleEmptyAssistantStop` on `assistantMessage.usage.output`: keep the context hint when nothing was generated, and otherwise name the billed output-token count and point at a provider-side filter/translation. Also log `outputTokens` alongside the existing warning fields. Fixes #8511 --- packages/coding-agent/CHANGELOG.md | 4 ++ .../coding-agent/src/session/turn-recovery.ts | 18 +++++++-- .../agent-session-empty-stop-guard.test.ts | 38 ++++++++++++++++++- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6f6108547..183174543 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### 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` 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 ([#8511](https://github.com/can1357/oh-my-pi/issues/8511)). + ## [17.3.3] - 2026-08-14 ### Fixed diff --git a/packages/coding-agent/src/session/turn-recovery.ts b/packages/coding-agent/src/session/turn-recovery.ts index f1220df61..3b264773a 100644 --- a/packages/coding-agent/src/session/turn-recovery.ts +++ b/packages/coding-agent/src/session/turn-recovery.ts @@ -674,15 +674,27 @@ 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) { + // Billed output on a 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. + 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..218aaebc3 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,31 @@ 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("ends auto-retry state when empty stop retries hit the cap", async () => { vi.spyOn(scheduler, "wait").mockResolvedValue(undefined); const { session, mock } = await createHarness(