The behavior fix landed on main in #104608 (fe3884a7ad): draft-stream final
pagination now routes markdownSource previews through markdownToTelegramChunks,
the same chunker the durable reply funnel uses (delivery.replies.ts
buildChunkTextResolver), and the overflow-pagination golden was re-recorded on
safe boundaries there. This adds the missing parity regression coverage:
table-driven cases for mid-word, mid-entity, mid-tag, and fenced code-block
page boundaries assert the streamed pages equal the durable chunker's HTML
chunks exactly, and that retained/current page snapshots carry the durable
plainText projection so an HTML-parse 400 degrades both funnels identically.
Formatting proof: Testbox tbx_01kx9n9q7rksfxgr5baj0esw57 check:changed format
lane passed on this diff (local worktree has no node_modules for the hook).
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Progress drafts join their rendered lines with <br>, but the Bot API
parse_mode=HTML entity set has no <br> tag — line breaks must be literal
newlines. Telegram therefore rejected every multi-line preview edit with
"can't parse entities", and the parse-error fallback silently downgraded
the whole streamed progress draft (all lanes) to unformatted plain text.
Convert <br> to newlines in the parse-mode transport branch, matching what
the rich-message branch already does.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Render Telegram progress draft rows with clean plain previews while preserving Telegram HTML formatting through parse_mode.
The progress HTML path now stays transport-owned, including richMessages=false progress messages, while debug/plain/sanitized text remains readable without raw markup.
Thanks @snowzlmbot!
Restore readable standard Telegram text delivery by default after Bot API 10.1 rich messages rendered as unsupported in current clients. Keep native rich tables and structured messages available through the account-level richMessages opt-in, with account-aware capability advertising and documented structural limits.
Fixes#93263.
Address Clownfish follow-up on Telegram native draft finalization. Requires real streamed assistant partials before materializing drafts, clears stale native draft previews, and keeps media/buttons on normal send path.
* fix(telegram): send fresh finals for stale previews
* test(telegram): cover stale preview send fallback
* fix(telegram): keep stale archived preview fallback
* fix(telegram): clear stale active previews
* fix(telegram): reset preview state after fresh finals