| """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, patch |
|
|
| import pytest |
|
|
| from gateway.config import GatewayConfig, Platform |
| from gateway.run import GatewayRunner, _parse_session_key |
|
|
|
|
| |
| |
| |
|
|
| class _FakeRegistry: |
| """Return pre-canned sessions, then None once exhausted.""" |
|
|
| def __init__(self, sessions): |
| self._sessions = list(sessions) |
|
|
| def get(self, session_id): |
| if self._sessions: |
| return self._sessions.pop(0) |
| return None |
|
|
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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" |
|
|
|
|
| |
| |
| |
|
|
| @pytest.mark.asyncio |
| @pytest.mark.parametrize( |
| ("mode", "sessions", "expected_calls", "expected_fragment"), |
| [ |
| |
| ( |
| "all", |
| [ |
| SimpleNamespace(output_buffer="building...\n", exited=False, exit_code=None), |
| None, |
| ], |
| 1, |
| "is still running", |
| ), |
| |
| ( |
| "result", |
| [ |
| SimpleNamespace(output_buffer="building...\n", exited=False, exit_code=None), |
| None, |
| ], |
| 0, |
| None, |
| ), |
| |
| ( |
| "off", |
| [SimpleNamespace(output_buffer="done\n", exited=True, exit_code=0)], |
| 0, |
| None, |
| ), |
| |
| ( |
| "result", |
| [SimpleNamespace(output_buffer="done\n", exited=True, exit_code=0)], |
| 1, |
| "finished with exit code 0", |
| ), |
| |
| ( |
| "error", |
| [SimpleNamespace(output_buffer="done\n", exited=True, exit_code=0)], |
| 0, |
| None, |
| ), |
| |
| ( |
| "error", |
| [SimpleNamespace(output_buffer="traceback\n", exited=True, exit_code=1)], |
| 1, |
| "finished with exit code 1", |
| ), |
| |
| ( |
| "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)) |
|
|
| |
| 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_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" |
|
|
|
|
| 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" |
|
|
|
|
| @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] |
|
|
| |
| 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", |
| ) |
| ) |
|
|
| |
| 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] |
| |
| 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", |
| } |
| source = runner._build_process_event_source(evt) |
| assert source is None |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|