fix(telegram): keep /loop and synthetic sends in the active DM topic

Fixes #87051
This commit is contained in:
Teknium
2026-08-16 01:11:24 -07:00
parent 1e6717baf2
commit 25fabcf8eb
2 changed files with 118 additions and 3 deletions
+16 -3
View File
@@ -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 {
+102
View File
@@ -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