mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Sibling files (test_billing_rpc etc.) import tui_gateway.server at collection time, freezing module-level _hermes_home = get_hermes_home() (server.py:54) to the developer's real home before conftest isolation runs. _load_cfg() then reads the REAL ~/.hermes/config.yaml — e.g. a local MoA preset — instead of what _write_moa_config wrote. The server fixture now monkeypatches _hermes_home to the isolated HERMES_HOME and resets the mtime-keyed cfg cache (_cfg_cache/_cfg_mtime/ _cfg_path); monkeypatch restores originals on teardown. Complementary to the #57066 autouse teardown, which only restores the cfg cache to its pre-test value and never re-points _hermes_home. Combined tests/tui_gateway + tests/test_tui_gateway_server.py: 792 passed. Salvaged from PR #63981 by @lEWFkRAD.
156 lines
5.5 KiB
Python
156 lines
5.5 KiB
Python
"""Tests for /goal handling in tui_gateway.
|
|
|
|
The TUI routes ``/goal`` through ``command.dispatch`` (not ``slash.exec``)
|
|
because the CLI's ``_handle_goal_command`` queues the kickoff message onto
|
|
``_pending_input``, which the slash-worker subprocess has no reader for.
|
|
Instead we handle ``/goal`` directly in the server and return a
|
|
``{"type": "send", "notice": ..., "message": ...}`` payload the TUI client
|
|
uses to render a system line and fire the kickoff prompt.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import threading
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture()
|
|
def hermes_home(tmp_path, monkeypatch):
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
|
|
# Bust the goal-module DB cache so it re-resolves HERMES_HOME.
|
|
from hermes_cli import goals
|
|
|
|
goals._DB_CACHE.clear()
|
|
yield home
|
|
goals._DB_CACHE.clear()
|
|
|
|
|
|
@pytest.fixture()
|
|
def server(hermes_home, monkeypatch):
|
|
# Mocks are scoped to the initial import only (see
|
|
# tests/tui_gateway/test_protocol.py for the rationale).
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"hermes_cli.env_loader": MagicMock(),
|
|
"hermes_cli.banner": MagicMock(),
|
|
},
|
|
):
|
|
mod = importlib.import_module("tui_gateway.server")
|
|
|
|
# Pin config resolution to the isolated HERMES_HOME. Sibling test
|
|
# files (test_billing_rpc, test_delegation_session_lifecycle,
|
|
# test_gateway_owned_session_reap, ...) import tui_gateway.server at
|
|
# collection time — BEFORE the conftest env isolation runs — so the
|
|
# module-level ``_hermes_home = get_hermes_home()`` snapshot freezes
|
|
# the developer's real home. When any of them precede this file in
|
|
# the same process, ``importlib.import_module`` returns that cached
|
|
# module and ``_load_cfg()`` would read the REAL config.yaml (e.g. a
|
|
# local MoA preset) instead of the one ``_write_moa_config`` writes.
|
|
# Also reset the mtime-keyed config cache; monkeypatch restores the
|
|
# originals on teardown so nothing leaks to later tests either.
|
|
monkeypatch.setattr(mod, "_hermes_home", hermes_home)
|
|
monkeypatch.setattr(mod, "_cfg_cache", None)
|
|
monkeypatch.setattr(mod, "_cfg_mtime", None)
|
|
monkeypatch.setattr(mod, "_cfg_path", None)
|
|
yield mod
|
|
# Reset module-level session state without re-importing. importlib.reload
|
|
# would re-register the module's atexit hooks (ThreadPoolExecutor
|
|
# shutdown, _shutdown_sessions); the duplicates race the stderr
|
|
# buffer at interpreter shutdown and surface as Fatal Python error:
|
|
# _enter_buffered_busy. Clearing the per-session dicts gives the
|
|
# next test a clean slate.
|
|
mod._sessions.clear()
|
|
mod._pending.clear()
|
|
mod._answers.clear()
|
|
|
|
|
|
@pytest.fixture()
|
|
def session(server):
|
|
sid = "sid-test"
|
|
session_key = "tui-goal-session-1"
|
|
s = {
|
|
"session_key": session_key,
|
|
"history": [],
|
|
"history_lock": threading.Lock(),
|
|
"history_version": 0,
|
|
"running": False,
|
|
"attached_images": [],
|
|
"cols": 120,
|
|
}
|
|
server._sessions[sid] = s
|
|
return sid, session_key, s
|
|
|
|
|
|
def _call(server, method, **params):
|
|
handler = server._methods[method]
|
|
return handler(1, params)
|
|
|
|
|
|
# ── command.dispatch /goal ────────────────────────────────────────────
|
|
|
|
|
|
def test_goal_bare_shows_status_when_none_set(server, session):
|
|
sid, _, _ = session
|
|
r = _call(server, "command.dispatch", name="goal", arg="", session_id=sid)
|
|
assert r["result"]["type"] == "exec"
|
|
assert "No active goal" in r["result"]["output"]
|
|
|
|
|
|
# ── slash.exec /goal routing ──────────────────────────────────────────
|
|
|
|
|
|
def test_slash_exec_routes_goal_to_command_dispatch(server, session):
|
|
"""slash.exec must route /goal directly to command.dispatch internally
|
|
instead of returning an error. Previously the 4018 error required the
|
|
TUI client to retry via command.dispatch, but some clients failed the
|
|
fallback, leaving the command empty ("empty command")."""
|
|
sid, _, _ = session
|
|
r = _call(server, "slash.exec", command="goal status", session_id=sid)
|
|
# Should succeed by routing to command.dispatch internally
|
|
assert "result" in r
|
|
assert r["result"]["type"] == "exec"
|
|
assert "No active goal" in r["result"]["output"]
|
|
|
|
|
|
def test_pending_input_commands_includes_goal(server):
|
|
"""Guard: _PENDING_INPUT_COMMANDS must list 'goal' — removing it would
|
|
silently re-break the TUI."""
|
|
assert "goal" in server._PENDING_INPUT_COMMANDS
|
|
|
|
|
|
# ── command.dispatch /moa ────────────────────────────────────────────
|
|
|
|
def _write_moa_config(home, text):
|
|
cfg_path = home / "config.yaml"
|
|
cfg_path.write_text(text)
|
|
|
|
|
|
def test_moa_bare_returns_usage(server, session, hermes_home):
|
|
_write_moa_config(hermes_home, """
|
|
moa:
|
|
default_preset: default
|
|
presets:
|
|
default:
|
|
reference_models:
|
|
- provider: openai-codex
|
|
model: gpt-5.5
|
|
aggregator:
|
|
provider: openrouter
|
|
model: anthropic/claude-opus-4.8
|
|
""")
|
|
sid, _, s = session
|
|
r = _call(server, "command.dispatch", name="moa", arg="", session_id=sid)
|
|
# Bare /moa is usage-only now; switching to a preset is via the model picker.
|
|
assert "error" in r
|
|
assert "model_override" not in s
|
|
|
|
|