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.
155 lines
4.9 KiB
Python
155 lines
4.9 KiB
Python
"""Regression tests for #62034 — pending multi-choice clarify prompts must not
|
|
swallow unrelated thread follow-up messages.
|
|
|
|
When a NATIVE interactive multi-choice clarify (buttons rendered,
|
|
``awaiting_text=False``) is pending, the gateway text-intercept used to
|
|
consume ANY non-command message in the session as the clarify answer —
|
|
arbitrary prose vanished into clarify resolution and the agent appeared to
|
|
ignore the user's thread messages.
|
|
|
|
After the fix (``tools/clarify_gateway._coerce_text_response`` rejects
|
|
arbitrary prose for native multi-choice prompts):
|
|
|
|
* numeric selections ("2") and exact choice labels still resolve, and
|
|
* arbitrary prose falls through the intercept and continues as a normal
|
|
message-handling turn.
|
|
|
|
Open-ended clarifies, explicit "Other" text-capture mode, and the base
|
|
adapter's numbered-text fallback (which flips ``awaiting_text`` at send time)
|
|
keep accepting free text.
|
|
"""
|
|
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
from gateway.platforms.base import (
|
|
BasePlatformAdapter,
|
|
MessageEvent,
|
|
MessageType,
|
|
SendResult,
|
|
)
|
|
from gateway.session import SessionSource
|
|
|
|
|
|
SESSION_KEY = "agent:main:slack:dm:D123:1111.2222"
|
|
|
|
|
|
class _StubAdapter(BasePlatformAdapter):
|
|
def __init__(self):
|
|
super().__init__(PlatformConfig(enabled=True, token="test"), Platform.SLACK)
|
|
|
|
async def connect(self, *, is_reconnect: bool = False):
|
|
return True
|
|
|
|
async def disconnect(self):
|
|
pass
|
|
|
|
async def send(self, chat_id, content, reply_to=None, metadata=None):
|
|
return SendResult(success=True, message_id="m1")
|
|
|
|
async def get_chat_info(self, chat_id):
|
|
return {"id": chat_id, "type": "im"}
|
|
|
|
|
|
class _FellThroughIntercept(Exception):
|
|
"""Sentinel: _handle_message got PAST the clarify text-intercept."""
|
|
|
|
|
|
def _event(text):
|
|
return MessageEvent(
|
|
text=text,
|
|
message_type=MessageType.TEXT,
|
|
source=SessionSource(
|
|
platform=Platform.SLACK,
|
|
chat_id="D123",
|
|
chat_type="dm",
|
|
user_id="U1",
|
|
thread_id="1111.2222",
|
|
),
|
|
message_id="msg1",
|
|
)
|
|
|
|
|
|
def _clear_clarify_state():
|
|
from tools import clarify_gateway as cm
|
|
|
|
with cm._lock:
|
|
cm._entries.clear()
|
|
cm._session_index.clear()
|
|
cm._notify_cbs.clear()
|
|
|
|
|
|
def _make_runner(adapter):
|
|
from gateway.run import GatewayRunner
|
|
|
|
runner = GatewayRunner.__new__(GatewayRunner)
|
|
runner._startup_restore_in_progress = False
|
|
runner._scale_to_zero_note_real_inbound = lambda: None
|
|
runner._is_user_authorized = lambda source: True
|
|
runner._session_key_for_source = lambda source: SESSION_KEY
|
|
runner._adapter_for_source = lambda source: adapter
|
|
runner._update_prompt_pending = {}
|
|
return runner
|
|
|
|
|
|
async def _dispatch(runner, event):
|
|
"""Run _handle_message with a tripwire installed AFTER the clarify
|
|
intercept (the slash-confirm pending lookup is the next statement), so a
|
|
raised ``_FellThroughIntercept`` proves the message was NOT swallowed."""
|
|
import tools.slash_confirm as slash_confirm_mod
|
|
|
|
def _tripwire(_key):
|
|
raise _FellThroughIntercept()
|
|
|
|
with patch("hermes_cli.plugins.invoke_hook", return_value=[]), \
|
|
patch.object(slash_confirm_mod, "get_pending", _tripwire):
|
|
return await runner._handle_message(event)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_thread_prose_not_swallowed_by_native_multi_choice_clarify():
|
|
"""Arbitrary prose during a pending button-clarify continues as a normal turn."""
|
|
_clear_clarify_state()
|
|
from tools import clarify_gateway as cm
|
|
|
|
adapter = _StubAdapter()
|
|
runner = _make_runner(adapter)
|
|
# Native interactive multi-choice prompt: awaiting_text stays False.
|
|
entry = cm.register("cl-native", SESSION_KEY, "Pick a UI variant", ["buttons", "dropdown"])
|
|
assert entry.awaiting_text is False
|
|
|
|
with pytest.raises(_FellThroughIntercept):
|
|
await _dispatch(runner, _event("just checking the visual UI, no need to pass any data"))
|
|
|
|
# The clarify entry must still be pending and unresolved.
|
|
with cm._lock:
|
|
entry = cm._entries.get("cl-native")
|
|
assert entry is not None
|
|
assert not entry.event.is_set()
|
|
_clear_clarify_state()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prose_still_accepted_after_other_flips_text_capture():
|
|
"""After the user taps 'Other', free text IS the answer — must resolve."""
|
|
_clear_clarify_state()
|
|
from tools import clarify_gateway as cm
|
|
|
|
adapter = _StubAdapter()
|
|
runner = _make_runner(adapter)
|
|
cm.register("cl-other", SESSION_KEY, "Pick a UI variant", ["buttons", "dropdown"])
|
|
assert cm.mark_awaiting_text("cl-other") is True
|
|
|
|
result = await _dispatch(runner, _event("a carousel actually"))
|
|
|
|
assert result == ""
|
|
with cm._lock:
|
|
entry = cm._entries.get("cl-other")
|
|
assert entry is not None
|
|
assert entry.event.is_set()
|
|
assert entry.response == "a carousel actually"
|
|
_clear_clarify_state()
|
|
|
|
|