mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-25 17:18:11 +00:00
Wake-ups for kanban notifications and background delegation completions were injected via handle_message() using a build_session_key()-derived key, which can never match the raw X-Hermes-Session-Id key that api_server sessions run under — so the wake landed in a session nobody was reading. On top of that, ApiServerAdapter.send() reports failure without raising, and that was treated as a successful delivery, so the notify cursor advanced past events that were permanently lost; and background delegation was forced synchronous on api_server since there was no way to wake the session afterward. Fix: route wake-ups for non-push adapters through a self-post to /v1/chat/completions with the original session id, treat non-raising send failures as failures (rewind instead of advancing the cursor), and re-enable background delegation whenever a session id is available to wake. The origin session id is captured from the request-scoped api_server chat_id binding rather than HERMES_SESSION_ID: constructing a child agent calls set_current_session_id() with the subagent's internal id, clobbering that variable right before dispatch would read it and misrouting the wake into the subagent's own session. Related: #56580, #64609, #53027, #63169, #56531, #50319, #64113
692 lines
24 KiB
Python
692 lines
24 KiB
Python
"""Tests for configurable background process notification modes.
|
|
|
|
The gateway process watcher pushes status updates to users' chats when
|
|
background terminal commands run. ``display.background_process_notifications``
|
|
controls verbosity: off | result | error | all (default).
|
|
|
|
Contributed by @PeterFile (PR #593), reimplemented on current main.
|
|
"""
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from gateway.config import GatewayConfig, Platform
|
|
from gateway.run import GatewayRunner, _parse_session_key
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _FakeRegistry:
|
|
"""Return pre-canned sessions, then None once exhausted."""
|
|
|
|
def __init__(self, sessions, consumed=False):
|
|
self._sessions = list(sessions)
|
|
self._consumed = consumed
|
|
|
|
def get(self, session_id):
|
|
if self._sessions:
|
|
return self._sessions.pop(0)
|
|
return None
|
|
|
|
def is_completion_consumed(self, session_id):
|
|
return self._consumed
|
|
|
|
|
|
def _build_runner(monkeypatch, tmp_path, mode: str) -> GatewayRunner:
|
|
"""Create a GatewayRunner with a fake config for the given mode."""
|
|
(tmp_path / "config.yaml").write_text(
|
|
f"display:\n background_process_notifications: {mode}\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
import gateway.run as gateway_run
|
|
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
|
|
|
runner = GatewayRunner(GatewayConfig())
|
|
adapter = SimpleNamespace(send=AsyncMock(), handle_message=AsyncMock())
|
|
runner.adapters[Platform.TELEGRAM] = adapter
|
|
return runner
|
|
|
|
|
|
def _watcher_dict(session_id="proc_test", thread_id=""):
|
|
d = {
|
|
"session_id": session_id,
|
|
"check_interval": 0,
|
|
"platform": "telegram",
|
|
"chat_id": "123",
|
|
}
|
|
if thread_id:
|
|
d["thread_id"] = thread_id
|
|
return d
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _load_background_notifications_mode unit tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLoadBackgroundNotificationsMode:
|
|
|
|
def test_defaults_to_all(self, monkeypatch, tmp_path):
|
|
import gateway.run as gw
|
|
monkeypatch.setattr(gw, "_hermes_home", tmp_path)
|
|
monkeypatch.delenv("HERMES_BACKGROUND_NOTIFICATIONS", raising=False)
|
|
assert GatewayRunner._load_background_notifications_mode() == "all"
|
|
|
|
def test_reads_config_yaml(self, monkeypatch, tmp_path):
|
|
(tmp_path / "config.yaml").write_text(
|
|
"display:\n background_process_notifications: error\n"
|
|
)
|
|
import gateway.run as gw
|
|
monkeypatch.setattr(gw, "_hermes_home", tmp_path)
|
|
monkeypatch.delenv("HERMES_BACKGROUND_NOTIFICATIONS", raising=False)
|
|
assert GatewayRunner._load_background_notifications_mode() == "error"
|
|
|
|
def test_env_var_overrides_config(self, monkeypatch, tmp_path):
|
|
(tmp_path / "config.yaml").write_text(
|
|
"display:\n background_process_notifications: error\n"
|
|
)
|
|
import gateway.run as gw
|
|
monkeypatch.setattr(gw, "_hermes_home", tmp_path)
|
|
monkeypatch.setenv("HERMES_BACKGROUND_NOTIFICATIONS", "off")
|
|
assert GatewayRunner._load_background_notifications_mode() == "off"
|
|
|
|
def test_false_value_maps_to_off(self, monkeypatch, tmp_path):
|
|
(tmp_path / "config.yaml").write_text(
|
|
"display:\n background_process_notifications: false\n"
|
|
)
|
|
import gateway.run as gw
|
|
monkeypatch.setattr(gw, "_hermes_home", tmp_path)
|
|
monkeypatch.delenv("HERMES_BACKGROUND_NOTIFICATIONS", raising=False)
|
|
assert GatewayRunner._load_background_notifications_mode() == "off"
|
|
|
|
def test_invalid_value_defaults_to_all(self, monkeypatch, tmp_path):
|
|
(tmp_path / "config.yaml").write_text(
|
|
"display:\n background_process_notifications: banana\n"
|
|
)
|
|
import gateway.run as gw
|
|
monkeypatch.setattr(gw, "_hermes_home", tmp_path)
|
|
monkeypatch.delenv("HERMES_BACKGROUND_NOTIFICATIONS", raising=False)
|
|
assert GatewayRunner._load_background_notifications_mode() == "all"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _run_process_watcher integration tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("mode", "sessions", "expected_calls", "expected_fragment"),
|
|
[
|
|
# all mode: running output → sends update
|
|
(
|
|
"all",
|
|
[
|
|
SimpleNamespace(output_buffer="building...\n", exited=False, exit_code=None),
|
|
None, # process disappears → watcher exits
|
|
],
|
|
1,
|
|
"is still running",
|
|
),
|
|
# result mode: running output → no update
|
|
(
|
|
"result",
|
|
[
|
|
SimpleNamespace(output_buffer="building...\n", exited=False, exit_code=None),
|
|
None,
|
|
],
|
|
0,
|
|
None,
|
|
),
|
|
# off mode: exited process → no notification
|
|
(
|
|
"off",
|
|
[SimpleNamespace(output_buffer="done\n", exited=True, exit_code=0)],
|
|
0,
|
|
None,
|
|
),
|
|
# result mode: exited → notifies
|
|
(
|
|
"result",
|
|
[SimpleNamespace(output_buffer="done\n", exited=True, exit_code=0)],
|
|
1,
|
|
"finished with exit code 0",
|
|
),
|
|
# error mode: exit 0 → no notification
|
|
(
|
|
"error",
|
|
[SimpleNamespace(output_buffer="done\n", exited=True, exit_code=0)],
|
|
0,
|
|
None,
|
|
),
|
|
# error mode: exit 1 → notifies
|
|
(
|
|
"error",
|
|
[SimpleNamespace(output_buffer="traceback\n", exited=True, exit_code=1)],
|
|
1,
|
|
"finished with exit code 1",
|
|
),
|
|
# all mode: exited → notifies
|
|
(
|
|
"all",
|
|
[SimpleNamespace(output_buffer="ok\n", exited=True, exit_code=0)],
|
|
1,
|
|
"finished with exit code 0",
|
|
),
|
|
],
|
|
)
|
|
async def test_run_process_watcher_respects_notification_mode(
|
|
monkeypatch, tmp_path, mode, sessions, expected_calls, expected_fragment
|
|
):
|
|
import tools.process_registry as pr_module
|
|
|
|
monkeypatch.setattr(pr_module, "process_registry", _FakeRegistry(sessions))
|
|
|
|
# Patch asyncio.sleep to avoid real delays
|
|
async def _instant_sleep(*_a, **_kw):
|
|
pass
|
|
monkeypatch.setattr(asyncio, "sleep", _instant_sleep)
|
|
|
|
runner = _build_runner(monkeypatch, tmp_path, mode)
|
|
adapter = runner.adapters[Platform.TELEGRAM]
|
|
|
|
await runner._run_process_watcher(_watcher_dict())
|
|
|
|
assert adapter.send.await_count == expected_calls, (
|
|
f"mode={mode}: expected {expected_calls} sends, got {adapter.send.await_count}"
|
|
)
|
|
if expected_fragment is not None:
|
|
sent_message = adapter.send.await_args.args[1]
|
|
assert expected_fragment in sent_message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_thread_id_passed_to_send(monkeypatch, tmp_path):
|
|
"""thread_id from watcher dict is forwarded as metadata to adapter.send()."""
|
|
import tools.process_registry as pr_module
|
|
|
|
sessions = [SimpleNamespace(output_buffer="done\n", exited=True, exit_code=0)]
|
|
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]
|
|
|
|
await runner._run_process_watcher(_watcher_dict(thread_id="42"))
|
|
|
|
assert adapter.send.await_count == 1
|
|
_, kwargs = adapter.send.call_args
|
|
assert kwargs["metadata"] == {"thread_id": "42"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_thread_id_sends_no_metadata(monkeypatch, tmp_path):
|
|
"""When thread_id is empty, metadata should be None (general topic)."""
|
|
import tools.process_registry as pr_module
|
|
|
|
sessions = [SimpleNamespace(output_buffer="done\n", exited=True, exit_code=0)]
|
|
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]
|
|
|
|
await runner._run_process_watcher(_watcher_dict())
|
|
|
|
assert adapter.send.await_count == 1
|
|
_, kwargs = adapter.send.call_args
|
|
assert kwargs["metadata"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_consumed_completion_skips_raw_notification(monkeypatch, tmp_path):
|
|
"""#65379: after process(wait) already returned the completion inline,
|
|
the gateway watcher must NOT also push the raw
|
|
"[Background process ... finished with exit code ...]" message.
|
|
|
|
The agent-notify branch already honored _completion_consumed, but its
|
|
skip fell through to the text-notification branch, double-delivering the
|
|
same output to the chat (observed on Slack with
|
|
background_process_notifications: all)."""
|
|
import tools.process_registry as pr_module
|
|
|
|
sessions = [SimpleNamespace(
|
|
output_buffer="done\n", exited=True, exit_code=0, command="sleep 1; echo done",
|
|
)]
|
|
monkeypatch.setattr(
|
|
pr_module, "process_registry", _FakeRegistry(sessions, consumed=True)
|
|
)
|
|
|
|
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]
|
|
|
|
# notify_on_complete=True mirrors the reported scenario: the watcher's
|
|
# agent-notify skip must not fall through to a raw adapter.send().
|
|
watcher = _watcher_dict()
|
|
watcher["notify_on_complete"] = True
|
|
await runner._run_process_watcher(watcher)
|
|
|
|
adapter.send.assert_not_awaited()
|
|
adapter.handle_message.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_consumed_completion_skips_raw_notification_without_agent_notify(
|
|
monkeypatch, tmp_path
|
|
):
|
|
"""#65379 variant: same double-delivery guard for plain watchers
|
|
(notify_on_complete=False) — wait/log consumption suppresses the raw
|
|
completion message in every mode."""
|
|
import tools.process_registry as pr_module
|
|
|
|
sessions = [SimpleNamespace(
|
|
output_buffer="done\n", exited=True, exit_code=0, command="echo done",
|
|
)]
|
|
monkeypatch.setattr(
|
|
pr_module, "process_registry", _FakeRegistry(sessions, consumed=True)
|
|
)
|
|
|
|
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]
|
|
|
|
await runner._run_process_watcher(_watcher_dict())
|
|
|
|
adapter.send.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inject_watch_notification_routes_from_session_store_origin(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:group:-100:42"] = SimpleNamespace(
|
|
origin=SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="-100",
|
|
chat_type="group",
|
|
thread_id="42",
|
|
user_id="123",
|
|
user_name="Emiliyan",
|
|
)
|
|
)
|
|
|
|
evt = {
|
|
"session_id": "proc_watch",
|
|
"session_key": "agent:main:telegram:group:-100:42",
|
|
}
|
|
|
|
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.internal is True
|
|
assert synth_event.source.platform == Platform.TELEGRAM
|
|
assert synth_event.source.chat_id == "-100"
|
|
assert synth_event.source.chat_type == "group"
|
|
assert synth_event.source.thread_id == "42"
|
|
assert synth_event.source.user_id == "123"
|
|
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")
|
|
|
|
evt = {
|
|
"session_id": "proc_watch",
|
|
"session_key": "agent:main:telegram:group:-100:42",
|
|
"platform": "telegram",
|
|
"chat_id": "-100",
|
|
"thread_id": "42",
|
|
"user_id": "123",
|
|
"user_name": "Emiliyan",
|
|
}
|
|
|
|
source = runner._build_process_event_source(evt)
|
|
|
|
assert source is not None
|
|
assert source.platform == Platform.TELEGRAM
|
|
assert source.chat_id == "-100"
|
|
assert source.chat_type == "group"
|
|
assert source.thread_id == "42"
|
|
assert source.user_id == "123"
|
|
assert source.user_name == "Emiliyan"
|
|
|
|
|
|
def test_build_process_event_source_uses_cached_live_source_before_session_key_parse(
|
|
monkeypatch, tmp_path
|
|
):
|
|
from gateway.session import SessionSource
|
|
|
|
runner = _build_runner(monkeypatch, tmp_path, "all")
|
|
runner._cache_session_source(
|
|
"agent:main:telegram:group:-100:42",
|
|
SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="-100",
|
|
chat_type="group",
|
|
thread_id="42",
|
|
user_id="proc_owner",
|
|
user_name="alice",
|
|
),
|
|
)
|
|
|
|
source = runner._build_process_event_source(
|
|
{
|
|
"session_id": "proc_watch",
|
|
"session_key": "agent:main:telegram:group:-100:42",
|
|
}
|
|
)
|
|
|
|
assert source is not None
|
|
assert source.platform == Platform.TELEGRAM
|
|
assert source.chat_id == "-100"
|
|
assert source.chat_type == "group"
|
|
assert source.thread_id == "42"
|
|
assert source.user_id == "proc_owner"
|
|
assert source.user_name == "alice"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inject_watch_notification_ignores_foreground_event_source(monkeypatch, tmp_path):
|
|
"""Negative test: watch notification must NOT route to the foreground thread."""
|
|
from gateway.session import SessionSource
|
|
|
|
runner = _build_runner(monkeypatch, tmp_path, "all")
|
|
adapter = runner.adapters[Platform.TELEGRAM]
|
|
|
|
# Session store has the process's original thread (thread 42)
|
|
runner.session_store._entries["agent:main:telegram:group:-100:42"] = SimpleNamespace(
|
|
origin=SessionSource(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="-100",
|
|
chat_type="group",
|
|
thread_id="42",
|
|
user_id="proc_owner",
|
|
user_name="alice",
|
|
)
|
|
)
|
|
|
|
# The evt dict carries the correct session_key — NOT a foreground event
|
|
evt = {
|
|
"session_id": "proc_cross_thread",
|
|
"session_key": "agent:main:telegram:group:-100:42",
|
|
}
|
|
|
|
await runner._inject_watch_notification("[SYSTEM: watch match]", evt)
|
|
|
|
adapter.handle_message.assert_awaited_once()
|
|
synth_event = adapter.handle_message.await_args.args[0]
|
|
# Must route to thread 42 (process origin), NOT some other thread
|
|
assert synth_event.source.thread_id == "42"
|
|
assert synth_event.source.user_id == "proc_owner"
|
|
|
|
|
|
def test_build_process_event_source_returns_none_for_empty_evt(monkeypatch, tmp_path):
|
|
"""Missing session_key and no platform metadata → None (drop notification)."""
|
|
runner = _build_runner(monkeypatch, tmp_path, "all")
|
|
|
|
source = runner._build_process_event_source({"session_id": "proc_orphan"})
|
|
assert source is None
|
|
|
|
|
|
def test_build_process_event_source_returns_none_for_invalid_platform(monkeypatch, tmp_path):
|
|
"""Invalid platform string → None."""
|
|
runner = _build_runner(monkeypatch, tmp_path, "all")
|
|
|
|
evt = {
|
|
"session_id": "proc_bad",
|
|
"platform": "not_a_real_platform",
|
|
"chat_type": "dm",
|
|
"chat_id": "123",
|
|
}
|
|
source = runner._build_process_event_source(evt)
|
|
assert source is None
|
|
|
|
|
|
def test_build_process_event_source_returns_none_for_short_session_key(monkeypatch, tmp_path):
|
|
"""Session key with <5 parts doesn't parse, falls through to empty metadata → None."""
|
|
runner = _build_runner(monkeypatch, tmp_path, "all")
|
|
|
|
evt = {
|
|
"session_id": "proc_short",
|
|
"session_key": "agent:main:telegram", # Too few parts
|
|
}
|
|
source = runner._build_process_event_source(evt)
|
|
assert source is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _parse_session_key helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_parse_session_key_valid():
|
|
result = _parse_session_key("agent:main:telegram:group:-100")
|
|
assert result == {"platform": "telegram", "chat_type": "group", "chat_id": "-100"}
|
|
|
|
|
|
def test_parse_session_key_with_extra_parts():
|
|
"""6th part in a group key may be a user_id, not a thread_id — omit it."""
|
|
result = _parse_session_key("agent:main:discord:group:chan123:thread456")
|
|
assert result == {"platform": "discord", "chat_type": "group", "chat_id": "chan123"}
|
|
|
|
|
|
def test_parse_session_key_with_user_id_part():
|
|
"""Group keys with per-user isolation have user_id as 6th part — don't return as thread_id."""
|
|
result = _parse_session_key("agent:main:telegram:group:chat1:user99")
|
|
assert result == {"platform": "telegram", "chat_type": "group", "chat_id": "chat1"}
|
|
|
|
|
|
def test_parse_session_key_dm_with_thread():
|
|
"""DM keys use parts[5] as thread_id unambiguously."""
|
|
result = _parse_session_key("agent:main:telegram:dm:chat1:topic42")
|
|
assert result == {"platform": "telegram", "chat_type": "dm", "chat_id": "chat1", "thread_id": "topic42"}
|
|
|
|
|
|
def test_parse_session_key_thread_chat_type():
|
|
"""Thread-typed keys use parts[5] as thread_id unambiguously."""
|
|
result = _parse_session_key("agent:main:discord:thread:chan1:thread99")
|
|
assert result == {"platform": "discord", "chat_type": "thread", "chat_id": "chan1", "thread_id": "thread99"}
|
|
|
|
|
|
def test_parse_session_key_too_short():
|
|
assert _parse_session_key("agent:main:telegram") is None
|
|
assert _parse_session_key("") is None
|
|
|
|
|
|
def test_parse_session_key_wrong_prefix():
|
|
assert _parse_session_key("cron:main:telegram:dm:123") is None
|
|
assert _parse_session_key("agent:cron:telegram:dm:123") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# api_server (stateless) wake routing — gateway/wake.py self-post path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inject_watch_notification_raw_session_key_self_posts(monkeypatch, tmp_path):
|
|
"""An event whose session_key is a RAW api_server session id (not an
|
|
agent:main:... structured key) must wake the real session via the
|
|
/v1/chat/completions self-post instead of being dropped for missing
|
|
routing metadata."""
|
|
runner = _build_runner(monkeypatch, tmp_path, "all")
|
|
api_adapter = SimpleNamespace(
|
|
supports_async_delivery=False,
|
|
handle_message=AsyncMock(),
|
|
_host="127.0.0.1", _port=8642, _api_key="k", _model_name="m",
|
|
)
|
|
runner.adapters[Platform.API_SERVER] = api_adapter
|
|
|
|
posts = []
|
|
|
|
async def fake_self_post(adapter, *, text, session_id):
|
|
posts.append({"text": text, "session_id": session_id})
|
|
|
|
import gateway.wake as wake_mod
|
|
monkeypatch.setattr(wake_mod, "_self_post_chat_completion", fake_self_post)
|
|
|
|
evt = {
|
|
"session_id": "proc_watch",
|
|
"session_key": "raw-hq-session-id", # no agent:main:... structure
|
|
}
|
|
result = await runner._inject_watch_notification("[SYSTEM: subagent finished]", evt)
|
|
|
|
assert result is True
|
|
api_adapter.handle_message.assert_not_awaited()
|
|
assert posts == [
|
|
{"text": "[SYSTEM: subagent finished]", "session_id": "raw-hq-session-id"}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inject_watch_notification_origin_session_id_wins(monkeypatch, tmp_path):
|
|
"""origin_session_id (stamped at dispatch time by async_delegation) takes
|
|
precedence as the wake target."""
|
|
runner = _build_runner(monkeypatch, tmp_path, "all")
|
|
api_adapter = SimpleNamespace(
|
|
supports_async_delivery=False,
|
|
handle_message=AsyncMock(),
|
|
_host="127.0.0.1", _port=8642, _api_key="k", _model_name="m",
|
|
)
|
|
runner.adapters[Platform.API_SERVER] = api_adapter
|
|
|
|
posts = []
|
|
|
|
async def fake_self_post(adapter, *, text, session_id):
|
|
posts.append(session_id)
|
|
|
|
import gateway.wake as wake_mod
|
|
monkeypatch.setattr(wake_mod, "_self_post_chat_completion", fake_self_post)
|
|
|
|
evt = {
|
|
"session_id": "proc_watch",
|
|
"session_key": "",
|
|
"origin_session_id": "raw-origin-sid",
|
|
}
|
|
result = await runner._inject_watch_notification("[SYSTEM: done]", evt)
|
|
assert result is True
|
|
assert posts == ["raw-origin-sid"]
|