Skip to content

fix(telegram): split-and-deliver oversized edits instead of silent truncation (salvage of #19537)#23576

Merged
teknium1 merged 3 commits into
mainfrom
salvage/pr-19537
May 11, 2026
Merged

fix(telegram): split-and-deliver oversized edits instead of silent truncation (salvage of #19537)#23576
teknium1 merged 3 commits into
mainfrom
salvage/pr-19537

Conversation

@teknium1

@teknium1 teknium1 commented May 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Telegram's edit-based streaming path could silently drop everything past the 4096-character limit on long replies. The adapter caught the BadRequest: message_too_long error, best-effort truncated the content with , and returned SendResult(success=True) — so the stream consumer believed the full edit was delivered and never recovered.

Instead of returning failure (which races against the next streaming tick and can produce duplicates), the adapter now splits and delivers: edit the existing message with chunk 1, send the rest as continuation messages threaded as replies. User always gets the full reply in correct order; consumer state stays consistent.

How the fix works

edit_message(content) where utf16_len(content) > 4096
  ↓ pre-flight detects overflow
_edit_overflow_split(chat_id, message_id, content)
  ↓ truncate_message(len_fn=utf16_len) → ["chunk 1 (1/3)", "chunk 2 (2/3)", "chunk 3 (3/3)"]
  ↓ edit existing message_id with chunk 1
  ↓ send_message(reply_to=msg_id, text=chunk 2) → msg_id=A
  ↓ send_message(reply_to=A, text=chunk 3) → msg_id=B
  ↓
SendResult(success=True, message_id=B, continuation_message_ids=(A, B))

Stream consumer then advances _message_id = B, resets _last_sent_text, and fires on_new_message so subsequent tool-progress bubbles linearize below the new continuation. Subsequent edits target B, the most recent visible message.

Three layers (overflow handling on the edit path)

Layer Where Role
1. Prevent gateway/stream_consumer.py (#23455) UTF-16 length-aware splitting decides to chunk before any single edit grows past the limit.
2. Recover gateway/platforms/telegram.py (this PR) If a single edit still overflows (parse_mode inflation, etc.), split-and-deliver across the existing message + continuations.
3. Sidestep gateway/platforms/telegram.py (#23512) Native draft streaming for DM Telegram bypasses edit_message entirely; this layer only matters for groups, topics, and the per-response fallback after a draft failure.

Changes

  • gateway/platforms/base.py: SendResult.continuation_message_ids: tuple = (). Empty by default; populated by adapters that split-and-deliver. Backwards-compatible.
  • gateway/platforms/telegram.py:
    • edit_message(): pre-flight on utf16_len overflow; reactive on Telegram's message_too_long error after parse_mode formatting; both route through _edit_overflow_split.
    • _edit_overflow_split(): new helper. Edits original message_id with chunk 1 (with parse_mode + plain-fallback when finalize=True), sends remaining chunks via send_message threaded as replies, returns SendResult(success=True, message_id=<last>, continuation_message_ids=(...)).
  • gateway/stream_consumer.py: _send_or_edit advances _message_id to the last continuation, resets _last_sent_text, and fires on_new_message when continuation_message_ids is populated. getattr(result, "continuation_message_ids", ()) keeps backwards compat with SimpleNamespace mocks in existing tests.
  • tests/gateway/test_telegram_format.py: regression test asserts split-and-deliver contract.
  • tests/gateway/test_stream_consumer.py: TestEditOverflowSplitAndDeliver asserts the consumer-side _message_id advance + on_new_message firing.
  • scripts/release.py: AUTHOR_MAP entries for kjames2001.

Improvements during salvage

  • Evolved fail-on-overflow → split-and-deliver. The original PR returned success=False so the consumer's fallback path could re-send. That contract races against the next streaming tick and can produce duplicates or gaps. Split-and-deliver guarantees every byte of the user's reply lands in correct order with no consumer-side recovery dance.
  • Dropped scope-creep model-picker hunk at gateway/platforms/telegram.py:2114-2117 that silently killed the "X more available" hint by hardcoding total = len(models). Not mentioned in the PR title or body.
  • Restored timeout-aware retryable signal in send()'s fallthrough catch block.
  • Added regression tests for both adapter and consumer sides — the original PR shipped no tests.

Validation

tests/gateway/test_telegram_format.py + test_stream_consumer*.py: 204/204 pass.

Closes #19537.
Authored by @kjames2001; design evolved from fail-on-overflow to split-and-deliver during salvage.

@github-actions

github-actions Bot commented May 11, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: salvage/pr-19537 vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 8136 on HEAD, 8129 on base (🆕 +7)

🆕 New issues (1):

Rule Count
unresolved-attribute 1
First entries
gateway/platforms/telegram.py:1735: [unresolved-attribute] unresolved-attribute: Attribute `edit_message_text` is not defined on `None` in union `Unknown | None`

✅ Fixed issues: none

Unchanged: 4270 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

kjames2001 and others added 3 commits May 10, 2026 21:59
…uncation

When edit_message_text exceeded Telegram's 4096 UTF-16 codepoint limit,
the adapter caught the BadRequest, best-effort truncated the content
with '…', and returned SendResult(success=True). The stream consumer
believed the full edit was delivered and never recovered, silently
dropping everything past the truncation boundary on long replies.

Returning failure isn't safe either — the consumer's existing fallback
path can race against the next streaming tick, producing duplicate
sends or gaps. Instead, the adapter now SPLITS the oversized payload
across the existing message + new continuation messages, so the user
always gets the full reply in correct order.

How it works:

1. Pre-flight: if utf16_len(content) already exceeds MAX_MESSAGE_LENGTH,
   call the new _edit_overflow_split helper directly — saves a doomed
   round-trip + a Telegram error.

2. Reactive: if Telegram still returns 'message_too_long' after the
   pre-flight (e.g. parse_mode formatting inflated the payload past
   the limit via MarkdownV2 escapes), the same helper handles it.

3. _edit_overflow_split:
   - Splits via truncate_message(len_fn=utf16_len) — same chunking the
     non-streaming send() path uses; chunks get '(1/N)' suffixes.
   - Edits the original message_id with chunk 1 (with parse_mode +
     plain-fallback when finalize=True, mirroring the main edit path).
   - Sends each remaining chunk via self._bot.send_message threaded as
     a reply to the previous chunk so the user sees them as a
     contiguous block. MarkdownV2-with-plain-fallback per chunk on
     finalize.
   - Returns SendResult(success=True, message_id=<last_chunk_id>,
     continuation_message_ids=(<chunk2_id>, <chunk3_id>, ...)) so the
     stream consumer can keep editing the most recent visible message
     and the gateway has full visibility into every message id.

SendResult contract extension:

  Added optional continuation_message_ids: tuple = () field. When
  empty (the common case), behavior is unchanged. When populated, the
  caller knows the adapter delivered across multiple platform messages.

Stream consumer integration:

  GatewayStreamConsumer._send_or_edit advances _message_id to the
  last-continuation id when it sees continuation_message_ids on a
  successful edit result, resets _last_sent_text (the new visible
  message holds only the final chunk's text), and fires
  on_new_message so tool-progress bubbles linearize below the new
  continuation rather than the original. Mirrors the openclaw #32535
  inter-tool-leak guard.

Composes with what just landed:

  - PR #23455 (UTF-16 length-aware splitting in stream consumer)
    prevents most overflows upstream by measuring text in UTF-16
    codeunits before deciding to split. This PR is the safety net at
    the adapter boundary.
  - PR #23512 (native draft streaming, default for DM Telegram) routes
    DM streaming through send_draft, which has its own contract
    unaffected by this change. So this fix narrows in scope to the
    edit-based path: groups, supergroups, forum topics, every
    non-Telegram platform, and the per-response fallback after a
    draft failure.

Salvage notes:

  - Cherry-picked from PR #19537 by @kjames2001. Original PR returned
    failure on overflow; this evolves to split-and-deliver so users
    never lose content and the consumer state stays consistent.
  - Dropped an unrelated model-picker hunk (line 2114-2117) that
    silently killed the 'X more available — type /model <name>
    directly' hint by hardcoding total=len(models). Not in scope.
  - Restored the timeout-aware retryable=not is_timeout signal in
    send()'s fallthrough catch block.

Closes #19537.
Two new tests:

- tests/gateway/test_telegram_format.py
  test_message_too_long_splits_into_continuations_not_silent_truncation:
  asserts edit_message returns success=True with continuation_message_ids
  populated and message_id pointing at the last continuation when
  content exceeds MAX_MESSAGE_LENGTH (#19537). Replaces the original
  fail-on-overflow assertion with the split-and-deliver contract.

- tests/gateway/test_stream_consumer.py
  TestEditOverflowSplitAndDeliver.test_consumer_advances_message_id_on_split_and_deliver:
  asserts the consumer side updates _message_id to the latest
  continuation, clears _last_sent_text, and fires on_new_message when
  the adapter reports a split-and-deliver result.
@teknium1 teknium1 force-pushed the salvage/pr-19537 branch from acb8ed9 to d5130b8 Compare May 11, 2026 04:59
@teknium1 teknium1 changed the title fix(telegram): return failure on message_too_long instead of silent truncation (salvage of #19537) fix(telegram): split-and-deliver oversized edits instead of silent truncation (salvage of #19537) May 11, 2026
@teknium1 teknium1 merged commit 6c1af45 into main May 11, 2026
13 of 16 checks passed
@teknium1 teknium1 deleted the salvage/pr-19537 branch May 11, 2026 05:02
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery platform/telegram Telegram bot adapter labels May 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists platform/telegram Telegram bot adapter type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants