From 73a20a6ad62b678d69335ef3a352ccf9ab85167b Mon Sep 17 00:00:00 2001 From: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:10:36 +0200 Subject: [PATCH] fix(telegram): clip mid-stream overflow instead of splitting (#48648) --- plugins/platforms/telegram/adapter.py | 43 +++++++++++++--- tests/gateway/test_telegram_format.py | 70 ++++++++++++++++++++++++--- 2 files changed, 99 insertions(+), 14 deletions(-) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index b1f31e5b13a..de08c149a16 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -2975,11 +2975,18 @@ class TelegramAdapter(BasePlatformAdapter): return rich_result # Pre-flight: if content already exceeds the limit, split-and-deliver - # without round-tripping a doomed edit. + # without round-tripping a doomed edit. During streaming + # (finalize=False) we truncate instead of splitting — splitting creates + # continuation messages whose IDs become the new edit target, and on + # the next token chunk the full accumulated text is re-edited into the + # continuation, triggering another split → infinite duplication loop + # (#48648). The full content is delivered when finalize=True. if utf16_len(content) > self.MAX_MESSAGE_LENGTH: - return await self._edit_overflow_split( - chat_id, message_id, content, finalize=finalize, metadata=metadata, - ) + if finalize: + return await self._edit_overflow_split( + chat_id, message_id, content, finalize=finalize, metadata=metadata, + ) + content = self._truncate_stream_overflow_preview(content) try: if not finalize: @@ -3028,9 +3035,18 @@ class TelegramAdapter(BasePlatformAdapter): "[%s] edit_message overflow (%d UTF-16 > %d), splitting", self.name, utf16_len(content), self.MAX_MESSAGE_LENGTH, ) - return await self._edit_overflow_split( - chat_id, message_id, content, finalize=finalize, metadata=metadata, + if finalize: + return await self._edit_overflow_split( + chat_id, message_id, content, finalize=finalize, metadata=metadata, + ) + # Mid-stream: truncate and retry instead of splitting (#48648). + truncated = self._truncate_stream_overflow_preview(content) + await self._bot.edit_message_text( + chat_id=int(chat_id), + message_id=int(message_id), + text=truncated, ) + return SendResult(success=True, message_id=message_id) # Flood control / RetryAfter — short waits are retried inline, # long waits return a failure immediately so streaming can fall back # to a normal final send instead of leaving a truncated partial. @@ -3093,6 +3109,21 @@ class TelegramAdapter(BasePlatformAdapter): ) return SendResult(success=False, error=str(e)) + def _truncate_stream_overflow_preview(self, content: str) -> str: + """Return a one-message preview for oversized streaming edits. + + Streaming edits must keep targeting the original message. Splitting a + mid-stream preview creates continuation messages and moves the active + message id, so the next accumulated-token edit repeats the overflow + cycle (#48648). Final edits still use ``_edit_overflow_split`` to + deliver the complete response. + """ + return self.truncate_message( + content, + self.MAX_MESSAGE_LENGTH, + len_fn=utf16_len, + )[0] + async def _edit_overflow_split( self, chat_id: str, diff --git a/tests/gateway/test_telegram_format.py b/tests/gateway/test_telegram_format.py index c096a1198b1..a39c28c3d66 100644 --- a/tests/gateway/test_telegram_format.py +++ b/tests/gateway/test_telegram_format.py @@ -908,12 +908,12 @@ class TestEditMessageStreamingSafety: @pytest.mark.asyncio async def test_message_too_long_splits_into_continuations_not_silent_truncation(self): - """When edit_message_text exceeds Telegram's 4096 UTF-16 limit, the - adapter must split the content across the existing message + new - continuation messages so the user gets the full reply. Previously - the adapter best-effort truncated the content with '…' and returned - success=True, dropping everything past the truncation boundary - (#19537).""" + """When edit_message_text exceeds Telegram's 4096 UTF-16 limit on + finalize, the adapter must split the content across the existing + message + new continuation messages so the user gets the full reply. + Previously the adapter best-effort truncated the content with '…' and + returned success=True, dropping everything past the truncation + boundary (#19537).""" adapter = TelegramAdapter(PlatformConfig(enabled=True, token="fake-token")) adapter._bot = MagicMock() adapter._bot.edit_message_text = AsyncMock() @@ -926,7 +926,7 @@ class TestEditMessageStreamingSafety: # 6000-char content well over the 4096 UTF-16 limit. oversized = "x" * 6000 - result = await adapter.edit_message("123", "456", oversized, finalize=False) + result = await adapter.edit_message("123", "456", oversized, finalize=True) # Adapter reports success with continuations populated. assert result.success is True @@ -961,7 +961,7 @@ class TestEditMessageStreamingSafety: "-100123", "456", "x" * 6000, - finalize=False, + finalize=True, metadata={"thread_id": "17585"}, ) @@ -970,6 +970,60 @@ class TestEditMessageStreamingSafety: assert all(kwargs.get("message_thread_id") == 17585 for kwargs in sent_kwargs) assert sent_kwargs[0]["reply_to_message_id"] == 456 + @pytest.mark.asyncio + async def test_mid_stream_overflow_truncates_instead_of_splitting(self): + """During streaming (finalize=False), oversized content must be + truncated to fit one message rather than split into continuations. + Splitting mid-stream creates new message IDs that the stream consumer + then edits with the full accumulated text, causing an infinite + duplication loop (#48648).""" + adapter = TelegramAdapter(PlatformConfig(enabled=True, token="fake-token")) + adapter._bot = MagicMock() + adapter._bot.edit_message_text = AsyncMock() + _next_id = [1000] + + async def _fake_send(**kwargs): + _next_id[0] += 1 + return SimpleNamespace(message_id=_next_id[0]) + + adapter._bot.send_message = AsyncMock(side_effect=_fake_send) + + oversized = "x" * 6000 + result = await adapter.edit_message("123", "456", oversized, finalize=False) + + # Must NOT create continuation messages during streaming. + assert result.success is True + assert adapter._bot.send_message.await_count == 0, ( + "mid-stream overflow must not send continuation messages" + ) + # message_id stays the original — no shift to a new ID. + assert result.message_id == "456" + # The edit must contain truncated content (≤ MAX_MESSAGE_LENGTH). + edited_text = adapter._bot.edit_message_text.call_args.kwargs["text"] + assert len(edited_text) <= adapter.MAX_MESSAGE_LENGTH + + @pytest.mark.asyncio + async def test_mid_stream_reactive_overflow_retries_truncated_edit(self): + """If Telegram rejects a streaming edit as too long, retry with a + one-message preview instead of splitting into continuations.""" + adapter = TelegramAdapter(PlatformConfig(enabled=True, token="fake-token")) + adapter._bot = MagicMock() + adapter._bot.edit_message_text = AsyncMock( + side_effect=[Exception("Bad Request: message is too long"), None] + ) + adapter._bot.send_message = AsyncMock() + + content = "x" * adapter.MAX_MESSAGE_LENGTH + result = await adapter.edit_message("123", "456", content, finalize=False) + + assert result.success is True + assert result.message_id == "456" + adapter._bot.send_message.assert_not_called() + assert adapter._bot.edit_message_text.await_count == 2 + retry_text = adapter._bot.edit_message_text.await_args_list[1].kwargs["text"] + assert len(retry_text) <= adapter.MAX_MESSAGE_LENGTH + + # ========================================================================= # Telegram guest mention gating # =========================================================================