hermes-agent/tests/tui_gateway/test_goal_command.py
Jeff Watts 0860b9804f test(tui_gateway): pin goal-command config home against collection-time _hermes_home freeze
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.
2026-07-29 21:30:53 -07:00

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