hermes-agent/tests/gateway/test_background_process_notifications.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

338 lines
12 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"
# ---------------------------------------------------------------------------
# _run_process_watcher integration tests
# ---------------------------------------------------------------------------
@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_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"
@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"
# ---------------------------------------------------------------------------
# _parse_session_key helper
# ---------------------------------------------------------------------------
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"}
# ---------------------------------------------------------------------------
# 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"]