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).
495 lines
17 KiB
Python
495 lines
17 KiB
Python
"""Photon adapter resilience to transient Spectrum/Envoy upstream overflow.
|
|
|
|
Covers the three behaviors that let the adapter ride through a Photon
|
|
"reset reason: overflow" event instead of degrading delivery and silently
|
|
dying (issue #50185):
|
|
|
|
1. ``_is_retryable_error`` classifies the Envoy/sidecar overflow strings as
|
|
retryable so ``_send_with_retry`` actually engages its backoff loop.
|
|
2. ``send_typing`` is rate-gated per chat, and ``stop_typing`` resets the
|
|
gate so the next turn's typing indicator fires immediately.
|
|
3. ``_supervise_sidecar`` detects an unexpected sidecar exit and raises a
|
|
``retryable=True`` fatal so the gateway reconnect watcher revives the
|
|
platform — instead of returning silently and leaving ``_inbound_loop``
|
|
spinning against a dead port.
|
|
4. ``_monitor_sidecar_health`` promotes degraded upstream stream health
|
|
reported by ``/healthz`` into the same retryable reconnect path.
|
|
|
|
No Node sidecar is spawned and no ports are bound.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from typing import Any, Dict
|
|
|
|
import pytest
|
|
|
|
from gateway.config import PlatformConfig
|
|
from gateway.platforms.base import SendResult
|
|
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)
|
|
|
|
|
|
# -- Gap 1: retryable classification of overflow errors ---------------------
|
|
|
|
@pytest.mark.parametrize(
|
|
"error",
|
|
[
|
|
"UNAVAILABLE: internal sidecar error",
|
|
"upstream connect error or disconnect/reset before headers",
|
|
"reset reason: overflow",
|
|
# Case-insensitive: real strings arrive with mixed case.
|
|
"Internal Sidecar Error",
|
|
],
|
|
)
|
|
def test_overflow_strings_classified_retryable(error: str) -> None:
|
|
assert PhotonAdapter._is_retryable_error(error) is True
|
|
|
|
|
|
def test_unrelated_error_not_retryable() -> None:
|
|
# A genuine permanent failure must NOT be retried.
|
|
assert PhotonAdapter._is_retryable_error("400 bad request: invalid spaceId") is False
|
|
assert PhotonAdapter._is_retryable_error(None) is False
|
|
|
|
|
|
def test_base_network_patterns_still_match() -> None:
|
|
# The override delegates to the base classifier first, so generic
|
|
# network strings keep working.
|
|
assert PhotonAdapter._is_retryable_error("ConnectError: connection refused") is True
|
|
|
|
|
|
def test_structured_non_retryable_sidecar_error_not_legacy_retried() -> None:
|
|
error = str(
|
|
photon_adapter.PhotonSidecarError(
|
|
path="/send",
|
|
status_code=500,
|
|
error="internal sidecar error",
|
|
error_class="auth_or_config",
|
|
retryable=False,
|
|
)
|
|
)
|
|
|
|
assert PhotonAdapter._is_retryable_error(error) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_with_retry_uses_structured_retryable_flag(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
adapter = _make_adapter(monkeypatch)
|
|
calls = 0
|
|
sleeps: list[float] = []
|
|
|
|
async def _fake_sleep(delay: float) -> None:
|
|
sleeps.append(delay)
|
|
|
|
async def _fake_sidecar_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls == 1:
|
|
raise photon_adapter.PhotonSidecarError(
|
|
path=path,
|
|
status_code=500,
|
|
error="temporary upstream failure",
|
|
error_class="upstream_transient",
|
|
retryable=True,
|
|
)
|
|
return {"ok": True, "messageId": "m-2"}
|
|
|
|
monkeypatch.setattr(photon_adapter.asyncio, "sleep", _fake_sleep)
|
|
monkeypatch.setattr(adapter, "_sidecar_call", _fake_sidecar_call)
|
|
|
|
result = await adapter._send_with_retry(
|
|
"space-1", "hello", max_retries=1, base_delay=0.25
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.message_id == "m-2"
|
|
assert calls == 2
|
|
assert sleeps == [0.25]
|
|
|
|
|
|
# -- Gap 2: typing-indicator cooldown ---------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_typing_cooldown_suppresses_rapid_repeats(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
adapter = _make_adapter(monkeypatch)
|
|
calls: list[Dict[str, Any]] = []
|
|
|
|
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
|
|
calls.append(payload)
|
|
return {"ok": True}
|
|
|
|
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
|
|
|
|
# First call fires; immediate repeats are suppressed by the cooldown.
|
|
await adapter.send_typing("chat-1")
|
|
await adapter.send_typing("chat-1")
|
|
await adapter.send_typing("chat-1")
|
|
|
|
assert len(calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_typing_resets_cooldown(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
adapter = _make_adapter(monkeypatch)
|
|
starts = 0
|
|
|
|
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
|
|
nonlocal starts
|
|
if payload.get("state") == "start":
|
|
starts += 1
|
|
return {"ok": True}
|
|
|
|
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
|
|
|
|
# A start, then a stop (end of turn), then a start for the next turn must
|
|
# fire immediately — the cooldown only suppresses rapid consecutive starts
|
|
# without an intervening stop.
|
|
await adapter.send_typing("chat-1")
|
|
await adapter.stop_typing("chat-1")
|
|
await adapter.send_typing("chat-1")
|
|
|
|
assert starts == 2
|
|
|
|
|
|
# -- Gap 3: sidecar crash detection -----------------------------------------
|
|
|
|
class _EofStdout:
|
|
"""A proc.stdout whose readline() reports immediate EOF (dead sidecar)."""
|
|
|
|
def readline(self) -> bytes:
|
|
return b""
|
|
|
|
|
|
class _DeadProc:
|
|
"""Minimal subprocess.Popen stand-in for a sidecar that has exited."""
|
|
|
|
def __init__(self, exit_code: int = 1) -> None:
|
|
self.stdout = _EofStdout()
|
|
self.stdin = None
|
|
self._exit_code = exit_code
|
|
|
|
def poll(self) -> int:
|
|
return self._exit_code
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unexpected_sidecar_exit_raises_retryable_fatal(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
adapter = _make_adapter(monkeypatch)
|
|
# Simulate a live session whose sidecar then dies underneath it.
|
|
adapter._inbound_running = True
|
|
|
|
notified: list[bool] = []
|
|
|
|
async def _fake_notify() -> None:
|
|
notified.append(True)
|
|
|
|
monkeypatch.setattr(adapter, "_notify_fatal_error", _fake_notify)
|
|
|
|
await adapter._supervise_sidecar(_DeadProc(exit_code=137)) # type: ignore[arg-type]
|
|
|
|
assert adapter.has_fatal_error is True
|
|
assert adapter.fatal_error_code == "SIDECAR_CRASHED"
|
|
# retryable=True routes the platform into the reconnect watcher rather
|
|
# than crashing the whole gateway.
|
|
assert adapter.fatal_error_retryable is True
|
|
assert adapter._running is False
|
|
# The notification is dispatched onto its own task rather than awaited on
|
|
# the supervisor's stack, so that disconnect() cancelling the supervisor
|
|
# cannot kill the handoff. Let that task run before asserting delivery.
|
|
await _drain_pending_tasks()
|
|
assert notified == [True]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clean_shutdown_does_not_raise_fatal(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
adapter = _make_adapter(monkeypatch)
|
|
# disconnect() sets _inbound_running = False before stopping the sidecar,
|
|
# so the detection block must NOT fire on a clean shutdown.
|
|
adapter._inbound_running = False
|
|
|
|
notified: list[bool] = []
|
|
|
|
async def _fake_notify() -> None:
|
|
notified.append(True)
|
|
|
|
monkeypatch.setattr(adapter, "_notify_fatal_error", _fake_notify)
|
|
|
|
await adapter._supervise_sidecar(_DeadProc(exit_code=0)) # type: ignore[arg-type]
|
|
|
|
assert adapter.has_fatal_error is False
|
|
assert notified == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_degraded_stream_health_raises_retryable_fatal(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
adapter = _make_adapter(monkeypatch)
|
|
adapter._inbound_running = True
|
|
adapter._sidecar_health_interval = 0.0
|
|
|
|
async def _fake_call(path: str, payload: Dict[str, Any]) -> Any:
|
|
assert path == "/healthz"
|
|
return {
|
|
"ok": True,
|
|
"stream": {
|
|
"ok": False,
|
|
"state": "degraded",
|
|
"degradedForMs": 120000,
|
|
"lastIssue": "[spectrum.stream] stream interrupted; reconnecting",
|
|
},
|
|
}
|
|
|
|
notified: list[bool] = []
|
|
|
|
async def _fake_notify() -> None:
|
|
notified.append(True)
|
|
adapter._inbound_running = False
|
|
|
|
monkeypatch.setattr(adapter, "_sidecar_call", _fake_call)
|
|
monkeypatch.setattr(adapter, "_notify_fatal_error", _fake_notify)
|
|
|
|
await adapter._monitor_sidecar_health()
|
|
|
|
assert adapter.has_fatal_error is True
|
|
assert adapter.fatal_error_code == "UPSTREAM_STREAM_DEGRADED"
|
|
assert adapter.fatal_error_retryable is True
|
|
# Dispatched detached (see _dispatch_fatal_notification) so the health
|
|
# task's own teardown cannot cancel the handoff; drain before asserting.
|
|
await _drain_pending_tasks()
|
|
assert notified == [True]
|
|
|
|
|
|
async def _drain_pending_tasks(limit: int = 50) -> None:
|
|
"""Let detached fatal-notification tasks finish before asserting on them.
|
|
|
|
``_dispatch_fatal_notification`` deliberately does not await the
|
|
notification (that is what kept ``disconnect()`` from cancelling its own
|
|
caller), so a test that drives ``_monitor_sidecar_health`` /
|
|
``_supervise_sidecar`` directly returns before the notification has run.
|
|
"""
|
|
for _ in range(limit):
|
|
pending = [
|
|
t for t in asyncio.all_tasks()
|
|
if t is not asyncio.current_task() and not t.done()
|
|
]
|
|
if not pending:
|
|
return
|
|
await asyncio.wait(pending, timeout=1.0)
|
|
|
|
|
|
# -- Gap 5: self-cancellation race in _stop_sidecar() (issue #73159) --------
|
|
#
|
|
# The tests above mock out _notify_fatal_error() entirely, so the real
|
|
# integration chain (supervisor task -> _notify_fatal_error() ->
|
|
# disconnect() -> _stop_sidecar() -> cancel the supervisor task) is never
|
|
# exercised end to end. That chain is exactly where the bug lives: when
|
|
# _notify_fatal_error() is a real callback that calls adapter.disconnect(),
|
|
# _stop_sidecar() is invoked FROM WITHIN the currently-running supervisor
|
|
# task, and cancelling self._sidecar_supervisor_task there cancels the very
|
|
# task executing the fatal-error handler -- aborting it (via CancelledError,
|
|
# a BaseException the handler's `except Exception` guards don't catch)
|
|
# before the Gateway's reconnect-queue step ever runs.
|
|
|
|
class _FakeStoppedProc:
|
|
"""Minimal proc stand-in so _stop_sidecar() reaches its finally block
|
|
(it early-returns entirely when self._sidecar_proc is None) without
|
|
spawning a real subprocess or doing any real I/O."""
|
|
|
|
def __init__(self) -> None:
|
|
self.stdin = None
|
|
|
|
def wait(self, timeout: float | None = None) -> int:
|
|
return 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_supervisor_task_survives_self_triggered_disconnect(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The real chain: _supervise_sidecar() (running as the actual
|
|
self._sidecar_supervisor_task) detects the crash and calls a REAL
|
|
_notify_fatal_error() that calls adapter.disconnect() -- which reaches
|
|
_stop_sidecar() from inside the task it's about to try to cancel.
|
|
|
|
Before the fix: this raises CancelledError out of _supervise_sidecar(),
|
|
so the task ends up in the "cancelled" state and reconnect_queued below
|
|
is never set (mirroring how the real Gateway's fatal-error handler,
|
|
which runs the reconnect-queue logic AFTER disconnect() returns, never
|
|
gets there either).
|
|
|
|
After the fix: disconnect() completes normally, _supervise_sidecar()
|
|
returns normally, and the task is NOT cancelled.
|
|
"""
|
|
adapter = _make_adapter(monkeypatch)
|
|
adapter._inbound_running = True
|
|
# A minimal fake proc so _stop_sidecar() reaches its finally block
|
|
# (the real process-management behavior is covered separately by
|
|
# test_sidecar_lifecycle.py) -- this test is purely about the
|
|
# self-cancellation race.
|
|
adapter._sidecar_proc = _FakeStoppedProc()
|
|
|
|
reconnect_queued: list[bool] = []
|
|
|
|
async def _real_notify_fatal_error() -> None:
|
|
# Stand-in for the Gateway's actual fatal-error handler: it calls
|
|
# adapter.disconnect() (real chain: disconnect -> _stop_sidecar,
|
|
# which used to self-cancel), then -- only if that completes
|
|
# without the CancelledError escaping -- proceeds to queue the
|
|
# platform for background reconnection.
|
|
await adapter.disconnect()
|
|
reconnect_queued.append(True)
|
|
|
|
monkeypatch.setattr(adapter, "_notify_fatal_error", _real_notify_fatal_error)
|
|
|
|
async def _run_supervisor():
|
|
await adapter._supervise_sidecar(_DeadProc(exit_code=75))
|
|
|
|
task = asyncio.ensure_future(_run_supervisor())
|
|
adapter._sidecar_supervisor_task = task
|
|
|
|
# Must complete cleanly -- must NOT raise CancelledError out to us.
|
|
await task
|
|
|
|
assert task.cancelled() is False, (
|
|
"The supervisor task must not end up cancelled by its own "
|
|
"fatal-error handling chain"
|
|
)
|
|
assert adapter.has_fatal_error is True
|
|
assert adapter.fatal_error_code == "SIDECAR_CRASHED"
|
|
assert reconnect_queued == [True], (
|
|
"The reconnect-queue step (everything after disconnect() returns "
|
|
"in the real Gateway handler) must actually run -- this is the "
|
|
"exact step issue #73159 reports as silently skipped"
|
|
)
|
|
# _stop_sidecar() must have cleared the task reference either way.
|
|
assert adapter._sidecar_supervisor_task is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_external_disconnect_still_cancels_supervisor(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The OTHER call path -- external cleanup (Gateway shutdown, an
|
|
explicit /platform disconnect) -- calls _stop_sidecar() from a
|
|
DIFFERENT task than the supervisor. That legitimate case must still
|
|
cancel a still-running supervisor task exactly as before."""
|
|
adapter = _make_adapter(monkeypatch)
|
|
adapter._sidecar_proc = _FakeStoppedProc()
|
|
|
|
supervisor_ran_forever = asyncio.Event()
|
|
|
|
async def _hangs_forever():
|
|
try:
|
|
supervisor_ran_forever.set()
|
|
await asyncio.sleep(3600)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
|
|
task = asyncio.ensure_future(_hangs_forever())
|
|
adapter._sidecar_supervisor_task = task
|
|
await supervisor_ran_forever.wait()
|
|
|
|
# Called from THIS (different) task -- the external-cleanup case.
|
|
await adapter._stop_sidecar()
|
|
|
|
# cancel() only schedules the CancelledError; await it so the task
|
|
# actually settles into the cancelled state before asserting.
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
assert task.cancelled() is True, (
|
|
"External cleanup must still cancel a running supervisor task"
|
|
)
|
|
assert adapter._sidecar_supervisor_task is None
|
|
|
|
|
|
# -- target_not_allowed: shared/free-tier outbound-send restriction ----------
|
|
#
|
|
# Spectrum throws AuthenticationError("Target not allowed for this project")
|
|
# from space.send when a shared/free-tier line initiates an outbound send to
|
|
# a new target. The sidecar classifies it as the structured code
|
|
# `target_not_allowed`; the adapter must treat it as permanent in BOTH
|
|
# _send_with_retry and _standalone_send, surfacing the canonical user-facing
|
|
# message instead of raw upstream error text (issues #50971 / #51897).
|
|
|
|
|
|
def test_target_not_allowed_maps_to_canonical_message() -> None:
|
|
err = photon_adapter._sidecar_error_from_response(
|
|
"/send",
|
|
500,
|
|
'{"ok":false,"error":"internal sidecar error",'
|
|
'"error_class":"target_not_allowed","retryable":false}',
|
|
)
|
|
|
|
assert err.error_class == "target_not_allowed"
|
|
assert err.retryable is False
|
|
assert err.error == photon_adapter._TARGET_NOT_ALLOWED_MESSAGE
|
|
# No raw upstream text may leak through the structured code path.
|
|
assert "Target not allowed for this project" not in str(err)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_send_classifies_target_not_allowed(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("PHOTON_SIDECAR_TOKEN", "token")
|
|
|
|
class _Resp:
|
|
status_code = 500
|
|
text = (
|
|
'{"ok":false,"error":"internal sidecar error",'
|
|
'"error_class":"target_not_allowed","retryable":false}'
|
|
)
|
|
|
|
@staticmethod
|
|
def json() -> Dict[str, Any]:
|
|
return {
|
|
"ok": False,
|
|
"error": "internal sidecar error",
|
|
"error_class": "target_not_allowed",
|
|
"retryable": False,
|
|
}
|
|
|
|
class _FakeClient:
|
|
def __init__(self, *a: Any, **k: Any) -> None:
|
|
pass
|
|
|
|
async def __aenter__(self) -> "_FakeClient":
|
|
return self
|
|
|
|
async def __aexit__(self, *a: Any) -> bool:
|
|
return False
|
|
|
|
async def post(self, *a: Any, **k: Any) -> _Resp:
|
|
return _Resp()
|
|
|
|
monkeypatch.setattr(photon_adapter.httpx, "AsyncClient", _FakeClient)
|
|
|
|
result = await photon_adapter._standalone_send(
|
|
PlatformConfig(enabled=True, extra={}), "space-1", "hello",
|
|
)
|
|
|
|
assert result.get("error") == photon_adapter._TARGET_NOT_ALLOWED_MESSAGE
|
|
assert result.get("error_class") == "target_not_allowed"
|
|
assert result.get("retryable") is False
|
|
assert "Target not allowed for this project" not in str(result)
|