fix(agents): preserve streamed assistant text when Claude CLI result event is empty#90450
Conversation
|
Codex review: needs maintainer review before merge. Reviewed July 5, 2026, 9:15 PM ET / 01:15 UTC. Summary PR surface: Source +14, Tests +175. Total +189 across 4 files. Reproducibility: yes. Source inspection shows current main discards accumulated streamed Claude text on empty successful result events, and the PR discussion includes sanitized production JSONL plus source-blind CLI proof of the same path. Review metrics: 1 noteworthy metric.
Stored data model Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Risk before merge
Maintainer options:
Next step before merge
Maintainer decision needed
Security Review detailsBest possible solution: Land the focused parser fix after exact-head required checks finish, preserving genuine-empty failover and tracking synthetic-placeholder follow-ups separately. Do we have a high-confidence way to reproduce the issue? Yes. Source inspection shows current main discards accumulated streamed Claude text on empty successful result events, and the PR discussion includes sanitized production JSONL plus source-blind CLI proof of the same path. Is this the best way to solve the issue? Yes. Preserving streamed text at the parser boundary covers buffered, incremental, and live-session consumers while leaving non-empty results, structured errors, and genuine-empty failover unchanged. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 60cf7eaa005b. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +14, Tests +175. Total +189 across 4 files. View PR surface stats
Acceptance criteria:
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
Review history (5 earlier review cycles)
|
|
Addressing the two P2 risk notes from the review, since they're the design trade-offs maintainers need to accept: 1. Malformed empty result with a tool_use block → silent turn instead of fallback This is a real trade-off, but silence is the safer side of it. By the time the empty result arrives, the turn's tool calls have already executed (side effects included). Falling back re-answers the turn on a model that never saw those tool calls or their results, so the fallback reply is generated without the context of actions that already happened — arguably worse than a silent turn. Also note the window is narrow: the guard only suppresses the failover when the turn verifiably emitted tool_use blocks (structured detection, not heuristics) and both the final result text and every streamed text delta were empty. 2. Requested-provider/no-fallback contract for tool-only turns
If maintainers prefer a more conservative rollout, gating the Separately: we left an instrumented hook in our production gateway that dumps the raw JSONL stream the next time Claude emits an empty final result after streaming deltas; we'll attach a sanitized capture here when it fires. |
|
The malformed-import blocker from the last review was a casualty of syncing this branch with import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";The prior verdict was computed against On the two P2 notes (tool-only → silent vs. fallback): covered in my earlier comment — silence is the safer side, since by the time the empty result arrives the tool calls have already executed, so a fallback re-answers without that context. Still happy to gate the @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
Pushed Change ( Tests (
Local verification on This directly addresses the P1 at @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
Rebased onto current The 3 remaining red shards (
This PR only touches |
|
The This PR is parser-only ( Request: drop the Impact confirmed still live on The 3 red CI shards ( @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
Sanitized raw JSONL capture (the promised follow-up). The instrumented debug hook left in our production gateway has now captured this intermittent empty-final-result event 218 times. Here is a representative capture, fully sanitized: all assistant/user text redacted to The smoking gun: a turn that completed successfully ( Reproduced identically by both parsers this PR fixes ( This is exactly the case the parser fallback recovers: This should close the remaining "raw JSONL capture not attached" item from the review. |
|
Branch updated to current The 3 previously-red CI shards ( Contributor-side items are all resolved now:
The only remaining item is the @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
Status recap for maintainers — this is now a single, low-risk decision. Latest ClawSweeper re-review has this at platinum hermit / diamond lobster proof, "ready for maintainer review", 0 P1, Security Cleared (parser + tests only). Two residual signals are stale and I'd flag them as noise:
The one real decision is the P2 (message-delivery): after this fix, a Claude turn that already streamed its full reply but ends with an empty @shakkernerd @vincentkoc — you've both been in this exact area very recently (empty-response diagnostics and CLI message-delivery evidence), so this may be a quick call for you: is preserving already-streamed Claude text over an empty terminal @clawsweeper re-review |
Rebuilt onto current
|
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
I re-checked this PR because new issue #100162 is now pointing at the same empty-completion failure. That issue does not need a competing fix lane: its source-repro path maps back to the two parser branches this PR owns ( Current head I also reran the focused proof locally with Node 24: node scripts/run-vitest.mjs src/agents/cli-output.test.ts src/agents/cli-runner.reliability.test.tsResult: passed 2 Vitest shards in 60.77s ( So #100162 looks like additional maintainer-priority evidence for this PR rather than a new implementation target. Remaining gate is still the maintainer message-delivery call: when Claude streamed assistant text but the terminal |
|
Thanks for the re-check, @harjothkhara — agreed on the framing, and the local numbers match here (61/61 + 67/67 on One scoping note so the maintainer message-delivery call has the full picture, since #100162 is now in the mix: This PR fixes the streamed-then-empty subset — assistant text (or a tool_use turn) already arrived over the stream, but the final Mapping to #100162: the "reply had already fully arrived but was dropped" path is what this PR closes. The "embedded_run completes genuinely empty, nothing to recover" path is complementary — this PR intentionally leaves that failing over. Options (a)/(b) in that issue (retry / delivery-failed signal) live in the runner/supervisor layer, and this PR keeps Happy to open a follow-up for the genuinely-empty signal if the maintainer wants it, but that's a separate, larger contract change — kept this one parser-only and low-risk. |
|
Land-ready proof for exact head
Known gap: the empty terminal envelope is intermittent against live Anthropic credentials, so exact-head proof uses a deterministic local CLI backend. The PR's earlier production Ubuntu logs provide real-credential before/after corroboration. |
|
Maintainer closeout at exact head
Known scope: this fixes loss of text already present in the stream. The broader genuine-empty-completion investigation remains tracked by #100162. |
|
Merged via squash.
|
|
Thanks for landing this, @steipete 🙏 Quick one before I open anything: the merged parser-only version covers streamed-then-empty, but leaves out the tool-only empty turn (turn emitted Want a small follow-up gated behind the existing |
What Problem This Solves
Claude CLI can stream a complete assistant answer and then emit an empty successful terminal result. OpenClaw previously treated that terminal envelope as authoritative, discarded the streamed answer, and failed the turn as an empty response.
Why This Change Was Made
Both JSONL consumers now preserve already-accumulated assistant text only when the terminal result is successful and empty. Reusable Claude sessions finish from the same incremental parser that classified pre-tool commentary, so an empty terminal result cannot merge commentary back into the final answer. Non-empty terminal results and structured errors keep precedence, while a genuinely empty turn remains empty and follows the existing failover policy.
The maintainer rewrite removed unused parallel tool-call bookkeeping and unrelated runner-policy tests, leaving one parser-owned behavior path plus focused regressions.
User Impact
Claude CLI replies that fully arrive through streaming are no longer lost merely because the final result envelope is empty. Genuine empty turns still fail as before.
Evidence
4b8cb8fce4b5e25adfa4f7aa2aee49a2d484c36b.cbx_3dfa68399e4a: public network, no instance role, no Tailscale, no hydration. The focused and source-blind runs exercised the same five-file behavior patch before main-ancestry/changelog-only rebases.run_8ffd5b59ad11: 135/135 parser and reusable-session tests passed.run_af6ab48c56b7throughopenclaw agent --local --jsonand a deterministic Claude-style JSONL backend:28761397180: all required checks passed.oxfmtandgit diff --check: pass.The contributor's production Ubuntu proof remains useful evidence for the intermittent real Claude CLI event sequence. The maintainer proof revalidates the rewritten user-facing local-agent command without credentials.
Risk checklist
AI-assisted disclosure