Files
hermes-agent/tests/agent/test_wait_state_visibility.py
T
teknium1 d10bb2ab6f test: make tests/ mirror the source tree; drop issue numbers from filenames
`scripts/run_tests.sh tests/<dir>/` is how a change gets its regression
coverage run, so a test filed under the wrong directory is a test nobody
runs when that code changes. Two kinds of drift had accumulated.

Parallel directories for one source package, folded into the mirror:
  tests/acp        -> tests/acp_adapter   (its __init__/conftest move with it)
  tests/cli        -> tests/hermes_cli    (prompt_toolkit fixture merged into
                                           hermes_cli/conftest.py)
  tests/run_agent  -> tests/agent         (backoff fixture becomes
                                           agent/conftest.py)
  tests/relay      -> tests/gateway/relay
  tests/state      -> tests/hermes_state

246 loose files at tests/ root, routed by the package they import/patch:
hermes_cli, hermes_state, agent, gateway, tools, plugins, tui_gateway, cron.
Installer and desktop-update script tests go to tests/scripts/{install,
desktop_update}/. 43 tests of root-level modules (batch_runner, utils,
hermes_constants, packaging) stay at the root.

Filenames drop their issue numbers (95 files: test_89315_x.py -> test_x.py);
the number stays in the module docstring where it has context.

Collisions: test_cli_skin_integration.py existed in both tests/ and tests/cli
with different subsets — merged into one (10 tests, all kept);
run_agent/test_pre_compress_memory_context.py -> agent/..._handoff.py;
tests/test_account_usage.py -> agent/test_account_usage_fetch.py;
tests/test_web_server.py -> hermes_cli/test_web_server_ws_ping.py.
Deleted: test_minisweagent_path.py (empty since PR #2804),
test_model_picker_scroll.py (tested a private copy of the logic, imported
nothing), test_process_loop_event_loop_warning.py (asserted asyncio behaviour,
imported nothing from Hermes).

Repo-root path arithmetic (Path(__file__).parents[N], dirname chains) is
bumped for the 202 files that changed depth and verified by evaluating every
such expression against the new location. classify_changes' desktop-updater
lane prefix, tests-os.yml's ignore glob and every in-tree path comment follow
the moves. tests/test_tests_tree_layout.py keeps the tree from drifting back.
2026-09-13 09:18:02 -07:00

114 lines
4.7 KiB
Python

"""Tests for wait-state visibility — the live "what are we waiting on" notices.
Long provider waits (slow/overloaded backend, no first byte, reasoning model
thinking for minutes) used to leave CLI/TUI/Desktop users staring at a generic
"cogitating..." spinner with no explanation. ``AIAgent._emit_wait_notice``
rewrites the live spinner/status line (via ``thinking_callback``, bridged to
``thinking.delta`` for TUI/Desktop) and updates the activity tracker (which the
gateway's "⏳ Working — N min" heartbeat includes).
"""
from __future__ import annotations
import sys
import time
import types
from types import SimpleNamespace
import pytest
# Stub optional heavy imports so run_agent imports cleanly in isolation.
sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None))
sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object))
sys.modules.setdefault("fal_client", types.SimpleNamespace())
def _make_agent(tmp_path, monkeypatch, **kwargs):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / ".env").write_text("", encoding="utf-8")
(tmp_path / "config.yaml").write_text("{}\n", encoding="utf-8")
from run_agent import AIAgent
return AIAgent(
model="test-model",
api_key="sk-dummy",
base_url="https://openrouter.ai/api/v1",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
platform="cli",
**kwargs,
)
def test_emit_wait_notice_updates_spinner_and_activity(tmp_path, monkeypatch):
"""The notice reaches the live display callback AND the activity tracker."""
seen: list = []
agent = _make_agent(tmp_path, monkeypatch, thinking_callback=seen.append)
agent._emit_wait_notice("⏳ waiting on test-model — 30s with no response yet")
assert seen == ["⏳ waiting on test-model — 30s with no response yet"]
summary = agent.get_activity_summary()
assert "waiting on test-model" in summary["last_activity_desc"]
def test_emit_wait_notice_without_callback_still_touches_activity(tmp_path, monkeypatch):
"""No thinking_callback bound (gateway sessions) — activity still updates."""
agent = _make_agent(tmp_path, monkeypatch)
agent.thinking_callback = None
agent._emit_wait_notice("⏳ waiting on test-model — 60s")
assert "waiting on test-model" in agent.get_activity_summary()["last_activity_desc"]
def test_nonstream_wait_loop_emits_explained_notice(tmp_path, monkeypatch):
"""After ~30s with no response, interruptible_api_call rewrites the live
line with an explanation (model name, elapsed, overload hint, recovery
deadline) instead of a bare 'waiting for non-streaming response'."""
from agent import chat_completion_helpers as h
seen: list = []
agent = _make_agent(tmp_path, monkeypatch, thinking_callback=seen.append)
agent.api_mode = "codex_responses"
monkeypatch.setattr(agent, "_compute_non_stream_stale_timeout", lambda *a, **k: 60.0)
# Compress the 30s cadence: the loop fires the notice every 100 polls of
# 0.3s; patch the join timeout down via a tiny thread that stays alive
# briefly, and shrink the poll interval by patching time. Simplest
# reliable approach: run a worker that hangs ~1.2s and patch the modulo
# counter trigger by making the loop's join timeout effectively immediate.
dummy_client = SimpleNamespace()
monkeypatch.setattr(agent, "_create_request_openai_client", lambda **k: dummy_client)
monkeypatch.setattr(agent, "_abort_request_openai_client", lambda c, reason=None: None)
monkeypatch.setattr(agent, "_close_request_openai_client", lambda c, reason=None: None)
stop = {"flag": False}
def fake_hang(api_kwargs, client=None, on_first_delta=None):
deadline = time.time() + 10
while time.time() < deadline and not stop["flag"] and not agent._interrupt_requested:
time.sleep(0.02)
raise RuntimeError("connection closed")
monkeypatch.setattr(agent, "_run_codex_stream", fake_hang)
# TTFB kill at 1s ends the call quickly; the wait notice fires on the
# 100-poll cadence, so to observe it within the 1s window we shrink the
# cadence by patching threading.Thread.join used in the poll loop is
# overkill — instead just verify the TTFB reconnect notice, which flows
# through the same _emit_wait_notice path.
monkeypatch.setenv("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", "1")
try:
with pytest.raises(TimeoutError):
h.interruptible_api_call(agent, {"model": "gpt-5.5", "input": "hi"})
finally:
stop["flag"] = True
reconnect_notices = [s for s in seen if "reconnecting" in s]
assert reconnect_notices, f"expected a reconnect wait-notice, saw: {seen}"
assert "no response from provider" in reconnect_notices[0]