hermes-agent/tests/gateway/test_completion_delivery.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

318 lines
10 KiB
Python

"""Lifecycle-scoped gateway delivery regressions for terminal completions.
The gateway contract here is deliberately narrower than exactly-once: one live
GatewayRunner suppresses concurrent/replayed copies after successful adapter
injection, failed injection remains retryable, and durable async-delegation
state (when available) is acknowledged through its authoritative SQLite API.
"""
import asyncio
import json
import queue
from collections import OrderedDict
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from gateway.config import Platform
from gateway.run import GatewayRunner
from gateway.session import SessionSource
from tools.process_registry import ProcessRegistry, ProcessSession
@pytest.fixture(autouse=True)
def isolated_registry(tmp_path, monkeypatch):
"""Any current/future durable compatibility path must stay in tmp state."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
import tools.process_registry as pr_module
monkeypatch.setattr(pr_module, "CHECKPOINT_PATH", tmp_path / "processes.json")
registry = pr_module.ProcessRegistry()
monkeypatch.setattr(pr_module, "process_registry", registry)
return registry
def _runner(adapter, *, origins=None):
runner = object.__new__(GatewayRunner)
runner._running = True
runner.adapters = {Platform.TELEGRAM: adapter}
runner.session_store = SimpleNamespace(
_ensure_loaded=lambda: None,
_entries=origins or {},
)
runner._session_source_cache = {}
runner._completion_delivery_lock = __import__("threading").Lock()
runner._completion_deliveries_inflight = set()
runner._completion_deliveries_delivered = OrderedDict()
runner._completion_delivery_retention = 2048
return runner
def _async_event(delegation_id="deleg_duplicate"):
return {
"type": "async_delegation",
"delegation_id": delegation_id,
"session_key": "agent:main:telegram:dm:12345:678",
"goal": "Investigate flaky test",
"status": "completed",
"summary": "Found it",
"api_calls": 1,
"duration_seconds": 12.0,
"dispatched_at": 1000.0,
"completed_at": 1012.0,
# PR #62479 stamps these on gateway-owned events. They must not
# change the producer identity used for queue replay.
"origin_profile": "default",
"origin_hermes_home": "/tmp/hermes-default",
}
def _completion_event(*, started_at, session_id="proc_reused"):
return {
"type": "completion",
"session_id": session_id,
"session_key": "agent:main:telegram:dm:123",
"platform": "telegram",
"chat_type": "dm",
"chat_id": "123",
"started_at": started_at,
"command": "echo done",
"exit_code": 0,
"completion_reason": "exited",
"output": "done\n",
}
def _stop_after_sleeps(monkeypatch, runner, count):
sleep_calls = 0
async def _bounded_sleep(_delay):
nonlocal sleep_calls
sleep_calls += 1
if sleep_calls >= count:
runner._running = False
monkeypatch.setattr(asyncio, "sleep", _bounded_sleep)
def test_duplicate_async_queue_replay_injects_once(monkeypatch, isolated_registry):
"""Byte-identical queue replays produce one turn in one gateway lifecycle."""
isolated = queue.Queue()
monkeypatch.setattr(isolated_registry, "completion_queue", isolated)
isolated.put(dict(_async_event()))
isolated.put(dict(_async_event()))
adapter = SimpleNamespace(handle_message=AsyncMock())
runner = _runner(adapter)
_stop_after_sleeps(monkeypatch, runner, count=2)
asyncio.run(runner._async_delegation_watcher(interval=0))
adapter.handle_message.assert_awaited_once()
def test_unroutable_async_event_is_not_requeued_forever(
monkeypatch, isolated_registry,
):
isolated = queue.Queue()
monkeypatch.setattr(isolated_registry, "completion_queue", isolated)
event = _async_event("deleg_desktop_or_cli")
event["session_key"] = "20260711_unparseable_ui_session"
isolated.put(event)
adapter = SimpleNamespace(handle_message=AsyncMock())
runner = _runner(adapter)
_stop_after_sleeps(monkeypatch, runner, count=2)
asyncio.run(runner._async_delegation_watcher(interval=0))
adapter.handle_message.assert_not_awaited()
assert isolated.empty()
def test_concurrent_claims_share_the_same_narrow_delivery_seam():
"""Concurrent consumers in one runner cannot both enter the adapter."""
entered = asyncio.Event()
release = asyncio.Event()
async def _blocked_injection(_event):
entered.set()
await release.wait()
adapter = SimpleNamespace(handle_message=AsyncMock(side_effect=_blocked_injection))
runner = _runner(adapter)
event = _async_event()
text = "completion"
async def _exercise():
first = asyncio.create_task(runner._deliver_completion_notification(text, dict(event)))
await entered.wait()
second = asyncio.create_task(runner._deliver_completion_notification(text, dict(event)))
await asyncio.sleep(0)
release.set()
return await asyncio.gather(first, second)
assert sorted(asyncio.run(_exercise()), key=str) == [None, True]
adapter.handle_message.assert_awaited_once()
def test_failed_async_injection_is_retried_and_only_success_is_acked(
monkeypatch, isolated_registry,
):
isolated = queue.Queue()
monkeypatch.setattr(isolated_registry, "completion_queue", isolated)
isolated.put(_async_event())
adapter = SimpleNamespace(
handle_message=AsyncMock(side_effect=[RuntimeError("temporary"), None])
)
runner = _runner(adapter)
_stop_after_sleeps(monkeypatch, runner, count=3)
from tools import async_delegation
acknowledgements = []
monkeypatch.setattr(
async_delegation,
"complete_completion_delivery",
lambda delegation_id, _claim_id: acknowledgements.append(delegation_id) or True,
raising=False,
)
asyncio.run(runner._async_delegation_watcher(interval=0))
assert adapter.handle_message.await_count == 2
assert acknowledgements == ["deleg_duplicate"]
def _persist_pending_completion(event):
from tools import async_delegation
async_delegation._persist_dispatch({
"delegation_id": event["delegation_id"],
"session_key": event["session_key"],
"origin_ui_session_id": "",
"parent_session_id": event.get("parent_session_id"),
"dispatched_at": event["dispatched_at"],
})
async_delegation._persist_completion(event, {
"status": "completed",
"summary": event["summary"],
})
def test_explicit_kill_returns_output_before_consuming_notification(monkeypatch):
import tools.process_registry as pr_module
registry = ProcessRegistry()
session = ProcessSession(
id="proc_kill_consumed",
command="sleep 999",
task_id="task",
started_at=1.0,
output_buffer="important terminal output\n",
notify_on_complete=True,
)
session.process = MagicMock()
session.process.pid = 4242
registry._running[session.id] = session
monkeypatch.setattr(registry, "_terminate_host_pid", lambda *_a, **_kw: None)
monkeypatch.setattr(registry, "_write_checkpoint", lambda: None)
monkeypatch.setattr(pr_module, "process_registry", registry)
result = registry.kill_process(session.id)
assert result["status"] == "killed"
assert result["output"] == "important terminal output\n"
assert registry.is_completion_consumed(session.id)
adapter = SimpleNamespace(handle_message=AsyncMock())
runner = _runner(adapter)
async def _instant_sleep(*_a, **_kw):
pass
monkeypatch.setattr(asyncio, "sleep", _instant_sleep)
asyncio.run(runner._run_process_watcher({
"session_id": session.id,
"check_interval": 0,
"session_key": "agent:main:telegram:dm:123",
"platform": "telegram",
"chat_type": "dm",
"chat_id": "123",
"notify_on_complete": True,
}))
adapter.handle_message.assert_not_awaited()
def test_process_tool_redacts_explicit_kill_output(monkeypatch):
from tools import process_registry as pr_module
registry = ProcessRegistry()
session = ProcessSession(
id="proc_kill_redacted",
command="printenv",
task_id="task",
started_at=1.0,
output_buffer="PRIVATE_TOKEN=opaque-value\n",
exited=True,
exit_code=0,
)
registry._finished[session.id] = session
monkeypatch.setattr(pr_module, "process_registry", registry)
def _redact(result):
assert result["output"] == "PRIVATE_TOKEN=opaque-value\n"
result["output"] = "PRIVATE_TOKEN=<redacted>\n"
return result
monkeypatch.setattr(pr_module, "_redact_process_result", _redact)
result = json.loads(pr_module._handle_process({
"action": "kill",
"session_id": session.id,
}))
assert result["output"] == "PRIVATE_TOKEN=<redacted>\n"
def test_autonomous_completion_redacts_real_command_and_output_secrets(monkeypatch):
import agent.redact as redact_module
import tools.process_registry as pr_module
secret = "abc123randomopaquetokenvalue999"
registry = ProcessRegistry()
session = ProcessSession(
id="proc_autonomous_redaction",
command=f"printenv MY_SERVICE_TOKEN={secret}",
task_id="task",
started_at=1234.5,
output_buffer=f"MY_SERVICE_TOKEN={secret}\nHOME=/home/user\n",
exited=True,
exit_code=0,
notify_on_complete=True,
)
registry._finished[session.id] = session
monkeypatch.setattr(pr_module, "process_registry", registry)
monkeypatch.setattr(redact_module, "_REDACT_ENABLED", True)
adapter = SimpleNamespace(handle_message=AsyncMock())
runner = _runner(adapter)
async def _instant_sleep(*_a, **_kw):
pass
monkeypatch.setattr(asyncio, "sleep", _instant_sleep)
asyncio.run(runner._run_process_watcher({
"session_id": session.id,
"check_interval": 0,
"session_key": "agent:main:telegram:dm:123",
"platform": "telegram",
"chat_type": "dm",
"chat_id": "123",
"notify_on_complete": True,
}))
delivered = adapter.handle_message.await_args.args[0]
assert secret not in delivered.text
assert "HOME=/home/user" in delivered.text