From b1616db8714363c6a39393577456d4dcc2ff30ec Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sun, 6 Sep 2026 13:10:40 +0800 Subject: [PATCH 1/2] fix(claude): re-send a prompt the backend consumed without running it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A zero-turn result with NO `` is the unexplained variant: the SDK reports `subtype: "success"`, the model never ran, and nothing says why. The prompt was then simply lost and the user retyped it. Closes #326. Distinct from #233, and the error text already distinguishes them: the provider prints the captured stderr when there is one, and falls back to "No cause was reported by the backend" only when it is null. #233 (and upstream anthropics/claude-code#80223) is a blocked skill-frontmatter expansion — it HAS a cause, and `#tryHandleSkillBlock` parks it for approval. This one has no cause, so nothing parked it and nothing retried it. Both occurrences in local transcripts share one signature: the first prompt after a long idle gap (12h47m and 59m34s), with a fresh `system:init` landing between the prompt and the error ~2s later. Reading: the SDK loop went cold during the idle, the next send rebuilt it, and the first prompt into the rebuilt loop was swallowed. Why the loop goes cold is NOT established — codeoid does not tear it down on idle, and there is no production log trace since provider lifecycle events go to stdout only. The fix does not need that answer. It keys on `num_turns === 0` — a typed field carrying correct data — so it recovers whatever produced the zero turn and does not depend on the upstream reporting defect being fixed. Re-sending is safe BECAUSE it is a zero-turn: no assistant turn ran, so no tool executed and there is no side effect to duplicate. That is what separates this from a normal failed turn, which must never be silently repeated. Scoped tightly: only when no stderr explains it (re-sending a real denial would just be denied again), and only once per turn — the guard resets in runTurn() so each new prompt gets its own single attempt, rather than one per session. A visible info message means the retry is never silent. The machinery already existed: `#lastPushedContent`, `#ensureQueryLoop`, and the re-push pattern from `#retryAfterGrant`. The interception seam in `#translateSDKMessage` already handled the explained zero-turn; this adds the symmetric branch. Five tests cover it, and were confirmed to catch the regression — disabling the branch fails three of them. They assert the re-send, the one-attempt ceiling, that a normal multi-turn result is untouched, that a zero-turn WITH a cause still surfaces that cause rather than being silently retried, and that the guard is per-turn rather than per-session. Co-Authored-By: Claude Opus 5 (1M context) --- src/daemon/providers/claude/index.ts | 58 +++++++++++++++++++ src/tests/provider-claude.test.ts | 86 ++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/src/daemon/providers/claude/index.ts b/src/daemon/providers/claude/index.ts index 1a5a8ec..6d26235 100644 --- a/src/daemon/providers/claude/index.ts +++ b/src/daemon/providers/claude/index.ts @@ -162,6 +162,10 @@ export class ClaudeProvider implements SessionProvider { /** Last TurnOpts — the retry after a skill approval rebuilds the loop with * these, then re-pushes #lastPushedContent into the SAME turn queue (#233). */ #lastTurnOpts: TurnOpts | null = null; + /** One re-send per turn for an UNEXPLAINED zero-turn result. Reset in + * runTurn() so every new prompt gets its own single attempt, and in + * resetToNewSession() with the rest of the backing-session state. */ + #zeroTurnRecoveryAttempted = false; /** Rendered transcript from seedFromHistory() — prepended to the next prompt. */ #pendingHistorySeed: string | null = null; @@ -239,6 +243,7 @@ export class ClaudeProvider implements SessionProvider { this.#claudeCodeSessionId = newBackingId; this.#hasQueried = false; this.#backingRecoveryAttempted = false; + this.#zeroTurnRecoveryAttempted = false; this.#lastPushedContent = null; } @@ -253,6 +258,8 @@ export class ClaudeProvider implements SessionProvider { this.#currentRequestUserInput = opts.requestUserInput ?? null; this.#currentSender = opts.sender ?? null; this.#lastTurnOpts = opts; + // A genuinely new prompt earns its own single recovery attempt. + this.#zeroTurnRecoveryAttempted = false; this.#ensureQueryLoop(opts); @@ -955,6 +962,51 @@ export class ClaudeProvider implements SessionProvider { this.#pushSDKMessage(this.#lastPushedContent, "later", true); } + /** + * Re-send a prompt the backend consumed without running it. + * + * A zero-turn result with NO `` is the unexplained + * variant: the SDK reports `subtype: "success"`, the model never ran, and + * nothing says why. Observed after a long session idle, where the result + * lands ~2s after the prompt and immediately behind a fresh `system:init` — + * i.e. the loop had gone cold and the first prompt into the rebuilt one was + * swallowed. Before this, the prompt was simply lost and the user retyped it. + * + * Re-sending is safe *because* it is a zero-turn: `num_turns === 0` means no + * assistant turn ran, so no tool executed and there is no side effect to + * duplicate. That is what separates this from a normal failed turn, which we + * must never silently repeat. + * + * Deliberately keyed on `num_turns` — a typed field carrying correct data — + * rather than on any cause, so it recovers whatever produced the zero turn + * and does not depend on the upstream reporting defect being fixed + * (anthropics/claude-code#80223, still open). + * + * Scoped tightly: only when NO stderr explains it (a real skill-command + * denial is #233's parked-approval path, and re-sending it would just be + * denied again), and only once per turn. + * + * Returns true when the turn was re-sent — the caller then suppresses the + * translation, so no terminal `turn_done` is emitted and the Session keeps + * the turn open, exactly as the #233 park does. + */ + #tryRecoverConsumedPrompt(): boolean { + if (this.#zeroTurnRecoveryAttempted) return false; + if (!this.#lastTurnOpts || this.#lastPushedContent === null) return false; + this.#zeroTurnRecoveryAttempted = true; + console.error( + `[claude-provider ${this.#init.sessionId.slice(0, 8)}] zero-turn with no reported cause — re-sending the consumed prompt (${this.#lastPushedContent.length}B)`, + ); + this.#emit({ + type: "custom_message", + role: "info", + content: "The backend consumed the prompt without running it — re-sending…", + }); + this.#ensureQueryLoop(this.#lastTurnOpts); + this.#pushSDKMessage(this.#lastPushedContent, "later", true); + return true; + } + /** End a parked skill turn as a clean error (denied / dismissed / broken). */ #failSkillTurn(command: string, reason: string): void { const detail = command ? `: ${command}` : ""; @@ -994,6 +1046,12 @@ export class ClaudeProvider implements SessionProvider { this.#translateState.lastLocalCommandStderr = null; return; } + // The UNEXPLAINED zero-turn: same "prompt consumed, model never ran", but + // with no stderr naming a cause. Re-send it once rather than losing it. + // Same suppression contract as the skill-block branch above. + if (m.type === "result" && m.num_turns === 0 && !stderr && this.#tryRecoverConsumedPrompt()) { + return; + } translateSDKMessage(msg, this.#emit.bind(this), this.id, this.#translateState, (e) => this.onSessionEvent?.(e)); } } diff --git a/src/tests/provider-claude.test.ts b/src/tests/provider-claude.test.ts index b97160a..1e343af 100644 --- a/src/tests/provider-claude.test.ts +++ b/src/tests/provider-claude.test.ts @@ -507,6 +507,92 @@ describe("skillCommandAllowRules", () => { // Park-and-retry: a blocked skill command must NOT fail the turn (that killed the // pipeline — #233). It parks the turn (approval_pending), and on approval the // loop rebuilds with the grant and retries the SAME prompt in place. +/** + * The UNEXPLAINED zero-turn: `num_turns: 0` with no `` + * naming a cause. The prompt was consumed and the model never ran, so before + * this the prompt was simply lost and the user retyped it. + * + * Distinct from #233 (a blocked skill command, which HAS a stderr cause and + * parks for approval) and from the upstream reporting defect + * (anthropics/claude-code#80223) — this recovers whatever produced the zero + * turn, because it keys on `num_turns` rather than on any cause. + */ +describe("ClaudeProvider – unexplained zero-turn re-send", () => { + beforeEach(() => { sdkMessages = []; sdkThrowError = null; capturedQueryOpts = null; sdkGate = null; }); + + const ZERO_TURN = { type: "result", subtype: "success", is_error: false, num_turns: 0, result: "" }; + const REAL_TURN = { type: "result", subtype: "success", num_turns: 2, result: "done", modelUsage: {} }; + const info = (events: ProviderEvent[]) => + events.filter( + (e) => e.type === "custom_message" && String((e as { content?: string }).content).includes("re-sending"), + ); + const zeroTurnErrors = (events: ProviderEvent[]) => + events.filter( + (e) => + e.type === "turn_done" && + String((e as { result?: { errorMessage?: string } }).result?.errorMessage ?? "").includes( + "produced no turn", + ), + ); + + it("re-sends the consumed prompt instead of surfacing an error", async () => { + sdkMessages = [ZERO_TURN, REAL_TURN]; + const events = await collectTurnEvents(makeProvider()); + // The user is told, once, that the turn is being retried... + expect(info(events)).toHaveLength(1); + // ...and never sees the zero-turn error, because the turn recovered. + expect(zeroTurnErrors(events)).toHaveLength(0); + // The turn still ends normally, on the real result. + expect(events.some((e) => e.type === "turn_done")).toBe(true); + }); + + it("gives up after ONE attempt — a second zero-turn is a real error", async () => { + sdkMessages = [ZERO_TURN, ZERO_TURN]; + const events = await collectTurnEvents(makeProvider()); + expect(info(events)).toHaveLength(1); // not two + // The retry failed the same way, so the turn ends honestly rather than + // looping on a backend that will never run this prompt. + expect(zeroTurnErrors(events)).toHaveLength(1); + }); + + it("leaves a normal multi-turn result alone", async () => { + sdkMessages = [REAL_TURN]; + const events = await collectTurnEvents(makeProvider()); + expect(info(events)).toHaveLength(0); + expect(zeroTurnErrors(events)).toHaveLength(0); + }); + + it("does NOT touch a zero-turn that HAS a reported cause", async () => { + // A stderr cause that is not a skill-command pattern, so #233's parked + // approval declines it too — it must fall through to the honest error with + // the real reason, NOT be silently re-sent as if unexplained. + sdkMessages = [ + { + type: "user", + message: { role: "user", content: "Error: disk on fire" }, + }, + ZERO_TURN, + ]; + const events = await collectTurnEvents(makeProvider()); + expect(info(events)).toHaveLength(0); + const errs = zeroTurnErrors(events); + expect(errs).toHaveLength(1); + expect(String((errs[0] as { result: { errorMessage?: string } }).result.errorMessage)).toContain( + "disk on fire", + ); + }); + + it("gives each NEW prompt its own single attempt", async () => { + const provider = makeProvider(); + sdkMessages = [ZERO_TURN, REAL_TURN]; + expect(info(await collectTurnEvents(provider, "first"))).toHaveLength(1); + // The one-shot guard is per-turn, not per-session: a later prompt that hits + // the same cold-loop failure must still be recoverable. + sdkMessages = [ZERO_TURN, REAL_TURN]; + expect(info(await collectTurnEvents(provider, "second"))).toHaveLength(1); + }); +}); + describe("ClaudeProvider – skill-command approval (#233)", () => { // A Store backed by a real Map, so a persisted grant is visible to the retry's // query rebuild (a fixed-map stub could never satisfy the retry). From dabcea9ffe8193b76c9d920496928934fc3d776a Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sun, 6 Sep 2026 14:31:21 +0800 Subject: [PATCH 2/2] fix(claude): log the zero-turn give-up, not just the attempt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oracle review on #327: recovery logged when it started but not when it stopped. The user always saw the failure either way — falling through emits the real turn_done error — but the operator could not tell "the re-send ran and also came back empty" from "never retried at all". Those point at different problems: a wedged backend versus a one-off swallow. Both early returns now say which one happened, and the existing line reuses the same tag. No test asserts the log text: the behaviour it describes (a second zero-turn surfaces as a turn error) is already covered, and pinning console output would buy nothing but a brittle assertion. Co-Authored-By: Claude Opus 5 (1M context) --- src/daemon/providers/claude/index.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/daemon/providers/claude/index.ts b/src/daemon/providers/claude/index.ts index 6d26235..35b9232 100644 --- a/src/daemon/providers/claude/index.ts +++ b/src/daemon/providers/claude/index.ts @@ -991,11 +991,24 @@ export class ClaudeProvider implements SessionProvider { * the turn open, exactly as the #233 park does. */ #tryRecoverConsumedPrompt(): boolean { - if (this.#zeroTurnRecoveryAttempted) return false; - if (!this.#lastTurnOpts || this.#lastPushedContent === null) return false; + const tag = `[claude-provider ${this.#init.sessionId.slice(0, 8)}]`; + // Falling through emits the real turn_done error, so the USER sees the + // failure either way. These lines are for the operator: they separate "the + // retry ran and also came back empty" from "never retried", which is the + // difference between a backend that is wedged and a one-off swallow. + if (this.#zeroTurnRecoveryAttempted) { + console.error( + `${tag} zero-turn again after the re-send — giving up, surfacing as a turn error`, + ); + return false; + } + if (!this.#lastTurnOpts || this.#lastPushedContent === null) { + console.error(`${tag} zero-turn with no reported cause and no prompt to re-send`); + return false; + } this.#zeroTurnRecoveryAttempted = true; console.error( - `[claude-provider ${this.#init.sessionId.slice(0, 8)}] zero-turn with no reported cause — re-sending the consumed prompt (${this.#lastPushedContent.length}B)`, + `${tag} zero-turn with no reported cause — re-sending the consumed prompt (${this.#lastPushedContent.length}B)`, ); this.#emit({ type: "custom_message",