feat(cli): render claude CLI native thinking with /reasoning gating#99401
Conversation
|
Codex review: needs maintainer review before merge. Reviewed July 3, 2026, 10:02 AM ET / 14:02 UTC. Summary PR surface: Source +216, Tests +672. Total +888 across 12 files. Reproducibility: yes. Current main has no CLI thinking parser/output path, and the PR supplies real Claude CLI stream-json frames plus live Discord/Telegram off/stream/on proof for the new behavior. Review metrics: none identified. 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
Security Review detailsBest possible solution: Land this narrow parser-and-existing-gates implementation if maintainers accept the raw-thinking bus/archive parity model, while keeping HTTP reasoning and routed durable follow-up delivery as separate tracked scopes. Do we have a high-confidence way to reproduce the issue? Yes. Current main has no CLI thinking parser/output path, and the PR supplies real Claude CLI stream-json frames plus live Discord/Telegram off/stream/on proof for the new behavior. Is this the best way to solve the issue? Yes for this branch scope. Reusing AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 8c915f068525. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +216, Tests +672. Total +888 across 12 files. View PR surface stats
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
|
054e155 to
1d7a19c
Compare
Mantis Telegram Desktop ProofSummary: Mantis captured native Telegram Desktop before/after GIF evidence with Convex-leased Telegram credentials.
Motion-trimmed clips: |
The claude-cli (claude-stdio) live-session backend emits native
reasoning on stream-json (`thinking_delta` + a snapshot `thinking`
block), but the CLI parser had no thinking handling at all, so it was
silently dropped on every surface in every /reasoning mode.
- cli-output.ts: parse thinking_delta/snapshot into stream:"thinking"
with replace-style per-content-block-index dedupe
(assembleThinkingTextByIndex), robust to tool-interleaved multi-block
turns (snapshot replaces per-index rather than assuming a streamed
prefix). signature_delta/redacted_thinking stay skipped.
- agent-runner-cli-dispatch.ts: bridge stream:"thinking" into
onReasoningStream via a new createReasoningTextBridge (mirrors the
existing assistant-text bridge), replacing the old
assistant-text-as-reasoning heuristic. Track the final reasoning
snapshot and, when present, prepend a durable
`{text, isReasoning: true}` payload before the answer — mirroring
the embedded-runner durable reasoning payload
(embedded-agent-runner/run/payloads.ts).
- agent-runner-execution.ts: thread isReasoningSnapshot through to
onReasoningStream, still passing requiresReasoningProgressOptIn:true
so CLI reasoning rides the exact same channel gates as embedded
reasoning (Discord/Telegram): /reasoning off -> nothing on any
surface, stream -> window preview only, on -> durable payload too.
The bridge is suppressed under the same conditions as the assistant
bridge (e.g. silentExpected follow-ups), so no durable payload is
synthesized for turns that are expected to stay silent.
Supersedes this branch's prior commit (a straight port of
af7eb48e05's emit-always model, no gating and no durable mode) with
the above gated/durable design per product decision.
Also, in cli-output.ts: reset the per-content-block-index thinking
tracker on every new Anthropic message (message_start, or a fresh
top-level "assistant" snapshot's message.id) instead of leaving it
scoped to the whole CLI turn. Content-block indices restart at 0 per
Anthropic message, so a tool round-trip within one turn (message A:
thinking + tool_use; tool result fed back; message B: a fresh
message_start) was letting message A's stale index-0 thinking text
bleed into message B's index-0 delta.
And in agent-runner-cli-dispatch.ts: attach the durable reasoning
payload whenever any thinking was bridged, not only when the CLI also
produced a visible final answer, so a thinking-only turn (no visible
reply) doesn't silently drop its reasoning.
Round 2: the durable `{isReasoning: true}` payload synthesized above
was also reaching the QUEUED FOLLOW-UP delivery path
(followup-runner.ts -> resolveFollowupDeliveryPayloads) with no gate
at all, so a claude-cli queued follow-up with /reasoning off would
leak internal reasoning to the channel as an ordinary visible message.
resolveFollowupDeliveryPayloads (followup-delivery.ts) now takes a
reasoningPayloadsEnabled flag and drops isReasoning payloads unless
it is true; followup-runner.ts threads opts?.reasoningPayloadsEnabled
through at both call sites, from the same GetReplyOptions the direct
path already reads (dispatch-from-config.ts:2054). This makes the
queued-follow-up path apply the identical isReasoning/
reasoningPayloadsEnabled gate that direct dispatch already enforces
(dispatch-from-config.ts:3347, :3537) — no new mechanism, CLI
follow-ups now go through the exact same gate embedded follow-ups do.
Scope note: routeReply's own unconditional suppression of isReasoning
payloads on the origin-routing branch (route-reply.ts:131,
shouldSuppressReasoningPayload) is pre-existing, shared with the
embedded runner, and unchanged by this commit — that gate governs
whether a kept-and-routed reasoning payload is actually rendered on a
cross-channel queued follow-up, and is out of scope here. This commit
closes the leak (off -> never eligible on any path); it does not
change what happens to an eligible payload once it reaches routeReply.
Co-Authored-By: Claude <[email protected]>
1d7a19c to
ab98e51
Compare
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
Live transport proof — Discord + Telegram,
|
/reasoning |
Discord | Telegram |
|---|---|---|
| stream | ![]() |
![]() |
| on | ![]() |
![]() |
| off | ![]() |
![]() |
What each cell shows
- stream (GIFs): the live progress window with CLI thinking streaming in real time — gerund
status line, then 🧠 reasoning text updating mid-generation, then the window collapsing to the
summary line (Discord:🧠 1 thought · 💬 1 note · 🛠️ 1 tool call · 10s) with the final answer. - on: a durable 🧠 reasoning message that persists after the answer (Discord blockquote /
Telegram 🧠-prefixed message) — the same durable placement embedded reasoning already uses on
these channels. - off: same prompt, same model — no reasoning anywhere on the surface; only the final
answer. Gateway log for the off turns shows full-size claude-cli sessions (e.g. Telegram off:
23.0s, 75 raw stream-json lines — comparable to the on runs), i.e. thinking was generated
upstream and display-gated, not absent.
Additional angles in the proof branch (claude-cli-thinking/visual/): mid-generation + settled
captures per cell, including telegram-on.gif
which catches the in-flight window (💬 commentary + 🛠️ tool lines) and the durable 🧠 landing below it.
obviyus
left a comment
There was a problem hiding this comment.
Reviewed against current main with the full changed surface, siblings, and gates read:
- Bug real: main's
createCliJsonlStreamingParserhas no thinking handling, and CLI "reasoning" today is the assistant-text bridge heuristic (shouldBridgeCliAssistantTextToReasoning) — a proxy, not data. - Root-cause fix: parses the real
thinking_deltastream with per-index dedup and per-message-id tracker reset (the tool-round-trip index-restart hazard is handled and regression-tested), deletes the heuristic rather than keeping a second path, and routes through the existing gates:requiresReasoningProgressOptInsemantics match the embedded sibling (embedded-agent-subscribe.ts), durable drop rides the pre-existingreasoningPayloadsEnabledpredicate; thefollowup-deliveryaddition extends that same predicate to a path that previously had noisReasoninghandling at all. - Verified locally:
cli-output+agent-runner-cli-dispatchsuites green in a PR worktree at headab98e51815b. - CI: the single red lane ("Run agentic native Telegram proof") is a capture-environment limitation, not a finding — its own manifest reports baseline/candidate both
skipped("proof environment cannot drive a claude-cli reasoning turn in Telegram Desktop"). Same harness signature as #99722's false-fail earlier today. - Non-blocking follow-up: the
reasoningPayloadsEnableddrop predicate now lives in two delivery owners; a shared helper would prevent drift.
Approving and landing. Thanks @Marvinthebored — clean seam choice and the red-team test on the message-boundary reset is exactly the right paranoia.
…rser Layers our progress-preview UX on top of PR openclaw#99401's native claude-cli thinking parser (cherry-picked onto v2026.6.11), replacing our earlier 6.10-lineage parser port (6868ad2) whose parser logic openclaw#99401 now supplies natively. Only the render-side deltas remain: - progress-draft-compositor: guard the first delivered frame so a tool-progress preview never emits a bare label before the first tool line exists (silent-window arming); freeze on final-reply start. - telegram bot-message-dispatch: separator-based heading extraction so a labelless draft no longer duplicates the first preview line. - telegram draft-stream / streaming / typing-mode / get-reply-options: keep the claude-cli preview live across inter-tool commentary and silent tool windows, keyed by toolCallId for in-place refresh. Parser + /reasoning gating come entirely from openclaw#99401; this commit is the render UX only.










Summary
Problem. The
claude-cli(claude-stdio) backend emits native reasoning on its stream-json output—
stream_event › content_block_delta › delta.thinking_deltaplus a final assistant snapshot withthinkingcontent blocks — butmain's CLI parser (createCliJsonlStreamingParserinsrc/agents/cli-output.ts) has no thinking handling at all. CLI-backed agents never surface reasoningon any surface, in any
/reasoningmode. Related open feature requests: #68374, #97526.Why now. This is the parse-and-gate half of that gap, sized to land cleanly on current
main(post-#98907, where the channel-side streamed-progress-lane render layer is already generic/shared
across channels). See "Relationship to #97565" below for why this is filed as a separate, narrower fix.
Outcome. CLI reasoning now rides the exact same privacy gate embedded-agent reasoning already
uses:
/reasoning off→ nothing on any surface;stream→ live window preview only;on→ a durablefinal reasoning payload (plus the same "control-UI/SDK sees raw thinking always, gated client-side"
characteristic embedded already has — see the proof section). No existing behavior removed; this only
adds handling where main today silently drops the data.
Out of scope. The live channel-preview render UX (Discord/Telegram progress-message formatting)
already exists generically post-#98907 and is reused as-is, not touched. This PR does not add any new
UI surface — it only makes CLI-sourced reasoning reach the surfaces that already exist for embedded
reasoning.
Success criteria. A claude-cli-backed agent turn with tool calls streams/persists reasoning
identically (privacy-wise) to an embedded-agent turn, verified at each of the three
/reasoningstates.Reviewer focus. (1) the per-Anthropic-message reset in
cli-output.ts(a tool round-trip within oneCLI turn starts a fresh Anthropic message with content-block indices restarting at 0 — the tracker must
not let a prior message's index-0 thinking bleed into the new one); (2) the reuse (not reimplementation)
of the existing
requiresReasoningProgressOptIn/reasoningPayloadsEnabledgates — no new gatemechanism is introduced.
Maintainer-ready classification
an existing privacy gate; not a behavior change for any other provider).
src/agents/cli-output.ts's CLI stream-json parser never implemented thinking-blockhandling, so
stream:"thinking"was never emitted for CLI-backed turns, and the CLI dispatch layer(
src/auto-reply/reply/agent-runner-cli-dispatch.ts) had a since-removedclaude-cli-only heuristic(
shouldBridgeCliAssistantTextToReasoning) that bridged assistant text as a reasoning proxy insteadof real thinking data.
end-to-end — same
stream:"thinking"event shape ({text, delta, isReasoningSnapshot}), sameonReasoningStream({text, requiresReasoningProgressOptIn: true})call contract consumed by Discord(
extensions/discord/src/monitor/message-handler.process.ts) and Telegram(
extensions/telegram/src/bot-message-dispatch.ts), same durable-payload shape(
{text, isReasoning: true}) consumed by the universal drop gate insrc/auto-reply/reply/dispatch-from-config.ts(reply.isReasoning === true && !reasoningPayloadsEnabled→ drop). No new gate, no new payload shape, no channel-specific code added.
(thinking-tracker state bleeding across an Anthropic-message boundary within one CLI turn) before this
was filed — see the dedicated test "resets per-index thinking state on a new message within the same
turn (tool round-trip)" in
cli-output.test.ts.Relationship to #97565
#97565 targets the same gap with broader scope, including a from-scratch channel render layer that
#98907 has since made largely generic. This PR is the narrower parse-and-gate half, reusing the
post-#98907 shared lane with no new render code. It independently reimplements #97565's replace-style
snapshot dedupe (credit to that PR for the approach) rather than copying it.
Linked context
/v1/responses+/v1/chat/completions) —this PR doesn't touch those HTTP surfaces (see proof section: the OpenAI-compat endpoint has no
stream:"thinking"handler on either side of this diff), so it's a step toward [Feature]: Expose claude-cli thinking blocks as reasoning on /v1/responses (and /v1/chat/completions)` #68374, not a close.the underlying data (parse + privacy-gated delivery); the channel-preview UX [Feature]: claude-cli live-session — stream thinking deltas + incremental assistant text to channel preview (parity with Codex progress streaming) #97526 asks for already
exists generically post-fix(telegram): distinguish and render streamed reasoning/commentary progress lanes #98907 and picks this data up for free once merged.
Real behavior proof (required for external PRs)
Behavior addressed: CLI-backed (claude-stdio) agent turns now surface native reasoning through the
standard thinking stream and durable-reasoning payload, gated by
/reasoningoff/stream/on exactly likeembedded-agent reasoning.
Real environment tested: An isolated gateway (
~/openclaw-p6-home, port :19791, channels off, ownOPENCLAW_HOME) running an authenticatedclaude-cli/claude-sonnet-4-6agent, with a tee shim on thereal
claudebinary (v2.1.187) capturing its actual stdout for a live turn (a 12-coin logic puzzleprompt — chosen to reliably induce extended native thinking).
Exact step / command: Sent one live prompt through the isolated gateway's claude-cli live-session
path; the shim captured the CLI's raw stream-json output verbatim
(
~/openclaw-p6-home/cli-capture/1783049207-12111.stdout, 119 KB, 251 raw JSONL lines). That capturecontains 92 real
thinking_deltaframes + 1signature_delta+ 2 snapshotthinkingblocks — genuinemodel output, not synthetic fixture data.
Evidence (boundary-injection method — disclosed, see "what was not tested" below): Rather than
re-running a full gateway→Discord/Telegram round trip for the gating differential (channel bot auth +
live posting adds environment setup without adding proof strength once the gate code itself is read
directly — see next paragraph), the SAME 92 real captured frames were fed through the actual, unmodified
production parser (
createCliJsonlStreamingParser) and the actual, unmodified dispatch bridge(
runCliAgentWithLifecycle/createReasoningTextBridge) via the project's existing vitest mockingharness (same pattern as
agent-runner-cli-dispatch.test.ts), and the resulting window-stream events +durable payload were passed through the REAL downstream gate predicates, copied verbatim with citations,
from the actual channel code:
extensions/discord/src/monitor/message-handler.process.ts:631-632—reasoningDurableEnabled = reasoningLevel === "on",reasoningWindowEnabled = reasoningLevel === "stream"extensions/discord/src/monitor/message-handler.process.ts:1201— window drop:payload?.requiresReasoningProgressOptIn === true && !reasoningWindowEnabledsrc/auto-reply/reply/dispatch-from-config.ts:~3533— durable drop:reply.isReasoning === true && !reasoningPayloadsEnabledNeither gate is touched by this diff; they're the exact pre-existing gates embedded reasoning already
relies on.
Observed result (tri-state, real frames end-to-end):
This matches the intended parity table exactly:
off= nothing on any gated surface;stream= livewindow only;
on= durable payload only (no live window inonmode, matching embedded's existingdurable=on, window=streamsplit).Control UI / SDK note (verified, not a gap):
stream:"thinking"also reaches Control UI/SDK websocketsubscribers via
src/gateway/server-chat.ts, which relays the generic agent-event bus without its ownreasoningLevelcheck — this is true for embedded reasoning too (documented insrc/agents/embedded-agent-subscribe.ts: "the thinking stream always reaches the bus... display surfaces... gate presentation on their side"). Control UI applies its own client-side gate
(
ui/src/ui/views/chat.ts:2137:showReasoning = props.showThinking && reasoningLevel !== "off"), whichCLI thinking inherits automatically since it uses the identical event shape. Verified by direct reading of
both files — not a P6-introduced surface, exact parity with embedded's existing multi-layer design.
What was not tested (superseded — see Live transport proof below):
a full live gateway conversation through an actual Discord/Telegram bot— now supplied. Original disclosure kept for review history: a full live gateway conversation through an actual Discord/Telegram bot withscreen-captured render output at each
/reasoningstate (the earlier, now-superseded emit-alwaysiteration of this branch did do a live gateway run — see the superseded commit's history — but the
gating differential proven above is at the code layer these channel renderers consume from, which is
where the actual privacy decision is made).
Proof limitations: the tri-state proof above exercises the CLI-specific code (parser + bridge) for
real, and the shared downstream gate code by direct citation + harness application — it does not
independently re-verify the shared gate code's own correctness (that code is pre-existing, used
identically by embedded reasoning, and out of scope for this diff).
Before evidence: on
main(nostream:"thinking"handling incli-output.ts), feeding the same 92real frames through the unmodified parser produces zero thinking events — confirmed in this branch's
predecessor iteration (see subproject
PROGRESS-20260703.md, "Step 3 — LIVE before/after proof").Proof pack:
Live transport proof (Discord + Telegram, all three
/reasoningstates) — full live gateway runsof this branch with real
claude-cli/claude-sonnet-4-6turns (fresh/new+ explicit/modelperrun, visible in-frame; full grid with per-cell notes in
this PR comment):
/reasoningOff-state turns are full-size claude-cli sessions in the gateway log (e.g. Telegram off: 23.0s, 75
raw stream-json lines — comparable to the on runs), i.e. thinking was generated upstream and
display-gated, not absent. This closes the earlier "what was not tested" item: the live
Discord/Telegram tri-state transport run is now supplied.
Tests and validation
export PATH="/Users/marvin/node24/bin:$PATH"; node scripts/run-vitest.mjs src/agents/cli-output.test.ts src/agents/cli-runner src/auto-reply/reply/agent-runner-cli-dispatch.test.ts src/auto-reply/reply/agent-runner-execution.test.ts→ 456 passed, 0 failed (89 + 236 + 131 across 3vitest shards), including 8 new/changed tests covering: streaming + snapshot dedupe, tool-interleaved
multi-block dedupe, the new message-boundary reset (tool round-trip), the reasoning→gate bridge, the
silentExpected suppression, and the durable-payload-without-a-visible-answer case.
OPENCLAW_BUILD_ALL_NO_PNPM=1 node scripts/build-all.mjs→ clean build, no errors.issues before filing: (1) the per-message thinking-tracker reset above; (2) the durable payload was
previously dropped when a CLI turn produced thinking but no visible final answer — fixed by attaching
the durable payload whenever reasoning text exists, independent of whether other payloads exist.
Update — Round 2: queued follow-up reasoning gate
The durable
{isReasoning: true}payload synthesized for claude-cli was delivered on the queued follow-up path (resolveFollowupDeliveryPayloads) with no/reasoninggate at all, so a claude-cli queued follow-up with/reasoning offcould leak internal reasoning to the channel as an ordinary visible message.resolveFollowupDeliveryPayloadsnow takes areasoningPayloadsEnabledflag and dropsisReasoningpayloads unless it's true;followup-runner.tsthreads the realopts.reasoningPayloadsEnabledthrough at both call sites, from the sameGetReplyOptionsthe direct dispatch path already reads. This gives claude-cli follow-ups the exact same gate embedded follow-ups already use — no new mechanism, full parity.Proof:
08-followup-proof.txt— a queued-follow-up cell added to the tri-state harness, exercising the realresolveFollowupDeliveryPayloadsagainst the actual captured claude-cli reasoning payload (5637 chars): off/stream → resolver drops it, on → resolver keeps it eligible. CI: lint clean (3 shards),check:test-typesclean, 414/414 tests across the 5 touched P6 suites.Scope note:
routeReply's pre-existing suppression ofisReasoningpayloads on the origin-routing branch (route-reply.ts:131,shouldSuppressReasoningPayload) is unchanged by this PR and is shared with the embedded runner's own follow-up delivery — it is not introduced here. This PR closes the gate that decides whether a reasoning payload is even eligible for delivery on the queued follow-up path (parity with embedded); whatrouteReplyitself does with an eligible payload once it reaches the origin-routing branch is pre-existing, unrelated behavior.