mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-06 02:41:48 +00:00
/goal was silently broken outside the classic CLI.
TUI: /goal was routed through the HermesCLI slash-worker subprocess,
which set the goal row in SessionDB but then called
_pending_input.put(state.goal) — the subprocess has no reader for that
queue, so the kickoff message was discarded. No post-turn judge was
wired into prompt.submit either, so even a manual kickoff would not
continue the goal loop. Intercept /goal in command.dispatch instead,
drive GoalManager directly, and return {type: send, notice, message}
so the TUI client renders the Goal-set notice and fires the kickoff.
Run the judge in _run_prompt_submit after message.complete, surface
the verdict via status.update {kind: goal}, and chain the continuation
turn after the running guard is released.
Gateway: _post_turn_goal_continuation was gated on
hasattr(adapter, 'send_message'), but adapters only expose send().
That branch was dead on every platform — users never saw
'✓ Goal achieved', 'Continuing toward goal', or budget-exhausted
messages. Replace the dead call with adapter.send(chat_id, content,
metadata) and drop a broken reference to self._loop.
Tests:
- tests/tui_gateway/test_goal_command.py — full /goal dispatch matrix
(set / status / pause / resume / clear / stop / done / whitespace)
plus regressions for slash.exec → 4018 and 'goal' staying in
_PENDING_INPUT_COMMANDS.
- tests/gateway/test_goal_verdict_send.py — locks in the adapter.send
path for done / continue / budget-exhausted and verifies the hook
no-ops when no goal is set or the adapter lacks send().
196 lines
6.8 KiB
Python
196 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
|