mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Systematic prune per AGENTS.md test policy, one pass over every major test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli, cron, tui_gateway, honcho/openviking, root-level): - DELETE: source-reading tests (read_text/getsource on prod files), change-detector tests (exact catalog counts, model-name snapshots, config version literals), mock-echo tests (assert a mock returns what it was told), assertion-free/trivial tests, near-duplicate parametrizations (boundaries + one representative kept), async/sync twin duplicates, cosmetic within-file variations. - KEEP (mandatory): security/redaction/approval guards, message-role alternation invariants, prompt-caching/deterministic-call-id invariants, issue-number regression tests (deduped), E2E tests. - 6 test files deleted outright (script-style/no-assert or fully redundant); conftest.py, fakes/, fixtures/ untouched. - tests/acp/conftest.py added: autouse fixture stubs the live models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server tests performed on every session create — test_server.py 147s → 3.4s, and the tests are now genuinely hermetic. - Sleep-based slowness shrunk where safe (codex_ttfb_watchdog, compression_concurrent_fork, etc.); no wall-clock assertion tightened. Verification: full hermetic suite via scripts/run_tests.sh — 2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall (baseline: 583s wall, 13,564s subprocess CPU).
211 lines
6.5 KiB
Python
211 lines
6.5 KiB
Python
"""Sidecar lifecycle tests: orphan reaping and parent-death wiring.
|
|
|
|
A hard gateway exit used to leave the detached Node sidecar squatting the
|
|
loopback port with a token the next gateway run doesn't know — every
|
|
replacement spawn then died on EADDRINUSE. These tests cover the startup
|
|
reaper (`_reap_stale_sidecar`) and the stdin-pipe lifetime binding, without
|
|
spawning Node or binding ports.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from typing import Any, Dict, List, Tuple
|
|
|
|
import pytest
|
|
|
|
from gateway.config import PlatformConfig
|
|
from plugins.platforms.photon import adapter as photon_adapter
|
|
from plugins.platforms.photon.adapter import PhotonAdapter
|
|
|
|
|
|
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
|
|
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
|
|
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
|
|
cfg = PlatformConfig(enabled=True, token="", extra={})
|
|
return PhotonAdapter(cfg)
|
|
|
|
|
|
class _ProbeClient:
|
|
"""Fake httpx.AsyncClient whose /healthz probe behavior is injectable."""
|
|
|
|
connects = True
|
|
|
|
def __init__(self, *a: Any, **k: Any) -> None:
|
|
pass
|
|
|
|
async def __aenter__(self) -> "_ProbeClient":
|
|
return self
|
|
|
|
async def __aexit__(self, *a: Any) -> bool:
|
|
return False
|
|
|
|
async def post(self, *a: Any, **k: Any) -> Any:
|
|
if not self.connects:
|
|
raise photon_adapter.httpx.ConnectError("connection refused")
|
|
|
|
class _Resp:
|
|
status_code = 401 # orphan with a different token
|
|
|
|
return _Resp()
|
|
|
|
|
|
def _capture_kills(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[int, int]]:
|
|
kills: List[Tuple[int, int]] = []
|
|
|
|
def _fake_kill(pid: int, sig: int) -> None:
|
|
kills.append((pid, sig))
|
|
|
|
monkeypatch.setattr(photon_adapter.os, "kill", _fake_kill)
|
|
return kills
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reap_noop_when_port_free(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
adapter = _make_adapter(monkeypatch)
|
|
|
|
class _Refused(_ProbeClient):
|
|
connects = False
|
|
|
|
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _Refused)
|
|
kills = _capture_kills(monkeypatch)
|
|
|
|
await adapter._reap_stale_sidecar()
|
|
|
|
assert kills == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_sidecar_spawns_with_stdin_pipe(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path
|
|
) -> None:
|
|
"""The spawn must hold a stdin pipe and enable the sidecar's EOF watch."""
|
|
adapter = _make_adapter(monkeypatch)
|
|
|
|
async def _no_reap() -> None:
|
|
pass
|
|
|
|
monkeypatch.setattr(adapter, "_reap_stale_sidecar", _no_reap)
|
|
(tmp_path / "node_modules" / "spectrum-ts").mkdir(parents=True)
|
|
monkeypatch.setattr(photon_adapter, "_SIDECAR_DIR", tmp_path)
|
|
|
|
spawned: Dict[str, Any] = {}
|
|
hidden_flags = 0x08000000
|
|
monkeypatch.setattr(
|
|
"hermes_cli._subprocess_compat.windows_hide_flags",
|
|
lambda: hidden_flags,
|
|
)
|
|
|
|
class _PatchResult:
|
|
returncode = 0
|
|
stdout = ""
|
|
stderr = ""
|
|
|
|
def _fake_run(cmd: List[str], **kwargs: Any) -> _PatchResult:
|
|
spawned["patch_cmd"] = cmd
|
|
spawned["patch_kwargs"] = kwargs
|
|
return _PatchResult()
|
|
|
|
monkeypatch.setattr(photon_adapter.subprocess, "run", _fake_run)
|
|
|
|
class _FakeProc:
|
|
pid = 999
|
|
stdout = None
|
|
stdin = None
|
|
|
|
@staticmethod
|
|
def poll() -> None:
|
|
return None
|
|
|
|
def _fake_popen(cmd: List[str], **kwargs: Any) -> _FakeProc:
|
|
spawned["cmd"] = cmd
|
|
spawned["kwargs"] = kwargs
|
|
return _FakeProc()
|
|
|
|
monkeypatch.setattr(photon_adapter.subprocess, "Popen", _fake_popen)
|
|
|
|
class _HealthyClient(_ProbeClient):
|
|
async def post(self, *a: Any, **k: Any) -> Any:
|
|
class _Resp:
|
|
status_code = 200
|
|
|
|
return _Resp()
|
|
|
|
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _HealthyClient)
|
|
|
|
await adapter._start_sidecar()
|
|
|
|
kwargs = spawned["kwargs"]
|
|
assert kwargs["stdin"] is subprocess.PIPE
|
|
assert kwargs["env"]["PHOTON_SIDECAR_WATCH_STDIN"] == "1"
|
|
assert spawned["patch_kwargs"]["creationflags"] == hidden_flags
|
|
assert kwargs["creationflags"] == hidden_flags
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_spectrum_patch_runs_off_the_event_loop(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The node patch run must not block the shared gateway event loop.
|
|
|
|
``_start_sidecar`` spawns the Spectrum patch script and *waits* for it
|
|
(``timeout=10``). Run inline it holds the loop for that whole window, so
|
|
every other platform's traffic stalls — and ``_start_sidecar`` runs on
|
|
every reconnect (``connect(is_reconnect=True)``), not just startup, so the
|
|
stall recurs on a live gateway. The dep reinstall a few lines above already
|
|
hops to a thread for exactly this reason; the patch run must too.
|
|
"""
|
|
import threading
|
|
|
|
adapter = _make_adapter(monkeypatch)
|
|
main_thread = threading.current_thread()
|
|
seen: Dict[str, Any] = {}
|
|
|
|
# node_modules present + deps fresh, so we reach the patch run.
|
|
monkeypatch.setattr(photon_adapter.Path, "exists", lambda self: True)
|
|
monkeypatch.setattr(photon_adapter, "_sidecar_deps_stale", lambda: False)
|
|
|
|
async def _no_reap() -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(adapter, "_reap_stale_sidecar", _no_reap)
|
|
|
|
def _fake_run(*a: Any, **k: Any) -> Any:
|
|
seen["thread"] = threading.current_thread()
|
|
|
|
class _Done:
|
|
returncode = 0
|
|
stdout = ""
|
|
stderr = ""
|
|
|
|
return _Done()
|
|
|
|
monkeypatch.setattr(photon_adapter.subprocess, "run", _fake_run)
|
|
|
|
class _FakeProc:
|
|
pid = 4242
|
|
stdin = None
|
|
stdout = None
|
|
|
|
def poll(self) -> int:
|
|
# Report "exited" so the readiness health-poll loop bails out
|
|
# immediately instead of spinning for its full 15s deadline —
|
|
# the assertion below only cares where the patch run executed.
|
|
return 0
|
|
|
|
monkeypatch.setattr(
|
|
photon_adapter.subprocess, "Popen", lambda *a, **k: _FakeProc()
|
|
)
|
|
|
|
try:
|
|
await adapter._start_sidecar()
|
|
except Exception:
|
|
# Readiness/handshake past the patch run may fail under the fakes —
|
|
# irrelevant here; we only assert where the patch run executed.
|
|
pass
|
|
|
|
assert seen.get("thread") is not None, "patch run never executed"
|
|
assert seen["thread"] is not main_thread, (
|
|
"Spectrum patch subprocess ran on the event-loop thread; it must be "
|
|
"dispatched via asyncio.to_thread so a 10s node spawn can't freeze "
|
|
"every other platform on the gateway loop"
|
|
)
|