mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
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.
318 lines
10 KiB
Python
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
|