Skip to content

fix(claude): re-send a prompt the backend consumed without running it - #327

Merged
saucam merged 3 commits into
mainfrom
fix/zero-turn-prompt-recovery
Sep 6, 2026
Merged

fix(claude): re-send a prompt the backend consumed without running it#327
saucam merged 3 commits into
mainfrom
fix/zero-turn-prompt-recovery

Conversation

@saucam

@saucam saucam commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Closes #326.

The bug

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. The prompt was then simply lost — you retype it.

Error: The agent produced no turn — the prompt was consumed without running.
No cause was reported by the backend (check for a blocked slash-command expansion or rejected input).

Not #233 — and the error text already proves which is which

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.

Cause reported? Handled by
#233 / claude-code#80223 yes — blocked skill-frontmatter expansion #tryHandleSkillBlock parks for approval
This no nothing — prompt dropped

Evidence

Both occurrences in local transcripts share one signature — first prompt after a long idle gap, fresh system:init between prompt and error:

Session Idle gap Prompt → error Between
6f59c413 12h 47m 1.81s MCP ready, error 2ms later
cfe05fcd 59m 34s 1.99s same

Reading: the loop went cold during the idle, the next send rebuilt it, and the first prompt into the rebuilt loop was swallowed.

Honest limit: why the loop goes cold is not established. codeoid does not tear it down on idle; the memory-index oscillation path is already fixed via #frozenMemoryIndex; the only systemPromptAppend changed logs on disk are from test runs, since provider lifecycle events go to stdout only. n=2.

Why the fix doesn't need that answer

It keys on num_turns === 0 — a typed field carrying correct data — so it recovers whatever produced the zero turn, and works whether or not #80223 is ever 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 exactly 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;
  • once per turn, reset in runTurn() so each new prompt gets its own attempt rather than one per session;
  • a visible info message, so the retry is never silent.

The machinery already existed (#lastPushedContent, #ensureQueryLoop, the re-push from #retryAfterGrant). #translateSDKMessage already intercepted the explained zero-turn; this adds the symmetric branch.

Tests

Five, in provider-claude.test.ts, driven through the existing mocked-SDK harness:

  1. re-sends instead of surfacing an error
  2. gives up after one attempt — a second zero-turn is a real error
  3. leaves a normal multi-turn result alone
  4. a zero-turn with a cause still surfaces that cause (not silently retried)
  5. each new prompt gets its own single attempt

Confirmed to catch the regression — disabling the branch fails 3 of the 5.

Full suite: 2453 pass / 0 fail; typecheck and biome clean. One run showed 1 fail / 5 errors, which is a bun runner race (Cannot call describe() after the test run has completed) with the same signature as a baseline run taken before this change — not attributable here, and clean on re-run.

Follow-up (not in this PR)

Provider lifecycle events (rebuilding query loop, etc.) are stdout-only. Persisting them into the transcript would let the next occurrence diagnose itself instead of requiring transcript archaeology.

🤖 Generated with Claude Code

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. 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) <noreply@anthropic.com>

@highflame-oracle highflame-oracle Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔮 Oracle Review

🎯 Start Here

src/daemon/providers/claude/index.ts (~15 min) — Logic changes in index.ts


📋 PR Summary

What this PR does: fix(claude): re-send a prompt the backend consumed without running it


🔍 Code Review

Review completed.

Review Stats: suggestion:1


Generated by Oracle - Highflame's AI Code Reviewer

Comment thread src/daemon/providers/claude/index.ts
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) <noreply@anthropic.com>
@saucam
saucam merged commit d422879 into main Sep 6, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unexplained zero-turn silently loses the prompt (first send after a long idle)

2 participants