mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-06 10:47:12 +08:00
197 lines
6.8 KiB
Python
197 lines
6.8 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
|
||
|
|
mod._sessions.clear()
|
||
|
|
mod._pending.clear()
|
||
|
|
mod._answers.clear()
|
||
|
|
mod._methods.clear()
|
||
|
|
importlib.reload(mod)
|
||
|
|
|
||
|
|
|
||
|
|
@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
|