mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +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.
212 lines
7.8 KiB
Python
212 lines
7.8 KiB
Python
"""Tests for the async-delivery capability gate (issue #10760).
|
|
|
|
Stateless request/response adapters (the API server / WebUI path) cannot route
|
|
a background completion back to the agent after a turn ends — there is no
|
|
persistent channel and ``APIServerAdapter.send()`` is a no-op stub. So tools
|
|
that promise async delivery (``terminal`` notify_on_complete / watch_patterns,
|
|
``delegate_task`` background=True) must refuse the promise on that path instead
|
|
of silently registering a watcher that never fires.
|
|
|
|
This is wired through:
|
|
- ``BasePlatformAdapter.supports_async_delivery`` (default True)
|
|
- ``APIServerAdapter.supports_async_delivery = False``
|
|
- ``gateway.session_context._SESSION_ASYNC_DELIVERY`` contextvar +
|
|
``async_delivery_supported()`` helper, bound per-session.
|
|
|
|
These are behavior/invariant tests (how the capability relates to the channel),
|
|
not snapshots of a current value.
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from gateway.session_context import (
|
|
async_delivery_supported,
|
|
clear_session_vars,
|
|
get_session_env,
|
|
reset_session_vars,
|
|
set_session_vars,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Capability helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestAsyncDeliverySupported:
|
|
def test_default_unbound_is_supported(self):
|
|
"""CLI / cron / unaware paths never bind the var -> supported."""
|
|
assert async_delivery_supported() is True
|
|
|
|
|
|
def test_set_false_is_unsupported(self):
|
|
tokens = set_session_vars(
|
|
platform="api_server",
|
|
chat_id="sess1",
|
|
session_key="sess1",
|
|
async_delivery=False,
|
|
)
|
|
try:
|
|
assert async_delivery_supported() is False
|
|
# Platform must still be readable for routing/diagnostics even
|
|
# though delivery is unsupported.
|
|
assert get_session_env("HERMES_SESSION_PLATFORM") == "api_server"
|
|
finally:
|
|
clear_session_vars(tokens)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stateless runners — issues #53027 / #63142
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDeclareStatelessChannel:
|
|
"""``hermes -z`` and cron cannot receive a completion after their turn ends.
|
|
|
|
Cron clears the ``HERMES_SESSION_*`` routing keys, so an async delegation's
|
|
completion event carries ``session_key=""`` and the gateway watcher drops it
|
|
for lack of routing metadata; either way the job's final response has already
|
|
shipped. One-shot simply exits. Both must bind the capability, or
|
|
``delegate_task`` is forced background and every subagent result is lost.
|
|
"""
|
|
|
|
|
|
def test_declare_does_not_engage_full_session_context(self):
|
|
"""The helper binds ONLY the capability.
|
|
|
|
``set_session_vars`` latches ``_session_context_engaged``, which flips the
|
|
subprocess env bridge to ContextVar-authoritative. A pure single-process
|
|
one-shot must not trigger that as a side effect of declaring a capability.
|
|
"""
|
|
from gateway import session_context as sc
|
|
|
|
reset_session_vars()
|
|
engaged_before = sc._session_context_engaged
|
|
try:
|
|
sc.declare_stateless_channel()
|
|
assert sc._session_context_engaged is engaged_before
|
|
finally:
|
|
reset_session_vars()
|
|
|
|
|
|
class TestStatelessChannelForcesSyncDelegation:
|
|
"""The behavioral contract: a stateless channel must run delegations INLINE.
|
|
|
|
This is the regression that #53027 / #63142 describe — a background dispatch
|
|
on a channel that can never deliver the completion.
|
|
"""
|
|
|
|
def test_background_delegation_runs_inline_when_channel_is_stateless(
|
|
self, monkeypatch
|
|
):
|
|
import tools.delegate_tool as dt
|
|
from gateway.session_context import declare_stateless_channel
|
|
|
|
class _Parent:
|
|
_delegate_depth = 0
|
|
_subagent_id = None
|
|
|
|
fake_child = type("C", (), {"_subagent_id": "s1"})()
|
|
dispatched = []
|
|
|
|
def _fake_dispatch(*a, **kw):
|
|
dispatched.append(kw)
|
|
return {"delegation_id": "deleg_x"}
|
|
|
|
def _child(task_index, goal, child=None, parent_agent=None, **kw):
|
|
return {
|
|
"task_index": 0, "status": "completed", "summary": f"done: {goal}",
|
|
"api_calls": 1, "duration_seconds": 0.1, "model": "m",
|
|
"exit_reason": "completed",
|
|
}
|
|
|
|
creds = {
|
|
"model": "m", "provider": None, "base_url": None, "api_key": None,
|
|
"api_mode": None, "command": None, "args": None,
|
|
}
|
|
monkeypatch.setattr(dt, "_build_child_agent", lambda **kw: fake_child)
|
|
monkeypatch.setattr(dt, "_run_single_child", _child)
|
|
monkeypatch.setattr(dt, "_resolve_delegation_credentials", lambda *a, **k: creds)
|
|
monkeypatch.setattr(
|
|
"tools.async_delegation.dispatch_async_delegation_batch", _fake_dispatch
|
|
)
|
|
|
|
reset_session_vars()
|
|
try:
|
|
declare_stateless_channel()
|
|
out = dt.delegate_task(
|
|
goal="review the spec", background=True, parent_agent=_Parent()
|
|
)
|
|
finally:
|
|
reset_session_vars()
|
|
|
|
parsed = json.loads(out)
|
|
# The whole point: NOT dispatched to a channel that can't deliver.
|
|
assert not dispatched, "stateless channel must not dispatch a detached child"
|
|
assert parsed.get("status") != "dispatched"
|
|
# The caller gets the actual work product, in-turn.
|
|
assert "results" in parsed
|
|
assert "done: review the spec" in json.dumps(parsed)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Adapter capability flag
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestAdapterCapabilityFlag:
|
|
|
|
|
|
def test_api_server_bind_chokepoint_hardwires_no_delivery(self):
|
|
"""Every API-server agent-entry path binds through
|
|
_bind_api_server_session, which hardwires async_delivery=False — a new
|
|
route physically cannot reintroduce the silent no-op (#10760)."""
|
|
from gateway.platforms.api_server import APIServerAdapter
|
|
from gateway.session_context import clear_session_vars, get_session_env
|
|
|
|
tokens = APIServerAdapter._bind_api_server_session(
|
|
chat_id="c1", session_key="sk1", session_id="sid1"
|
|
)
|
|
try:
|
|
assert async_delivery_supported() is False
|
|
assert get_session_env("HERMES_SESSION_PLATFORM") == "api_server"
|
|
finally:
|
|
clear_session_vars(tokens)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# terminal_tool: refuses to register a watcher on unsupported sessions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestTerminalNotifyGate:
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_watchers(self):
|
|
from tools.process_registry import process_registry
|
|
|
|
process_registry.pending_watchers = []
|
|
yield
|
|
process_registry.pending_watchers = []
|
|
|
|
def _run_bg(self, command):
|
|
from tools.terminal_tool import terminal_tool
|
|
|
|
return json.loads(
|
|
terminal_tool(command=command, background=True, notify_on_complete=True)
|
|
)
|
|
|
|
def test_api_server_skips_watcher_and_notes(self):
|
|
from tools.process_registry import process_registry
|
|
|
|
tokens = set_session_vars(
|
|
platform="api_server", chat_id="s1", session_key="s1", async_delivery=False
|
|
)
|
|
try:
|
|
d = self._run_bg("sleep 30 && echo DONE")
|
|
finally:
|
|
clear_session_vars(tokens)
|
|
|
|
assert d.get("notify_on_complete") is False
|
|
assert d.get("notify_unsupported"), "must explain the limitation"
|
|
assert "poll" in d["notify_unsupported"].lower()
|
|
assert len(process_registry.pending_watchers) == 0
|
|
|
|
|