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:
Fábio Siqueira
2026-05-16 22:31:15 -03:00
committed by Teknium
parent 17f3254ede
commit fbabd560ff
6 changed files with 130 additions and 0 deletions

View File

@@ -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")