Skip to content

fix: Telegram double delivery, dead-bot crash, 429s, out-of-order chunks - #76

Merged
saucam merged 5 commits into
mainfrom
fix/telegram-stream-dup
Jul 2, 2026
Merged

fix: Telegram double delivery, dead-bot crash, 429s, out-of-order chunks#76
saucam merged 5 commits into
mainfrom
fix/telegram-stream-dup

Conversation

@saucam

@saucam saucam commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes (from the rendering/session-scaling audit)

P0 — every streamed assistant/thinking block delivered twice: the daemon's final full-message broadcast was sent immediately, but the delta buffer for that messageId was never dropped, so the idle flush re-sent the same content on every normal turn. The new StreamRelay treats the final broadcast as authoritative: it sends only the unflushed tail and deletes the buffer.

P0 — one thrown handler error permanently killed the bot: grammY's default error handler calls stop() and rethrows — long polling never resumes. Deterministic trigger: /ls rendered session status and workdir unescaped in MarkdownV2 (a tool_running underscore → Telegram 400 → crash). Now: bot.catch logs and keeps polling; status escaped with escMd, workdir with a new escCode (it sits in a code span, where only backtick/backslash are special — plain escMd would render literal backslashes).

P1 — no 429 handling: @grammyjs/auto-retry transformer honors retry_after instead of the old fire-and-forget .catch(() => {}) silently dropping flood-limited messages.

