Centralize Telegram text delivery: page planning and reply-target progression move into telegram-text-delivery.ts, shared by the durable send, edit, and streaming-final paths; bot/reply-threading.ts and per-path split/page logic are deleted. Formatted-to-plain recovery routes through withTelegramPlainFallback everywhere. Restores the projection-cursor invalidation contract on terminal multi-page failures (with regression). Net -321 production LOC. Live E2E on the PR: formatted entity path, plain-fallback recovery, and 2-page chaining.
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Telegram send-funnel parity invariant (durable and streaming funnels must degrade formatted sends identically) was prose-enforced and had drifted: draft previews and message edits only degraded on HTML-parse 400s, not empty-content 400s. One orchestrator (withTelegramPlainFallback) now owns classification, guard, logging, and plain-chunk planning for all 11 call sites; withTelegramHtmlParseFallback and buildTelegramPlainFallbackPlan are deleted. Net -29 production LOC. Behavior change: previews/edits recover empty-content 400s as plain text; degrade logs unify to `degrade=plain-fallback:<trigger>`.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replies, previews, and media in channel Direct Messages topics now remain in their originating topic. Bot-private and forum topic routing remains unchanged.
Verify Telegram accepted-message topics before follow-up sends, preserving partial-delivery evidence instead of trusting requested routing.
Co-authored-by: Lu Wang <7668944+wangwllu@users.noreply.github.com>
* fix(telegram): linkPreview:false is ignored on streamed replies
Replies delivered through draft streaming still unfurled the first URL
even with channels.telegram.linkPreview: false. Non-streamed sends
already honored the flag.
createTelegramDraftStream never received linkPreview and never set
link_preview_options, on either its initial sendMessage or its
editMessageText calls. Finalization could not clean it up: it skips the
final edit when the streamed draft text already equals the final text,
which is the common case, so the draft message stays as-is with the
preview attached.
Passes linkPreview from telegramCfg into the draft stream and applies
link_preview_options: { is_disabled: true } on the send and on every
edit, since an edit that omits the field re-enables the preview
server-side. Rich messages keep expressing this as skip_entity_detection
at render time, which already worked.
Call arity is preserved when no preview options apply, so existing edit
assertions are unaffected.
Closes#111525
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(telegram): cover streamed reply option parity
Co-authored-by: Zakaria Rahali <zakariarahali288@gmail.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
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