diff --git a/src/daemon/providers/claude/index.ts b/src/daemon/providers/claude/index.ts index 1a5a8ec..35b9232 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,64 @@ 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 { + 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( + `${tag} 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 +1059,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).