fix(telegram): distinguish and render streamed reasoning/commentary progress lanes#98907
Conversation
|
Codex review: needs maintainer review before merge. Reviewed July 2, 2026, 7:32 PM ET / 23:32 UTC. Summary PR surface: Source +664, Tests +1125. Total +1789 across 9 files. Reproducibility: yes. source-level reproduction is high confidence: current main lacks the Telegram lane prefixes/markdown rendering and parse_mode HTML Review metrics: none identified. Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Next step before merge
Security Review detailsBest possible solution: Land the channel-local Telegram implementation after maintainer final review, then use the merged PR to resolve the related canonical issue and reassess the older conflicting candidate PR. Do we have a high-confidence way to reproduce the issue? Yes, source-level reproduction is high confidence: current main lacks the Telegram lane prefixes/markdown rendering and parse_mode HTML Is this the best way to solve the issue? Yes, this appears to be the best maintainable fix: it stays inside the Telegram plugin, consumes the shared progress compositor contract, and avoids new core/config surfaces. The main alternative, adding a new core progress model, is broader than needed for this channel-local rendering and lifecycle bug. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against d47308f8008c. Label changesLabel changes:
Label justifications:
Evidence reviewedPR surface: Source +664, Tests +1125. Total +1789 across 9 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
|
aaf0dc7 to
2025a5f
Compare
1406fb6 to
bd56764
Compare
ff8e62a to
a55e12a
Compare
The streaming "gerund" progress box was deleteMessage'd immediately at teardown, so on fast turns it flashed and vanished before it could be read, and the delete could race a just-persisted message (intermittently dropping the first verbose commentary). Add MIN_PREVIEW_DWELL_MS (4000ms) and schedule the delete DETACHED via setTimeout for max(0, 4000 - timeVisible), measured from when the box first appeared. The delete never awaits, so turn teardown is never stalled. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Port the dev-beta2 progress-summary work onto the compositor-era dispatch: - Post a one-line activity digest (🧠 N thoughts · 💬 N notes · 🛠️ N tool calls · ⏱️ Ns, Discord parity) as a durable message when the progress window collapses at end-of-turn, before the final answer. Counted per-burst channel-side; only window-streamed activity feeds the bar (durable rv/verbose items never do — persistent message XOR bar count), no bar for error finals or turns where the window never rendered. - Render multi-line commentary with its line structure intact (per-line markdown conversion, joined as line breaks) instead of collapsing to one run-on line; per-line conversion also keeps block markdown (setext headings) from forming across lines. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Five progress-window fixes for the Telegram streaming lane, aligning it with the Discord reference surface: - FIX 2: /reasoning on + /verbose off no longer kills the progress window. forceBlockStreamingForReasoning is now scoped to non-progress modes, so durable reasoning removes only the 🧠 lane; commentary/tools still stream and the collapse bar still posts. - FIX 3: durable thoughts render behind the 🧠 marker instead of the literal "Thinking" header. The core formatReasoningMessage output is rewritten channel-side in reasoning-lane-coordinator (no core change), keeping the italic body. - FIX 4: /verbose on no longer duplicates tool calls. canPushStreamToolProgress now yields under verbose so the durable verbose lane owns every progress surface (invariant: persistent message XOR window). - FIX 1: the progress window collapses by EDITING the existing message in place into the summary bar (draft-stream finalizeToPreview), then posts the final below — no delete + repost, which scroll-jumped the client. Falls back to clearing the window when there is no bar to collapse into. - FIX 5: message_tool_only/codex finals that bypass the in-band answer path now post the collapse bar from a cleanup-time fallback (sawProgressFinal from the dispatch counts). Tests: adapted predating dispatch/reasoning tests to the new 🧠 marker and collapse-by-edit behavior; added coverage for FIX 2 (window alive under /reasoning on), FIX 4 (no window tool dup under verbose), and FIX 5 (message_tool_only collapse bar). 220 green across bot-message-dispatch, draft-stream, progress-summary. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Live on-off finding: the collapse path was inconsistent. Edit-in-place worked in off-off/stream-off, but in on-off (tool-progress-only window, reasoning durable) finalizeToPreview returned undefined and the caller fell into a bare clear() -> deleteMessage -> Telegram focus-jump; a sibling sub-branch dropped the bar entirely. Two divergent outcomes from one fallback. Root cause: a throttled tool-progress preview could still be pending (coalesced, never sent) when the turn ended, so the window had no message id even though it had "rendered". finalizeToPreview gave up and the dispatch fallback deleted the (late-landing) window. Fixes: - draft-stream finalizeToPreview: settle the stream, then MATERIALIZE a still- pending preview (send it, as a final flush would) so the window message exists and can be edited in place. Only when no message could be established does it return undefined. (draft-stream.ts:597) - dispatch collapse: one deterministic path returning "edited" | "posted" | "none". A bar is ALWAYS surfaced when one exists — edited in place, or posted durably with ZERO deleteMessage. clear()/delete now runs ONLY for the "none" case (error final or nothing to summarize), never when a bar exists, so no collapse path can focus-jump. Split into resolveProgressCollapseSummaryLine / collapseProgressWindowIntoSummary / resetAnswerLaneAfterCollapse / teardownProgressWindow for a readable branch. (bot-message-dispatch.ts:1949) Tests: added on-off dispatch repro (tool-progress-only + durable reasoning + mid-turn rotation + final -> edits into bar, no clear, exactly one bar), the no-live-message durable-bar-without-delete case, and three draft-stream finalizeToPreview cases (edit-in-place, pending-materialize, no-window). 225 green across bot-message-dispatch, draft-stream, progress-summary; extensions/telegram typechecks clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…erred Peter isolated the on-off focus-jump precisely: when a durable 🧠 posts BELOW the streaming window, the window (now above the reasoning) is repositioned to stay newest by DELETING it and reposting below. Telegram cannot move messages, so the delete-then-repost order scroll-jumps the client. Root cause: rotateAnswerLaneAfterToolProgress rewound the tool-progress window with stream.clear(), which deletes the old message IMMEDIATELY when it has been on screen past the dwell — before the replacement message is sent. Delete-first, post-second. Fix — invert the order, preserving arrival order: - draft-stream: new rotateToNewMessageDeferringDelete() rewinds the stream so the NEXT update creates a fresh message, and schedules the superseded message's delete for AFTER it (detached, floored at 1.5s so the new message lands first). Extracted the shared deferred-delete scheduler (scheduleDetachedDelete) used by both clear() and the reposition. (draft-stream.ts) - dispatch: rotateAnswerLaneAfterToolProgress now repositions via that method instead of clear()+forceNewMessage, so no window reposition deletes before the replacement lands. (bot-message-dispatch.ts:1259) Tests: draft-stream unit tests prove the sequencing (new message sent before the old is deleted; delete deferred; no-op with no live message). Added a dispatch repro (durable 🧠 then answer text mid-turn -> reposition, no clear). Updated the predating tool-progress-rotation tests to assert the deferred-delete reposition and that any deliverer-cleanup clear() runs only AFTER the rewind. 228 green across bot-message-dispatch, draft-stream, progress-summary; extensions/telegram typechecks clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…nchor loss) Live off-off finding (no reposition involved): the final answer posted below the viewport. Cause: collapseProgressWindowIntoSummary edited the tall window bubble DOWN to a one-line bar BEFORE the final was sent; shrinking content under the viewport breaks the Telegram client's at-bottom follow, so the freshly sent final landed off-screen. Fix — swap the order in deliverProgressModeFinalAnswer: - Snapshot the bar line (resolveProgressCollapseSummaryLine, which also consumes the once-guard) BEFORE the final send, so the final's own delivery cannot perturb the counts/timer. - Send the final answer FIRST (it lands at the bottom of the anchored viewport), THEN collapse the window above it. The bar is an edit of an already-posted message that sits above the final, so shrinking it is harmless. - Failure path: if the final send skips/fails, still collapse to the bar (or tear the window down when there is nothing to summarize) so no stale window lingers. - Mark answerLane.finalized / markProgressFinalDelivered LAST, after the collapse resets lane state, so end-of-turn cleanup sees a finalized lane (stop(), not a spurious clear()). Renamed collapseProgressWindowIntoSummary -> applyProgressCollapseSummary (takes the pre-resolved line; "edited" | "posted"). Tests: updated the collapse tests to assert the new order (final delivered before finalizeToPreview); added "sends the final answer before collapsing the window into the bar" (order + counter snapshot) and "still collapses the window when the final answer send is skipped". 230 green across bot-message-dispatch, draft-stream, progress-summary; extensions/telegram typechecks clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…n churn) Progress mode now keeps ONE window message per turn, edited through every lane handover and edited into the bar only at collapse — matching Discord's single-message model. Previously the tool-progress window was rotated/repositioned to a fresh bubble on every interim answer chunk and on the tools->text handover, even though progress mode never renders interim answer text into the window (updateDraftFromPartial already returns early for the answer lane). Each rotation spawned a new message; that churn was the on-off jump's underlying source. Fix — progress-mode-gated so the rotation branches fire only where they must (block/partial); block/partial and finalized-rotation paths are untouched: - prepareAnswerLaneForText returns early in progress mode — no rotate/reposition for interim answer text that never displays. (bot-message-dispatch.ts:1288) - prepareAnswerLaneForToolProgress no longer rotates in progress mode; the tool lane always edits the same message (rotate stays for block/partial where answer text streamed first). (bot-message-dispatch.ts:1064) - the interim answer-block path no longer rotates the window to a new bubble in progress mode. (bot-message-dispatch.ts:2356) Rotation/delete paths REMAIN (all post-first / deferred-delete per cc9c2b1) only for: block mode, forceNewMessage after a finalized answer (new turn), error teardown with nothing to summarize, and superseded-generation cleanup. Collapse uses task-9 order (final first, then edit the one window into the bar in place); zero deleteMessage in the happy path. The end-of-turn forceNewMessage-after- collapse is unchanged (end-of-turn, not mid-turn churn). LOC: net +~24 prod lines, all justified — three small streamMode==="progress" guards plus the invariant comments AGENTS.md requires for intentional caller differences. The rotation branches are shared with block/partial and cannot be deleted, only gated. Tests: removed the now-obsolete task-8 "repositions the tool-progress window" test (that path no longer runs in progress mode); added "keeps a single stationary window when text follows durable reasoning" and a multi-boundary turn "uses one stationary window message across a multi-boundary turn (commentary->tool->commentary->tool->final)" asserting one window message id used throughout, edited N times, zero deletes, one bar edit last, final posted before the bar edit. 231 green across bot-message-dispatch, draft-stream, progress-summary; extensions/telegram typechecks clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Progress mode's streaming window is a pure activity log (Discord parity): it carries only 🧠/💬/🛠️ progress lines, and the answer appears once, as the final message below the collapsed bar. Answer PARTIALS were already suppressed (updateDraftFromPartial early-returns for the answer lane), but intermediate answer BLOCKS (info.kind === "block", before the final) still streamed into the window via deliverLaneText, so interim assistant prose flashed inside the working bubble mid-turn. Fix: in the deliver() answer-block branch, suppress plain interim answer blocks in progress mode — buffer them (like the existing skipTextOnlyBlock path) so they still feed the final/collapse, and skip the draft-stream delivery. Media, approval, and button blocks are not plain interim prose and fall through to normal delivery. With plain blocks now suppressed upstream, the block-branch's progress rotation guard only guards those remaining media/button blocks; comment updated to say so. This is the product decision Peter approved (interim answer-block visibility in the window); it completes the single-message model so the window truly only ever shows progress lines. Tests: added "never streams an interim answer block into the progress window (Discord parity)" — an interim block never reaches update/updatePreview and never appears in delivered replies, while the final answer is delivered below. 233 green across bot-message-dispatch, draft-stream, progress-summary; extensions/telegram typechecks clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Addresses five codex red-team findings on the streamed progress-window
collapse bar. Channel-side only; no core src/** changes.
F1 (transcript pollution): the collapse bar went through
sendPayload({durable:true}), which unconditionally passed the transcript
mirror to the deliverReplies fallback, writing the cosmetic digest
("💬 1 note · 🛠️ 1 tool call · ⏱️ 4s") into the session transcript so the
model read it back as its own prior turn. Added a mirrorTranscript option to
sendPayload; the bar now sends durably with mirrorTranscript:false. Discord
parity: its summary bar (monitor/reply-delivery.ts deliverDiscordReply →
sendDurableMessageBatch) has no transcript-mirror seam at all. Real finals
keep mirroring.
F4 (tool overcount): progressSummary.noteToolCall() fired for ANY start-phase
tool, but the compositor renders a line only for work tools
(isChannelProgressDraftWorkToolName) and only when toolProgress is on, so
codex/message_tool_only turns showed "🛠️ 1 tool call" with no tool line. The
count is now gated by the same public work-tool-name check plus
streamToolProgressEnabled; non-counting tool starts still close the
reasoning/commentary bursts as a boundary.
F2 (silent collapse drop): finalizeToPreview ignored a false return from the
in-place edit (flood-wait 429 / terminal error), so applyProgressCollapseSummary
assumed "edited", cleared state, posted no bar, and left the tall window.
finalizeToPreview now returns undefined when the edit did not apply, so the
dispatch falls back to the existing durable-post path.
F3 (cosmetic send fails the turn): the cleanup-time deliverProgressCollapseSummary
could throw (429/network) and propagate out of dispatch after the real final
already delivered. The bar send is now wrapped: failures log via logVerbose and
never fail the turn; the once-guard is preserved.
F5 (ghost-preview race): rotateToNewMessageDeferringDelete rewound while a FIRST
send was still in flight; the late send landed as superseded {retain:true}, which
the dispatch handler kept as an orphaned stale bubble. A reposition now records
the in-flight generation and deletes its late-landing message (deferred), while
forceNewMessage's retain-as-durable-chunk contract is unchanged.
Tests: added unit tests for each invariant (bar delivered but absent from the
transcript mirror; message-tool start → no 🛠️ count and toolProgress-off → no
count; failed edit → undefined; bar send throw → turn still succeeds; reposition
race → superseded send deleted not retained). Telegram suites green
(bot-message-dispatch 165, draft-stream 58, progress-summary 16); extensions
tsgo clean for telegram.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Post-rebase onto main, check-test-types/lint failed on our touched test files (runtime unaffected): - core's onVerboseProgressVisibility now takes a thunk (isActive: () => boolean); the dispatch test passed a bare boolean. Pass () => true. - our TelegramDraftStream additions (finalizeToPreview, rotateToNewMessageDeferringDelete) are required members; the QA-e2e mock streams omitted them. Add both to every mock stream. - drop a redundant 'as string' cast (oxlint no-unnecessary-type-assertion). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
ClawSweeper review finding: F3 only wrapped the cleanup-fallback bar send. The normal path applyProgressCollapseSummary awaited an unguarded durable send when finalizeToPreview could not edit in place, and sendPayload throws durable.error on delivery failure — so a cosmetic summary-bar flood-wait could propagate and fail an otherwise-complete turn (merge-risk: message-delivery). Route BOTH cosmetic-bar sends through one shared guarded helper postCosmeticSummaryBar (swallow + logVerbose), so neither the cleanup path nor the finalizeToPreview-miss path can fail turn delivery. Add the missing regression: no-live-message fallback bar send throwing keeps the turn alive and the final answer delivered. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
8b5300a to
8e5bcf6
Compare
What Problem This Solves
On Telegram, the streamed progress window (the live "thinking" preview shown while a reply is generated) was broken in several visible ways compared to Discord:
🧠/💬markers — a user watching a streamed turn could not tell the model's reasoning from its commentary. Discord renders reasoning italic under🧠and commentary upright under💬.**bold**, backticks, and_…_in lane text either surfaced as literal characters or were silently flattened to plain text.<br>broke every multi-line preview at the transport. Progress drafts join rendered lines with<br>, but Bot APIparse_mode=HTMLhas no<br>tag. Telegram rejected every multi-line preview edit with "can't parse entities", and the parse-error fallback silently downgraded the whole draft to unformatted plain text — so streamed progress formatting effectively never rendered on non-rich accounts./reasoning onit repositioned below the durable reasoning block, yanking the client's scroll position up by pages and sometimes posting the final answer off-screen.Why This Change Was Made
Channel-side only (
extensions/telegram/**); no coresrc/**changes. One focused commit per concern:🧠/💬lane prefixes andcommentaryItalics:falseto the shared progress-draft compositor (mirroring the Discord channel), and render lane bodies throughrenderTelegramHtmlText(theparse_mode=HTML-safe converter). Lane lines collapse to one line first (multi-line commentary otherwise forms block markdown — a\n\n---\n\nseparator turns the paragraph above into a setext<h2>, which Telegram rejects). Long lines clip inside the whole-line_…_wrapper so truncation cannot chop the closing underscore and degrade italic to plain.<br>joins to literal newlines in theparse_mode=HTMLtransport branch, matching what the rich-message branch already does.🧠 N thoughts · 💬 N notes · 🛠️ N tool calls · ⏱️ Ns. Counts reflect only streamed content (durable/verbose onmessages own their own delivery and are excluded — persistent-message XOR window-count invariant).Non-goals: final-answer rendering and the durable (
/verbose on) lanes are untouched — this is only the streamed progress window.User Impact
Telegram users watching a streamed reply now see a formatted, stable progress window: reasoning (
🧠, italic) clearly distinct from commentary (💬, plain), the model's Markdown rendered (bold step names, monospace commands) instead of leaking or being stripped, a stationary box that no longer deletes/reposts or jumps the scroll, and a persistent one-line activity digest left behind on collapse. Biggest improvement for deepseek, which narrates progress almost entirely through its markdown-heavy reasoning lane.Maintainer-ready classification
extensions/telegram/**; no core, no plugin-SDK, no config/default surface added, no new env var.parse_mode-safe renderer, and emitted<br>into aparse_mode=HTMLtransport that has no such tag. The scroll-jump was a window-lifecycle defect (delete-then-repost across lane handovers).createChannelProgressDraftCompositor(src/channels/progress-draft-compositor.ts) — the same source Discord consumes; this change makes Telegram a consumer of that contract rather than diverging. The tool-count gate reuses the compositor's own public predicateisChannelProgressDraftWorkToolName(viaopenclaw/plugin-sdk/channel-outbound), so the bar's🛠️tally cannot drift from what the compositor actually renders.deliverDiscordReply→sendDurableMessageBatch) has no transcript-mirror seam, so the Telegram bar was hardened to match (see F1 below) — the invariant now holds identically on both channels.git diff --numstatnet prod LOC is small and localized; new files areprogress-summary.ts(tracker + formatter) and its test. No new fallback readers, no config compat.main(post-2026.7.1) includingb07fd6f1b4 fix(telegram): keep group history always on; the only overlap was an import block (dropped the now-deletedgroup-history-contextimport, kept upstream'sselectTelegramGroupHistoryAfterLastSelf). No open PR touches the progress-window path.Real behavior proof (required for external PRs)
Behavior addressed: streamed progress-window lane rendering,
<br>transport, collapse summary bar, stationary-window (no scroll-jump), interim-answer suppression.Real environment tested: dev gateway built from this exact branch head, Telegram Desktop, fresh session per turn (
/new).Exact steps:
/reasoning stream+ markdown-heavy prompt for the lane render;/reasoning on+/verbose offfor the collapse-bar + stationary-window turn.Evidence (runtime capture):
Summary bar + lane gating (
/reasoning on,/verbose off, gpt-5.5) — a single frame shows the collapse bar💬 2 notes · 🛠️ 2 tool calls · ⏱️ 61s, the durable 🧠 reasoning block rendered italic with bold/monospace preserved, and the correct gating: durable (/reasoning on) reasoning renders in full but is not counted in the bar, while streamed notes/tool calls are — matching the persistent-message XOR window-count invariant.Lane rendering close-ups (
/reasoning stream, markdown-heavy prompt) — reasoning🧠italic with**bold**rendered bold-italic and backtick commands monospace, all inside the italic wrapper; commentary💬upright with bold step names and monospace commands (visual contrast with the italic reasoning):🧠 reasoning zoom · 💬 commentary zoom · full-stage: prompt · 🧠 reasoning · 💬 commentary · 💬 step 3
Observed result:
🧠reasoning renders italic with bold/monospace preserved inside the wrapper;💬commentary renders upright; the window stays a single stationary message and collapses in place into🧠 N thoughts · 💬 N notes · 🛠️ N tool calls · ⏱️ Ns; the final answer arrives before the bar with no scroll-jump.4s dwell / cleanup — terminal proof (this head): the streaming preview is kept on screen for
MIN_PREVIEW_DWELL_MS = 4000from first appearance before a detached cleanup delete (never before the dwell, never blocking teardown), asserted directly on the current head byextensions/telegram/src/draft-stream.test.ts:Complemented by CI's Mantis native Telegram Desktop before/after capture on this same head (
Overall: true), which exercises the live preview → cleanup flow.What was not tested: streamed reasoning-lane italics were exercised on a reasoning-bearing provider in an earlier build and by unit tests; the latest sanity turn used a model that emitted empty reasoning, so its streamed-🧠 italic frame is covered by the unit suite + the earlier live capture rather than the final sanity capture. Group DM / forum-topic progress windows were not separately re-captured (unaffected by this change).
Proof limitations: captures are from a dev gateway on a developer Telegram account (names/emails redacted), not a production deployment.
Red-team hardening (this branch)
An adversarial pass produced 5 findings, all fixed in
fix(telegram): red-team hardening for the progress collapse bar:durable:true→transcriptMirror), so the model could read its own cosmetic activity digest back as a prior turn. Discord's bar has no such seam. Fix:mirrorTranscript:falseon the two bar sends; real finals keep mirroring.🛠️counter incremented on non-work tools (message/reply/etc.) before the compositor's work-tool gate. Fix: gate the count withisChannelProgressDraftWorkToolName+toolProgressso the tally matches what the window renders.finalizeToPreviewignored a failed edit (flood-wait 429). Fix: returnundefinedso the caller falls back to a durable bar post.logVerbose.repositionedSendGenerationsguard deletes the late-landing send deferred.Follow-up (ClawSweeper review finding): F3 originally guarded only the cleanup fallback bar send. The normal path (
applyProgressCollapseSummary) awaited an unguarded durable send whenfinalizeToPreviewcould not edit in place, andsendPayloadthrows on delivery failure — so a cosmetic bar flood-wait could fail an otherwise-complete turn (merge-risk: message-delivery). Both cosmetic-bar sends now route through one shared guarded helperpostCosmeticSummaryBar(swallow +logVerbose); added the regression for the no-live-message fallback send throwing while the final answer still delivers.Tests and validation
node scripts/run-vitest.mjs run --config test/vitest/vitest.extension-telegram.config.ts extensions/telegram/src/{bot-message-dispatch,draft-stream,progress-summary}.test.ts→ 239 passed (3 files). New coverage: lane markdown rendering in both lanes, clipped-long-reasoning stays italic, multi-line commentary staysparse_mode-safe (the setext-<h2>trap),<br>→newline transport, ≥4s dwell timing, collapse-bar counts/format, stationary-window reposition + deferred-delete, one test per red-team finding (F1 mirror-absent, F4 counts, F2 failed-edit fallback, F3 throw-swallow, F5 reposition-race), and both cosmetic-bar fallback sends throwing without failing the turn (cleanup + no-live-message paths).main(post-2026.7.1); telegram suites green on the rebased head.🤖 Generated with Claude Code