"""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): with patch.dict( "sys.modules", { "hermes_cli.env_loader": MagicMock(), "hermes_cli.banner": MagicMock(), }, ): mod = importlib.import_module("tui_gateway.server") 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; _methods is NOT cleared because it's # populated at module import time and re-registration only happens # via reload (which we don't do). 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