Observed live: turn 1 died with an anthropic 429 before the stream's `final`
frame, streamOnce resolved '' as the thread id, the REPL stored it, and every
later message sent `threadId: ""` — rejected by mcpd's z.string().min(1) with
HTTP 400. Permanently stuck: no turn could succeed again, so no `final` frame
could ever repair the id.
Two independent layers:
- streamOnce now resolves `string | undefined` — undefined when no `final`
frame arrived — and the REPL keeps its previous thread state on undefined
instead of overwriting it. One-shot mode skips the `(thread: ...)` footer
when there is none to report.
- chatBody refuses to serialize an empty threadId at all, so even a leaked ''
can never reach the wire.
Regression cover in chat-thread-brick.test.ts (7 tests), including the full
REPL chain: failed turn 1 → turn 2 body carries no threadId key. The
assertions are the direct inverse of the old behavior, so they fail pre-fix
by construction.
CLI suite 726 passed, lint clean, tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016dNpnBqyyz9GxfznVcX2sP