From 43407016d0dc1ff0816308088c524a2d33d06f97 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 28 Aug 2026 18:51:16 +0200 Subject: [PATCH] fix(telegram): last draft push is the final text so the client folds it into the message --- .../frontends/telegram/drafts.py | 30 +++++++++++++++---- .../frontends/telegram/frontend.py | 14 +++++---- tests/test_telegram.py | 3 ++ 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/beaver_gateway/frontends/telegram/drafts.py b/src/beaver_gateway/frontends/telegram/drafts.py index 6e918fb..8659aa9 100644 --- a/src/beaver_gateway/frontends/telegram/drafts.py +++ b/src/beaver_gateway/frontends/telegram/drafts.py @@ -1,5 +1,9 @@ """One ``sendMessageDraft`` stream per running turn (§3.8). +The client folds a draft into the message that follows only when their +texts are identical - so the last push is the final text itself, rendered +exactly as the outbox will send it, with no status line. + A draft is ephemeral and lives 30 s, Telegram throttles edits to about one per second per chat, and thinking or a tool call would otherwise look like a hang - so the draft opens with a status line straight away, is refreshed on @@ -74,15 +78,29 @@ class Draft: The final ``sendMessage`` must reach Telegram after the last draft, or the draft lands on top of the message and lingers for its 30 s. """ - if self._task is None: - return - self._task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await self._task - self._task = None + if self._task is not None: + self._task.cancel() + await asyncio.gather(self._task, return_exceptions=True) + self._task = None if self._inflight is not None: with contextlib.suppress(Exception): await self._inflight + self._inflight = None + + async def finish(self, text: str) -> None: + """Last push: the final text verbatim, so the message replaces the draft.""" + await self.stop() + if self._broken or not text.strip(): + return + try: + try: + await self._send(to_html(text), "HTML") + except TelegramBadRequest: + await self._send(text, None) + except TelegramAPIError as exc: + _log.warning( + "final draft to %s/%s failed: %s", self._chat_id, self._thread_id, exc + ) async def _run(self) -> None: while not self._broken: diff --git a/src/beaver_gateway/frontends/telegram/frontend.py b/src/beaver_gateway/frontends/telegram/frontend.py index eae1f04..2891a0e 100644 --- a/src/beaver_gateway/frontends/telegram/frontend.py +++ b/src/beaver_gateway/frontends/telegram/frontend.py @@ -39,7 +39,7 @@ from beaver_gateway.frontends.base import Frontend from beaver_gateway.frontends.telegram.drafts import Draft from beaver_gateway.frontends.telegram.inbox import Inbox from beaver_gateway.frontends.telegram.outbox import Outbox -from beaver_gateway.frontends.telegram.render import status_label +from beaver_gateway.frontends.telegram.render import chunks, status_label if TYPE_CHECKING: from beaver_gateway.core.bus import Event, EventBus @@ -659,7 +659,9 @@ class TelegramFrontend(Frontend): f"⏳ {status_label(str(event['name']), event.get('input'))}" ) case "turn.end": - await self._close_draft(key) + draft = self._drafts.get(key) + if draft is not None: + await draft.stop() if event.get("stop") == "error" and event.get("origin") == "user": await self._deliver( conv, @@ -691,7 +693,11 @@ class TelegramFrontend(Frontend): async def _on_reply(self, conv: Conversation, event: Event) -> None: turn_id = str(event.get("turn_id") or "") origin = str(event.get("item_origin") or "") + text = str(event.get("text") or "") await self._unreact(event.get("item")) + draft = self._drafts.pop(conv.external_id, None) + if draft is not None: + await draft.finish(chunks(text)[-1] if text.strip() else "") if origin != FRONTEND and not origin.startswith("сид"): user_text = str(event.get("user_text") or "") if user_text: @@ -701,9 +707,7 @@ class TelegramFrontend(Frontend): turn_id=turn_id, key=f"{turn_id}:mirror", ) - await self._deliver( - conv, str(event.get("text") or ""), turn_id=turn_id, key=f"{turn_id}:reply" - ) + await self._deliver(conv, text, turn_id=turn_id, key=f"{turn_id}:reply") # ---- drafts ------------------------------------------------------------ diff --git a/tests/test_telegram.py b/tests/test_telegram.py index cae7a76..4c78bed 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -251,6 +251,9 @@ async def test_general_is_master_and_reply_has_no_thread(stack: Stack) -> None: await asyncio.sleep(0.2) final = max(i for i, o in enumerate(stack.bot.order) if o[0] == "message") assert all(o[0] != "draft" for o in stack.bot.order[final:]) + last = stack.bot.drafts[-1] + assert last["text"] == reply["text"] and last["parse_mode"] == "HTML" + assert stack.bot.drafts[0]["text"] == "⏳ думаю" rows = await stack.world.conversations.queue.recent(master.id) assert [r.origin for r in rows] == ["telegram"]