From 25fabcf8eb897ac3a4ec749dfb46c54c1934c124 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:11:24 -0700 Subject: [PATCH] fix(telegram): keep /loop and synthetic sends in the active DM topic Fixes #87051 --- plugins/platforms/telegram/adapter.py | 19 +++- tests/gateway/test_telegram_reply_mode.py | 102 ++++++++++++++++++++++ 2 files changed, 118 insertions(+), 3 deletions(-) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 0dcd279b49..cef6519cfa 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -1564,9 +1564,13 @@ class TelegramAdapter(BasePlatformAdapter): Messages topics can opt in with explicit ``direct_messages_topic_id`` metadata. Hermes-created private-chat topic lanes are marked with ``telegram_dm_topic_reply_fallback``. Live replies send the private - topic thread id together with a reply anchor; synthetic/resumed sends - without an anchor use ``direct_messages_topic_id`` when metadata has it. - ``message_thread_id`` alone can render outside the visible lane. + topic thread id together with a reply anchor. Synthetic/resumed sends + without an anchor (loop wakeups, background-process notifications, + queued follow-ups after a gateway restart) prefer the Hermes topic's + ``message_thread_id`` so they stay in the active topic lane (#87051); + ``direct_messages_topic_id`` is only used when no topic thread + resolves, since the native DM-topic id does not match the Hermes + topic lane and can render the message in a different chat lane. When ``reply_to_mode`` is ``"off"``, the reply anchor is suppressed for DM topic fallback sends while preserving the ``message_thread_id`` so @@ -1578,6 +1582,15 @@ class TelegramAdapter(BasePlatformAdapter): if reply_to_message_id is None: reply_to_message_id = cls._metadata_reply_to_message_id(metadata) if reply_to_message_id is None: + # Anchor-less synthetic sends (loop wakeups, watch + # notifications, restart-resumed follow-ups) must stay in the + # active topic lane: prefer the Hermes topic thread id when it + # resolves (#87051). Routing via direct_messages_topic_id here + # sent these to a different lane than the topic the session + # runs in. + thread_message_id = cls._message_thread_id_for_send(thread_id) + if thread_message_id is not None: + return {"message_thread_id": thread_message_id} direct_topic_id = cls._metadata_direct_messages_topic_id(metadata) if direct_topic_id is not None: return { diff --git a/tests/gateway/test_telegram_reply_mode.py b/tests/gateway/test_telegram_reply_mode.py index 04b0e6bc2f..f37a5b8f89 100644 --- a/tests/gateway/test_telegram_reply_mode.py +++ b/tests/gateway/test_telegram_reply_mode.py @@ -227,3 +227,105 @@ class TestDMTopicFallbackReplyToMode: call = adapter._bot.send_message.call_args_list[0] assert call.kwargs.get("reply_to_message_id") is None + +class TestDMTopicSyntheticSendRouting: + """Anchor-less synthetic sends must stay in the active DM topic lane. + + Regression tests for https://github.com/NousResearch/hermes-agent/issues/87051: + after a gateway restart, /loop wakeups and background-process notifications + are injected as synthetic events with no reply anchor. The DM-topic + fallback's no-anchor branch routed them via Telegram's native + ``direct_messages_topic_id`` (or dropped the thread entirely), landing the + response in a different chat lane than the Hermes topic the session runs + in. The no-anchor branch must prefer the Hermes topic's + ``message_thread_id`` whenever it resolves. + """ + + def test_no_anchor_prefers_hermes_topic_thread_id(self): + """Synthetic send (no anchor) keeps message_thread_id of the topic.""" + metadata = { + "thread_id": "42", + "telegram_dm_topic_reply_fallback": True, + "direct_messages_topic_id": "20189", + } + result = TelegramAdapter._thread_kwargs_for_send( + "100", "42", metadata, reply_to_message_id=None, + ) + assert result == {"message_thread_id": 42} + + def test_no_anchor_no_direct_topic_keeps_thread_id(self): + """No anchor and no native DM-topic id: still route to the topic.""" + metadata = {"thread_id": "42", "telegram_dm_topic_reply_fallback": True} + result = TelegramAdapter._thread_kwargs_for_send( + "100", "42", metadata, reply_to_message_id=None, + ) + assert result == {"message_thread_id": 42} + + def test_no_anchor_no_thread_falls_back_to_direct_topic_id(self): + """When no topic thread resolves, the native DM-topic route survives.""" + metadata = { + "telegram_dm_topic_reply_fallback": True, + "direct_messages_topic_id": "20189", + } + result = TelegramAdapter._thread_kwargs_for_send( + "100", None, metadata, reply_to_message_id=None, + ) + assert result == { + "message_thread_id": None, + "direct_messages_topic_id": 20189, + } + + def test_no_anchor_plain_dm_omits_thread_id(self): + """Genuinely thread-less DM sends must not grow a thread id.""" + metadata = {"telegram_dm_topic_reply_fallback": True} + result = TelegramAdapter._thread_kwargs_for_send( + "100", None, metadata, reply_to_message_id=None, + ) + assert result == {} + + def test_no_anchor_general_topic_omits_thread_id(self): + """The forum General topic ('1') still maps to no thread id on send.""" + metadata = { + "thread_id": "1", + "telegram_dm_topic_reply_fallback": True, + "direct_messages_topic_id": "20189", + } + result = TelegramAdapter._thread_kwargs_for_send( + "100", "1", metadata, reply_to_message_id=None, + ) + assert result == { + "message_thread_id": None, + "direct_messages_topic_id": 20189, + } + + def test_anchored_reply_unchanged(self): + """Live replies with an anchor keep the topic thread id (unchanged).""" + metadata = { + "thread_id": "42", + "telegram_dm_topic_reply_fallback": True, + "telegram_reply_to_message_id": "12345", + } + result = TelegramAdapter._thread_kwargs_for_send( + "100", "42", metadata, reply_to_message_id=12345, + ) + assert result == {"message_thread_id": 42} + + @pytest.mark.asyncio + async def test_send_synthetic_loop_wakeup_lands_in_topic(self, adapter_factory): + """send() for an anchor-less synthetic event delivers in-topic.""" + adapter = adapter_factory() + adapter._bot = MagicMock() + adapter._bot.send_message = AsyncMock(return_value=MagicMock(message_id=1)) + adapter.truncate_message = lambda content, max_len, **kw: ["chunk1"] + metadata = { + "thread_id": "42", + "telegram_dm_topic_reply_fallback": True, + "direct_messages_topic_id": "20189", + } + + result = await adapter.send("12345", "loop wakeup reply", metadata=metadata) + + assert result.success is True + call = adapter._bot.send_message.call_args_list[0] + assert call.kwargs.get("message_thread_id") == 42 + assert call.kwargs.get("direct_messages_topic_id") is None