P1 — mid-stream buffer kill: any interleaving broadcast (e.g. a parallel subagent's tool_call) used to flush-and-delete the live buffer — later deltas were dropped and the prefix duplicated. Replaced with per-buffer flushed offsets: interleavings flush the unflushed tail, the buffer stays live, delivery is exactly-once and in-order.

P1 — thinking blocks silently dropped: parse_mode: Markdown on raw model reasoning 400s on any unbalanced */_/backtick; now retries as plain text.

P2 — out-of-order chunks: all sends flow through one promise chain — chunks of >4000-char messages go sequentially, split on line boundaries, and "✅ Done." can no longer overtake the content it announces.

P2 — tool ✓/✗ never rendered: completion/cancellation arrives as a toolStateUpdate delta the old handler ignored; the relay remembers tool names from the tool_call broadcast and renders terminal states.

P2 — buffers discarded on switch/detach: buffered undelivered content now flushes with a "✂️ Detached mid-stream" marker instead of vanishing.

Design

Streaming logic extracted from index.ts into stream.ts (StreamRelay behind a minimal RelayApi seam), so the 30 new tests drive the real production code with a fake Telegram API instead of mirroring the logic. The bot.catch test drives a real grammY Bot through handleUpdates and hangs without the fix.

Tests

30 new across telegram-stream.test.ts, telegram-bot.test.ts, and updated telegram-session-switch.test.ts: exactly-once delivery (4 variants), thinking exactly-once, interleaved tool_call no-dup/no-drop/in-order, /ls escaping, sequential chunking + Done-marker ordering, toolStateUpdate rendering (✓/✗/cancelled), auto-retry on 429, flush-on-detach markers.

Full suite: 687 pass / 0 fail. biome + tsc clean.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Improved Telegram message delivery so streamed updates stay in order, are chunked safely, and continue working across session switches and detaches.
    • Added clearer session, tool, and status messages with safer text formatting.
  • Bug Fixes

    • Fixed buffered output from being lost when switching or detaching sessions.
    • Improved handling of Telegram API errors and rate limits so polling and message sending are more resilient.
    • Prevented formatting issues that could break Markdown messages.

saucam and others added 3 commits July 3, 2026 00:05
The Telegram frontend double-delivered every streamed assistant/thinking
block: the final session.message rebroadcast sent the full content but
never dropped the delta buffer, so the idle flush re-sent it. Worse, any
unrelated session.message (e.g. a tool_call mid-stream) flush-deleted the
LIVE buffer, dropping all later deltas and duplicating the flushed prefix
when the final broadcast arrived. Long messages also chunked in parallel
(out-of-order >4000-char output, "Done." overtaking content), and thinking
flushes died silently on Telegram markdown parse errors.

Extract the buffering into StreamRelay (stream.ts):
- final full broadcast is authoritative: send the unflushed tail, drop the buffer
- interleaving messages partial-flush live buffers via a per-buffer flushed
  offset instead of deleting them — later deltas keep appending
- all sends go through one promise chain: chunks sequential, line-boundary
  splits, "Done." queued after the flush
- thinking sends retry as plain text when Markdown parsing fails

Tests exercise the real StreamRelay through the RelayApi seam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, honor 429s

Any thrown handler error permanently stopped long polling — grammy's
default error handler stops the bot and re-throws. A deterministic trigger
existed in /ls, which rendered session status and workdir unescaped in
MarkdownV2: a tool_running status underscore is a Telegram 400, which
killed the bot for good.

- install bot.catch to log and keep processing updates
- escape /ls status with escMd and workdir with escCode (inside a
  MarkdownV2 code span only backtick and backslash are special; escMd
  there would render literal backslashes)
- install @grammyjs/auto-retry as an API transformer so 429s wait for
  retry_after and retry instead of being swallowed by .catch(() => {})
- consolidate the duplicate esc/escMd helpers into stream.ts

Tests drive a real grammy Bot with a stubbed API transformer through
handleUpdates (the polling entry point): a 400 reply no longer stops
update processing, and a flood-limited send is retried once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ch/switch

Tool completed/cancelled states never rendered in Telegram: the daemon
broadcasts them as session.message.delta with toolStateUpdate, but the
delta handler only read contentAppend — tools showed ⚡ forever. And
session switch/detach cleared the streaming buffers outright, silently
discarding any streamed-but-undelivered content.

- remember messageId → tool name from the tool_call broadcast and render
  ✓ name / ✗ name failed / ✗ name cancelled when a toolStateUpdate delta
  arrives, flushing streamed text first so lines land in order
- replace clear() with flushAndClear() on session switch and /detach:
  buffered content is delivered with a "✂️ detached mid-stream" marker
  instead of vanishing
- update the session-switch mirror tests to the flush-before-clear
  contract

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@saucam, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 12 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f75f2501-1963-49d5-8cd7-b6ac97c57720

📥 Commits

Reviewing files that changed from the base of the PR and between 2042030 and cd82628.

📒 Files selected for processing (5)
  • src/frontends/telegram/index.ts
  • src/tests/telegram-bot.test.ts
  • src/tests/telegram-flows.test.ts
  • src/tests/telegram-session-switch.test.ts
  • src/tests/telegram-stream.test.ts
📝 Walkthrough

Walkthrough

Introduces @grammyjs/auto-retry dependency and a new StreamRelay module for Telegram message buffering/flushing with MarkdownV2 escaping helpers. Refactors the Telegram frontend to delegate streaming, chunking, and error handling to the relay, adds bot-level error catching, and adds corresponding unit/integration tests.

Changes

Telegram StreamRelay Refactor

Layer / File(s) Summary
Dependency addition
package.json
Adds @grammyjs/auto-retry version ^2.0.2 to dependencies.
StreamRelay module and Markdown helpers
src/frontends/telegram/stream.ts
Adds chunkText, StreamRelay class (buffering, flushing, chunked/thinking sends), and escMd/escCode/formatSessionLine/toolLine helpers.
Telegram frontend wiring
src/frontends/telegram/index.ts
Replaces inline streaming buffer with a per-user relay: StreamRelay, injects Bot with autoRetry(), adds bot.catch error handling, updates handlers (/ls, /new, /attach, /detach, /destroy, /search) to use relay methods and escMd/formatSessionLine, and removes legacy #flushStale/#flushBuffer/#sendChunked/esc helpers.
StreamRelay unit tests
src/tests/telegram-stream.test.ts
Adds tests for exactly-once delivery, tool-call interleaving, markdown fallback, chunkText, sequential chunked sends, tool completion rendering, and flushAndClear.
Bot-level integration tests
src/tests/telegram-bot.test.ts
Adds stubbed-bot tests for bot.catch resilience, 429 auto-retry, and formatSessionLine/escMd/escCode escaping.
Session switch/detach test updates
src/tests/telegram-session-switch.test.ts
Updates mirrored switch/detach logic and stubs to model flushAndClear and asserts buffered content is flushed before clearing.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TelegramFrontend
  participant StreamRelay
  participant RelayApi

  TelegramFrontend->>StreamRelay: handleDelta(chatId, delta)
  StreamRelay-->>StreamRelay: buffer text by messageId
  TelegramFrontend->>StreamRelay: handleMessage(chatId, message)
  StreamRelay-->>StreamRelay: flush unrelated buffer tail
  TelegramFrontend->>StreamRelay: flushIdle(chatId)
  StreamRelay->>RelayApi: sendMessage(chatId, bufferedText)
  StreamRelay->>RelayApi: sendMessage(chatId, "✅ Done.")
Loading
sequenceDiagram
  participant User
  participant TelegramFrontend
  participant StreamRelay

  User->>TelegramFrontend: /attach or /detach
  TelegramFrontend->>StreamRelay: flushAndClear(chatId)
  StreamRelay->>StreamRelay: deliver buffered content
  StreamRelay->>StreamRelay: clear buffers
  TelegramFrontend-->>User: escMd-escaped confirmation reply
Loading

Possibly related PRs

  • saucam/codeoid#29: Both PRs modify Telegram /attach and /detach handling in src/frontends/telegram/index.ts to clear or flush per-session buffered streaming state on session switch.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main Telegram reliability fixes, including duplicate delivery, bot crashes, 429 retries, and chunk ordering.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/telegram-stream-dup

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.52607% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 79.93%. Comparing base (9233d9a) to head (cd82628).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/frontends/telegram/index.ts 98.03% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #76      +/-   ##
==========================================
- Coverage   80.72%   79.93%   -0.79%     
==========================================
  Files          56       59       +3     
  Lines        7895     8890     +995     
==========================================
+ Hits         6373     7106     +733     
- Misses       1522     1784     +262     
Flag Coverage Δ
daemon 79.93% <99.52%> (-0.79%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/frontends/telegram/stream.ts 100.00% <100.00%> (ø)
src/frontends/telegram/index.ts 68.06% <98.03%> (ø)

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/tests/telegram-bot.test.ts (1)

54-71: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

No teardown stops the parked polling loop.

fe.start() kicks off long polling against this stub, and getUpdates is parked on a promise that only settles if signal aborts (i.e., only on bot.stop()). Neither test calls bot.stop()/fe.stop() afterward, so each test leaves a dangling never-resolving promise plus an active polling loop. Bun's test runner will likely force-exit the process, but this accumulates open handles across the suite and risks hangs if run under different configurations (e.g., --watch, leak detection).

♻️ Suggested cleanup
-    const fe = new TelegramFrontend("42:TEST_TOKEN", [ALLOWED_USER], bot);
-    await fe.start(fakeContext());
+    const fe = new TelegramFrontend("42:TEST_TOKEN", [ALLOWED_USER], bot);
+    await fe.start(fakeContext());
+    // ... test body ...
+    await bot.stop().catch(() => {});

Alternatively wrap with try { ... } finally { await bot.stop().catch(() => {}); } in each it, or add an afterEach that tracks and stops the bot created per test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tests/telegram-bot.test.ts` around lines 54 - 71, The stubbed polling
loop in makeStubbedBot is left running because fe.start() is never paired with a
stop, so each test leaks an active getUpdates promise. Update the telegram-bot
tests to ensure every Bot created by makeStubbedBot is torn down after each
test, ideally by calling bot.stop() or fe.stop() in a finally block or shared
afterEach cleanup, so the abort listener on getUpdates is triggered and no
polling handle remains open.
src/tests/telegram-session-switch.test.ts (1)

120-142: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Mock flush semantics are simplified relative to StreamRelay.flushAndClear.

makeDeps().flushAndClear only pushes non-empty buf.content and clears the map; it doesn't model the real implementation's partial-flush tracking (buf.content.length > buf.flushed) or emit the "✂️ Detached mid-stream" marker. This is fine as a higher-level mirror (the real flush semantics are covered in telegram-stream.test.ts), but worth keeping in mind if this mock and the production StreamRelay drift further.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tests/telegram-session-switch.test.ts` around lines 120 - 142, The
`makeDeps().flushAndClear` stub in `makeDeps` is too simplified compared with
`StreamRelay.flushAndClear`, so update it to mirror the production behavior more
closely by only flushing newly produced content based on the buffer’s flushed
state and by handling the detached mid-stream marker consistently. Keep the
helper aligned with the real `flushAndClear` semantics used by `StreamRelay` so
this test stays representative if the implementation changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/frontends/telegram/index.ts`:
- Around line 349-351: The /attach and /detach flow in the Telegram frontend is
confirming the session switch before the buffered relay messages finish sending.
In `src/frontends/telegram/index.ts`, after calling
`state.relay.flushAndClear(chatId)` in the attach/detach handling path, await
`settle()` on the relay before sending the user-visible confirmation so the old
buffered output and detached marker are delivered first. Use the existing
`flushAndClear` and `settle` relay methods in the switch/detach handler to keep
message order correct.
- Around line 739-742: `#forwardToChat` currently forwards any message as long
as user state exists, so stale daemon callbacks from a previous session can
still reach the active relay after detach or session switch. Update
`#forwardToChat` to validate `msg.sessionId` against the current session
information in `state` (including `attachedSessionId`/active session identity)
before accessing `state.relay`, and return early when the session does not
match. Keep the check near `state.relay` in `src/frontends/telegram/index.ts` so
old-session messages are dropped before they are forwarded.

---

Nitpick comments:
In `@src/tests/telegram-bot.test.ts`:
- Around line 54-71: The stubbed polling loop in makeStubbedBot is left running
because fe.start() is never paired with a stop, so each test leaks an active
getUpdates promise. Update the telegram-bot tests to ensure every Bot created by
makeStubbedBot is torn down after each test, ideally by calling bot.stop() or
fe.stop() in a finally block or shared afterEach cleanup, so the abort listener
on getUpdates is triggered and no polling handle remains open.

In `@src/tests/telegram-session-switch.test.ts`:
- Around line 120-142: The `makeDeps().flushAndClear` stub in `makeDeps` is too
simplified compared with `StreamRelay.flushAndClear`, so update it to mirror the
production behavior more closely by only flushing newly produced content based
on the buffer’s flushed state and by handling the detached mid-stream marker
consistently. Keep the helper aligned with the real `flushAndClear` semantics
used by `StreamRelay` so this test stays representative if the implementation
changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a51b7a75-2ffa-484f-880c-d379f4ac3caf

📥 Commits

Reviewing files that changed from the base of the PR and between 9233d9a and 2042030.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock, !**/*.lock, !bun.lock
📒 Files selected for processing (6)
  • package.json
  • src/frontends/telegram/index.ts
  • src/frontends/telegram/stream.ts
  • src/tests/telegram-bot.test.ts
  • src/tests/telegram-session-switch.test.ts
  • src/tests/telegram-stream.test.ts

Comment thread src/frontends/telegram/index.ts
Comment thread src/frontends/telegram/index.ts Outdated
…tale session broadcasts

Address CodeRabbit review on PR #76:

- flushAndClear only queues sends on the relay chain, so the
  "Attached to …" / "Detached from …" confirmations (sent outside the
  relay) could overtake the flushed buffer tail and interruption marker.
  Await relay.settle() after flushing in both handlers.
- #forwardToChat forwarded any broadcast as long as user state existed;
  an in-flight callback from the old session arriving after a detach or
  switch reached the current relay/chat — a stale idle status would even
  flush the new session's buffers and print a bogus "✅ Done.". Gate
  session-scoped messages on msg.sessionId === attachedSessionId via the
  exported isStaleBroadcast helper (messages without a sessionId are
  never dropped).
- test hygiene (review nitpick): stop the stubbed bot in a finally block
  so no parked getUpdates polling handle leaks; the stub answers grammy
  stop()'s signal-less offset-save getUpdates immediately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@saucam

saucam commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review in 45f0ca5:

  • Wait for flushed relay output before confirming switch/detach (Major): applied — /attach and /detach confirmations went out via ctx.reply directly and could overtake the relay's queued flush + ✂️ marker. Both handlers now await state.relay.settle() after flushAndClear(), with ordering tests asserting disconnect → flush → settle → confirm for both paths.
  • Drop stale daemon messages after detach or session switch (Major): applied — and it was worse than flagged: a stale session.status_change: idle from the OLD session would flush the NEW session's buffers and print a bogus '✅ Done.'. New exported isStaleBroadcast helper gates #forwardToChat (drops session-scoped messages whose sessionId ≠ current attachment; sessionId-less messages always pass). 5 unit tests cover switch/detach/stale-idle/passthrough cases; the attach-window race was checked — the final full broadcast recovers any dropped delta, so exactly-once holds.
  • Bot-test teardown (nitpick): applied — both bot tests await bot.stop() in finally (this surfaced and fixed a test-stub bug around grammy's signal-less offset-saving getUpdates).
  • Simplify mirror flush semantics (nitpick): rejected — the mirror deliberately models the observable handler contract; the real StreamRelay partial-flush/marker semantics are covered against the actual implementation in telegram-stream.test.ts, and duplicating them into the mirror recreates the drift risk the comment warns about.

695 pass / 0 fail, biome + tsc clean.

@saucam

saucam commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

Review addressed through 45f0ca5:

  • Await relay flush before switch/detach confirmations (Major): applied — confirmations went out via ctx.reply directly and could overtake the flushed tail + ✂️ marker; both handlers now await state.relay.settle() after flushAndClear.
  • Drop stale broadcasts after detach/switch (Major): applied with two hardenings beyond the suggested diff — the gate (isStaleBroadcast, sessionId-less messages never dropped) is set before the daemon attach call so live broadcasts in the attach window aren't eaten, and a failed attach restores the previous attachment instead of faking a detach. Worst pre-fix case: a stale idle from the old session flushing the new session's buffers and printing a bogus "✅ Done.".
  • Polling-loop teardown (nitpick): applied — and it surfaced a real stub bug: grammY's stop() issues a final signal-less getUpdates the parked stub hung on; the stub now answers it immediately and aborts the parked call.
  • Simplify the mirror flushAndClear (nitpick): skipped — the mirror models the observable flush-then-clear contract; the partial-flush offsets and ✂️ marker are tested against the real StreamRelay, and duplicating the implementation into the mock adds drift surface, not coverage.

Suite: 695 pass / 0 fail, biome + tsc clean.

codecov/patch failed at 76.82% — the PR's handler wiring in
src/frontends/telegram/index.ts (attach/switch/detach, forwardToChat,
/ls//new//destroy//search) was untested because every path sits behind
/auth. Add telegram-flows.test.ts: a local Bun.serve JWKS endpoint plus an
ES256-signed JWT drives the REAL verifyToken/ZeroID verification, and a
recording fake SessionManager captures the AttachedClient so daemon
broadcasts exercise the real forwarding path via the injectable-bot seam.

Covers: authenticated /ls escaping, /new, /destroy, oversized /search
plain-text fallback, attach + streamed turn + stop button + idle Done,
stale-session gating, approval prompt + Approve tap, switch/detach
flush-before-confirm ordering, and failed re-attach restore.

Local patch coverage for index.ts changed lines: 50/51 (98%).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@saucam
saucam merged commit ac8a076 into main Jul 2, 2026
5 checks passed
@saucam saucam mentioned this pull request Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant