fix(gateway): route background-process notifications into Telegram DM topics
Background-process completion notifications (notify_on_complete) and watch-pattern notifications were always delivered to the Telegram main chat instead of the originating private-chat topic. Hermes-created Telegram DM topic lanes only render a send when it carries both message_thread_id and a reply anchor. The synthetic MessageEvent injected on process completion had no message_id, so _reply_anchor_for_event returned None and _thread_kwargs_for_send dropped message_thread_id entirely — routing the notification to the main chat. Capture the triggering message id at spawn time and thread it through to the synthetic event so it can be reply-anchored back into the topic: - session_context: add HERMES_SESSION_MESSAGE_ID context var - telegram adapter: populate SessionSource.message_id on inbound messages - terminal tool: persist watcher_message_id on the process session - process registry: carry/persist message_id on watcher dicts + checkpoint - gateway: set MessageEvent.message_id on injected notifications Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,9 @@ class _FakeRegistry:
|
||||
return self._sessions.pop(0)
|
||||
return None
|
||||
|
||||
def is_completion_consumed(self, session_id):
|
||||
return False
|
||||
|
||||
|
||||
def _build_runner(monkeypatch, tmp_path, mode: str) -> GatewayRunner:
|
||||
"""Create a GatewayRunner with a fake config for the given mode."""
|
||||
@@ -280,6 +283,111 @@ async def test_inject_watch_notification_routes_from_session_store_origin(monkey
|
||||
assert synth_event.source.user_name == "Emiliyan"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_notification_carries_message_id_reply_anchor(monkeypatch, tmp_path):
|
||||
"""notify_on_complete injection carries the triggering message_id so the
|
||||
synthetic event can be reply-anchored back into a Telegram DM topic.
|
||||
|
||||
Without an anchor, Telegram private-chat topic sends fall back to the main
|
||||
chat (see _thread_kwargs_for_send / telegram_dm_topic_reply_fallback)."""
|
||||
import tools.process_registry as pr_module
|
||||
|
||||
sessions = [SimpleNamespace(
|
||||
output_buffer="SMOKE_OK\n", exited=True, exit_code=0, command="sleep 1",
|
||||
)]
|
||||
monkeypatch.setattr(pr_module, "process_registry", _FakeRegistry(sessions))
|
||||
|
||||
async def _instant_sleep(*_a, **_kw):
|
||||
pass
|
||||
monkeypatch.setattr(asyncio, "sleep", _instant_sleep)
|
||||
|
||||
runner = _build_runner(monkeypatch, tmp_path, "all")
|
||||
adapter = runner.adapters[Platform.TELEGRAM]
|
||||
|
||||
watcher = {
|
||||
"session_id": "proc_anchor",
|
||||
"check_interval": 0,
|
||||
"session_key": "agent:main:telegram:dm:123:24296",
|
||||
"platform": "telegram",
|
||||
"chat_id": "123",
|
||||
"thread_id": "24296",
|
||||
"message_id": "555",
|
||||
"notify_on_complete": True,
|
||||
}
|
||||
await runner._run_process_watcher(watcher)
|
||||
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
synth_event = adapter.handle_message.await_args.args[0]
|
||||
assert synth_event.internal is True
|
||||
assert synth_event.message_id == "555"
|
||||
assert synth_event.source.thread_id == "24296"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_notification_no_message_id_is_tolerated(monkeypatch, tmp_path):
|
||||
"""A watcher dict without message_id (CLI spawn, pre-upgrade checkpoint)
|
||||
still injects — message_id is simply None."""
|
||||
import tools.process_registry as pr_module
|
||||
|
||||
sessions = [SimpleNamespace(
|
||||
output_buffer="done\n", exited=True, exit_code=0, command="sleep 1",
|
||||
)]
|
||||
monkeypatch.setattr(pr_module, "process_registry", _FakeRegistry(sessions))
|
||||
|
||||
async def _instant_sleep(*_a, **_kw):
|
||||
pass
|
||||
monkeypatch.setattr(asyncio, "sleep", _instant_sleep)
|
||||
|
||||
runner = _build_runner(monkeypatch, tmp_path, "all")
|
||||
adapter = runner.adapters[Platform.TELEGRAM]
|
||||
|
||||
watcher = {
|
||||
"session_id": "proc_anchorless",
|
||||
"check_interval": 0,
|
||||
"session_key": "agent:main:telegram:dm:123:24296",
|
||||
"platform": "telegram",
|
||||
"chat_id": "123",
|
||||
"thread_id": "24296",
|
||||
"notify_on_complete": True,
|
||||
}
|
||||
await runner._run_process_watcher(watcher)
|
||||
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
synth_event = adapter.handle_message.await_args.args[0]
|
||||
assert synth_event.message_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_watch_notification_carries_message_id_reply_anchor(monkeypatch, tmp_path):
|
||||
from gateway.session import SessionSource
|
||||
|
||||
runner = _build_runner(monkeypatch, tmp_path, "all")
|
||||
adapter = runner.adapters[Platform.TELEGRAM]
|
||||
runner.session_store._entries["agent:main:telegram:dm:123:24296"] = SimpleNamespace(
|
||||
origin=SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="123",
|
||||
chat_type="dm",
|
||||
thread_id="24296",
|
||||
user_id="1",
|
||||
user_name="Fabio",
|
||||
)
|
||||
)
|
||||
|
||||
evt = {
|
||||
"session_id": "proc_watch",
|
||||
"session_key": "agent:main:telegram:dm:123:24296",
|
||||
"message_id": "777",
|
||||
}
|
||||
|
||||
await runner._inject_watch_notification("[SYSTEM: Background process matched]", evt)
|
||||
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
synth_event = adapter.handle_message.await_args.args[0]
|
||||
assert synth_event.message_id == "777"
|
||||
assert synth_event.source.thread_id == "24296"
|
||||
|
||||
|
||||
def test_build_process_event_source_falls_back_to_session_key_chat_type(monkeypatch, tmp_path):
|
||||
runner = _build_runner(monkeypatch, tmp_path, "all")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user