Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions src/daemon/providers/claude/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -239,6 +243,7 @@ export class ClaudeProvider implements SessionProvider {
this.#claudeCodeSessionId = newBackingId;
this.#hasQueried = false;
this.#backingRecoveryAttempted = false;
this.#zeroTurnRecoveryAttempted = false;
this.#lastPushedContent = null;
}

Expand All @@ -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);

Expand Down Expand Up @@ -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 `<local-command-stderr>` 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
Comment thread
saucam marked this conversation as resolved.
* 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}` : "";
Expand Down Expand Up @@ -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));
}
}
Expand Down
86 changes: 86 additions & 0 deletions src/tests/provider-claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<local-command-stderr>`
* 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: "<local-command-stderr>Error: disk on fire</local-command-stderr>" },
},
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).
Expand Down
Loading