hermes-agent/tests/tools/test_mcp_elicitation.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

251 lines
9.5 KiB
Python

"""Tests for the MCP elicitation handler in tools.mcp_tool.
These tests exercise ElicitationHandler in isolation -- the underlying
approval system and the MCP transport layer are mocked, so no real MCP
server or user input is required.
Tests skip cleanly if the optional `mcp` SDK is not installed (it is an
optional dependency under the `[mcp]` extra).
"""
import asyncio
from unittest.mock import patch
import pytest
pytest.importorskip("mcp.types")
from mcp.types import ElicitResult # noqa: E402 -- after importorskip
from tools.mcp_tool import ( # noqa: E402
ElicitationHandler,
_format_elicitation_schema_summary,
)
def _form_params(message="please confirm", schema=None):
"""Build a stand-in for ElicitRequestFormParams.
We use a plain object (not the SDK type directly) so the test doesn't
couple to optional Pydantic validation -- the handler reads fields via
getattr() and tolerates duck-typed inputs.
"""
from types import SimpleNamespace
return SimpleNamespace(
mode="form",
message=message,
requested_schema=schema or {},
)
def _url_params(message="open this url", url="https://example.com/auth", elicitation_id="e1"):
from types import SimpleNamespace
return SimpleNamespace(
mode="url",
message=message,
url=url,
elicitation_id=elicitation_id,
)
class TestSchemaSummary:
def test_empty_schema_falls_back_to_generic_message(self):
out = _format_elicitation_schema_summary({}, "pay")
assert "pay" in out
assert "Approval requested" in out
def test_properties_render_with_type_and_description(self):
schema = {
"type": "object",
"properties": {
"amount": {"type": "string", "description": "USD amount"},
"recipient": {"type": "string"},
},
}
out = _format_elicitation_schema_summary(schema, "pay")
assert "amount (string): USD amount" in out
assert "recipient (string)" in out
class TestElicitationHandlerFormMode:
def test_user_accepts_once_returns_accept(self):
handler = ElicitationHandler("pay", {"timeout": 5})
params = _form_params(
"authorize a payment of $0.50",
{"properties": {"approved": {"type": "boolean"}}},
)
with patch("tools.approval.request_elicitation_consent", return_value="accept"):
result = asyncio.run(handler(context=None, params=params))
assert isinstance(result, ElicitResult)
assert result.action == "accept"
assert result.content == {}
assert handler.metrics["accepted"] == 1
assert handler.metrics["declined"] == 0
def test_cancel_propagates_through(self):
"""request_elicitation_consent returns 'cancel' when the gateway
wait times out (resolved=False). The handler should propagate
that as ElicitResult(action='cancel') so the server can
distinguish 'no answer' from 'no'."""
handler = ElicitationHandler("pay", {"timeout": 5})
params = _form_params()
with patch("tools.approval.request_elicitation_consent", return_value="cancel"):
result = asyncio.run(handler(context=None, params=params))
assert result.action == "cancel"
assert handler.metrics["errors"] == 1
class TestElicitationHandlerFailureModes:
def test_url_mode_is_declined_without_prompting(self):
handler = ElicitationHandler("pay", {"timeout": 5})
params = _url_params()
# If the handler tried to prompt, this would raise AssertionError
# because the side_effect treats the call as a test failure.
with patch(
"tools.approval.request_elicitation_consent",
side_effect=AssertionError("URL mode must not prompt"),
):
result = asyncio.run(handler(context=None, params=params))
assert result.action == "decline"
assert handler.metrics["declined"] == 1
def test_exception_in_approval_fails_closed_to_decline(self):
handler = ElicitationHandler("pay", {"timeout": 5})
params = _form_params()
with patch(
"tools.approval.request_elicitation_consent",
side_effect=RuntimeError("approval system blew up"),
):
result = asyncio.run(handler(context=None, params=params))
assert result.action == "decline"
assert handler.metrics["errors"] == 1
def test_timeout_returns_cancel(self, monkeypatch):
# Shrink the outer grace window so the test budget is just the
# handler timeout. Default grace is 5s, which makes stall durations
# tight and the test flaky.
monkeypatch.setattr(
ElicitationHandler, "_OUTER_TIMEOUT_GRACE_SECONDS", 0
)
# _safe_numeric clamps `timeout` to a minimum of 1s, so the
# effective wait_for budget is 1s here. Stall longer than that
# so the wait_for reliably fires TimeoutError.
handler = ElicitationHandler("pay", {"timeout": 0.05})
params = _form_params()
def stall(*_args, **_kwargs):
import time as _t
_t.sleep(2)
return "accept"
with patch("tools.approval.request_elicitation_consent", side_effect=stall):
result = asyncio.run(handler(context=None, params=params))
assert result.action == "cancel"
assert handler.metrics["errors"] == 1
class TestElicitationHandlerWiring:
def test_session_kwargs_returns_callback(self):
handler = ElicitationHandler("pay", {})
kwargs = handler.session_kwargs()
assert kwargs == {"elicitation_callback": handler}
def test_disabled_config_does_not_construct_handler(self):
"""The server task initializer checks ``elicitation.enabled`` --
an explicit ``False`` should suppress handler creation. The unit
of that decision lives in MCPServerTask, but the handler itself
must remain harmless to instantiate with arbitrary config."""
handler = ElicitationHandler("pay", {"enabled": False, "timeout": 10})
# Just confirm it instantiates and reads timeout; the gate lives
# at the higher layer.
assert handler.timeout == 10
class TestElicitationHandlerContextBridge:
"""The MCP recv-loop task that fires elicitation callbacks does NOT
inherit the agent's contextvars (HERMES_SESSION_PLATFORM etc.). The
handler reads ``owner._pending_call_context`` -- a snapshot captured
by the MCP tool wrapper around ``session.call_tool`` -- and replays
it before invoking the approval router so gateway-session detection
survives the task hop. Regression tests for that bridge."""
def test_captured_context_is_replayed_in_consent_call(self):
"""The captured context's contextvar values must be observable
when ``request_elicitation_consent`` runs -- otherwise the
gateway-platform detection in approval.py sees an empty platform
string and falls back to the CLI path (the bug this fixes)."""
import contextvars
from types import SimpleNamespace
probe: contextvars.ContextVar[str] = contextvars.ContextVar(
"elicitation_test_probe", default=""
)
seen: list[str] = []
def fake_consent(*_args, **_kwargs):
seen.append(probe.get())
return "accept"
token = probe.set("gateway:telegram")
try:
captured = contextvars.copy_context()
finally:
probe.reset(token)
assert probe.get() == "", (
"Sanity check: the probe must be empty outside the captured "
"context, otherwise the test would pass even without replay."
)
owner = SimpleNamespace(_pending_call_context=captured)
handler = ElicitationHandler("pay", {"timeout": 5}, owner=owner)
params = _form_params()
with patch("tools.approval.request_elicitation_consent", side_effect=fake_consent):
result = asyncio.run(handler(context=None, params=params))
assert result.action == "accept"
assert seen == ["gateway:telegram"], (
f"Expected the captured contextvar to be visible inside the "
f"consent call; got {seen!r}"
)
def test_missing_captured_context_falls_back_to_direct_call(self):
"""Without an owner (or with an owner that hasn't entered a tool
call) the handler must still invoke the consent router -- just
without the contextvar replay. Otherwise CLI/TUI sessions, which
don't set HERMES_SESSION_PLATFORM, would break."""
handler = ElicitationHandler("pay", {"timeout": 5}, owner=None)
params = _form_params()
with patch("tools.approval.request_elicitation_consent", return_value="accept") as m:
result = asyncio.run(handler(context=None, params=params))
assert result.action == "accept"
assert m.call_count == 1
def test_pending_call_context_none_does_not_crash(self):
"""``owner._pending_call_context`` is set to None between tool
calls. An elicitation arriving in that window must not crash."""
from types import SimpleNamespace
owner = SimpleNamespace(_pending_call_context=None)
handler = ElicitationHandler("pay", {"timeout": 5}, owner=owner)
params = _form_params()
with patch("tools.approval.request_elicitation_consent", return_value="decline"):
result = asyncio.run(handler(context=None, params=params))
assert result.action == "decline"