mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
tui_gateway.server registers two atexit hooks at module load time:
ThreadPoolExecutor shutdown (line 170) and _shutdown_sessions (line 336).
Three test files reloaded the module on each fixture teardown to reset
per-test state. Each reload re-runs module-level code, including the
atexit registrations — duplicates accumulate across the test session.
At pytest interpreter shutdown the duplicated atexit hooks race the
stderr buffer flush:
Fatal Python error: _enter_buffered_busy: could not acquire lock
for <_io.BufferedWriter name='<stderr>'> at interpreter shutdown,
possibly due to daemon threads
pytest reports 'tests passed but the slice exited non-zero', and the
shard turns red on CI. Surfaced today on PR #34193's test slice 1
(204 files, 3572 tests passed, then Fatal Python error during exit).
Fix: drop importlib.reload(mod) from the three fixtures that have it.
Per-test reset is handled by clearing the mutable session dicts
(_sessions, _pending, _answers). _methods is also no longer cleared —
it's populated at module import time and would only be re-populated by
a reload, so clearing it without reload broke session.resume /
command.dispatch / slash.exec method registration across tests.
Affected fixtures:
- tests/tui_gateway/test_goal_command.py
- tests/tui_gateway/test_protocol.py
- tests/tui_gateway/test_review_summary_callback.py
The second reload in test_protocol.py at line 211 (reload of
tui_gateway.transport) is preserved — transport.py has no atexit hooks
or threads, so reload is safe there.
Tests: 84/84 in tests/tui_gateway/ pass cleanly with exit code 0; no
Fatal Python error at interpreter shutdown.
202 lines
7.3 KiB
Python
202 lines
7.3 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):
|
|
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"]
|
|
|
|
|
|
def test_goal_whitespace_only_shows_status(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"]
|
|
|
|
|
|
def test_goal_status_alias_shows_status(server, session):
|
|
sid, _, _ = session
|
|
r = _call(server, "command.dispatch", name="goal", arg="status", session_id=sid)
|
|
assert r["result"]["type"] == "exec"
|
|
assert "No active goal" in r["result"]["output"]
|
|
|
|
|
|
def test_goal_set_returns_send_with_notice(server, session):
|
|
sid, session_key, _ = session
|
|
r = _call(server, "command.dispatch", name="goal", arg="build a rocket", session_id=sid)
|
|
result = r["result"]
|
|
assert result["type"] == "send"
|
|
assert result["message"] == "build a rocket"
|
|
assert "notice" in result
|
|
assert "Goal set" in result["notice"]
|
|
assert "20-turn budget" in result["notice"]
|
|
|
|
# Persisted in SessionDB
|
|
from hermes_cli.goals import GoalManager
|
|
|
|
mgr = GoalManager(session_key)
|
|
assert mgr.state is not None
|
|
assert mgr.state.goal == "build a rocket"
|
|
assert mgr.state.status == "active"
|
|
|
|
|
|
def test_goal_pause_after_set(server, session):
|
|
sid, session_key, _ = session
|
|
_call(server, "command.dispatch", name="goal", arg="write a story", session_id=sid)
|
|
r = _call(server, "command.dispatch", name="goal", arg="pause", session_id=sid)
|
|
assert r["result"]["type"] == "exec"
|
|
assert "paused" in r["result"]["output"].lower()
|
|
|
|
from hermes_cli.goals import GoalManager
|
|
|
|
assert GoalManager(session_key).state.status == "paused"
|
|
|
|
|
|
def test_goal_resume_reactivates(server, session):
|
|
sid, session_key, _ = session
|
|
_call(server, "command.dispatch", name="goal", arg="write a story", session_id=sid)
|
|
_call(server, "command.dispatch", name="goal", arg="pause", session_id=sid)
|
|
r = _call(server, "command.dispatch", name="goal", arg="resume", session_id=sid)
|
|
assert r["result"]["type"] == "exec"
|
|
assert "resumed" in r["result"]["output"].lower()
|
|
|
|
from hermes_cli.goals import GoalManager
|
|
|
|
assert GoalManager(session_key).state.status == "active"
|
|
|
|
|
|
def test_goal_clear_removes_active_goal(server, session):
|
|
sid, session_key, _ = session
|
|
_call(server, "command.dispatch", name="goal", arg="write a story", session_id=sid)
|
|
r = _call(server, "command.dispatch", name="goal", arg="clear", session_id=sid)
|
|
assert r["result"]["type"] == "exec"
|
|
assert "cleared" in r["result"]["output"].lower()
|
|
|
|
from hermes_cli.goals import GoalManager
|
|
|
|
# After clear the row is marked status=cleared (kept for audit);
|
|
# ``has_goal()`` / ``is_active()`` return False so the goal loop
|
|
# stays off and ``status`` reports "No active goal".
|
|
mgr = GoalManager(session_key)
|
|
assert not mgr.has_goal()
|
|
assert not mgr.is_active()
|
|
assert "No active goal" in mgr.status_line()
|
|
|
|
|
|
def test_goal_stop_and_done_are_clear_aliases(server, session):
|
|
sid, _, _ = session
|
|
_call(server, "command.dispatch", name="goal", arg="first goal", session_id=sid)
|
|
r = _call(server, "command.dispatch", name="goal", arg="stop", session_id=sid)
|
|
assert "cleared" in r["result"]["output"].lower()
|
|
|
|
_call(server, "command.dispatch", name="goal", arg="second goal", session_id=sid)
|
|
r = _call(server, "command.dispatch", name="goal", arg="done", session_id=sid)
|
|
assert "cleared" in r["result"]["output"].lower()
|
|
|
|
|
|
def test_goal_requires_session(server):
|
|
r = _call(server, "command.dispatch", name="goal", arg="nope", session_id="unknown")
|
|
assert "error" in r
|
|
assert r["error"]["code"] == 4001
|
|
|
|
|
|
# ── slash.exec /goal routing ──────────────────────────────────────────
|
|
|
|
|
|
def test_slash_exec_rejects_goal_routes_to_command_dispatch(server, session):
|
|
"""slash.exec must reject /goal with 4018 so the TUI client falls through
|
|
to command.dispatch. Without this, the HermesCLI slash-worker subprocess
|
|
would set the goal but silently drop the kickoff — the queue is in-proc."""
|
|
sid, _, _ = session
|
|
r = _call(server, "slash.exec", command="goal status", session_id=sid)
|
|
assert "error" in r
|
|
assert r["error"]["code"] == 4018
|
|
assert "command.dispatch" in r["error"]["message"]
|
|
|
|
|
|
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
|