Files
hermes-agent/tests/tui_gateway/test_loop_command.py
T
teknium1 f79440e0f4 feat: /loop — recurring in-session wakeups (Claude Code parity)
Ports Claude Code's /loop (and its /proactive alias) across every Hermes
surface. /loop [interval] <prompt> re-runs a prompt or slash command on a
recurring cadence inside the live session; omitting the interval enables
self-paced mode (starts at the floor, backs off exponentially while the
agent's replies stop changing, snaps back on change — local digest
comparison, zero extra LLM cost).

Stop conditions: agent-emitted LOOP_COMPLETE marker, --times N,
--until <condition> (judged by the existing goal_judge aux task,
fail-open), /loop stop, and a loops.max_ticks backstop budget.

Core: hermes_cli/loops.py (LoopState + LoopManager + shared
dispatch_loop_command), persisted per session in SessionDB state_meta
(loop:<sid>) so /resume picks it up; migrates across compression
boundaries like /goal. New SessionDB.list_meta_prefix() powers the
gateway's cross-session scan.

Surfaces:
- CLI: /loop handler + idle-fire and post-turn-complete hooks in
  process_loop (mirrors the /goal hook shape; Ctrl+C pauses the loop)
- Gateway: /loop handler with route capture, mid-run control-verb guard,
  post-turn tick completion, and a supervised loop_wakeup_watcher that
  injects due wakeups into idle chats via the synthetic-message path
- TUI/dashboard/desktop: command.dispatch handler + per-session
  notification-poller wakeup driver + post-turn completion in the turn
  dispatcher; /loop added to the desktop slash palette
- /goal mixing: an active non-parked goal owns the idle boundary — loop
  ticks defer until it finishes, pauses, or parks; real user input always
  wins over both

Config: loops.{min_interval_seconds,max_ticks,self_paced_floor_seconds,
self_paced_ceiling_seconds}. Docs page + sidebar entry. 77 new tests.
Slack's 50-slash cap: /version moves to /hermes version to free the
native slot for /loop.
2026-08-14 13:40:19 -07:00

206 lines
6.3 KiB
Python

"""Tests for /loop handling in tui_gateway.
The TUI routes ``/loop`` through ``command.dispatch`` (same rationale as
``/goal`` — the CLI handler drives ``_pending_input``, which the slash
worker has no reader for). State mutations go through the shared
``dispatch_loop_command``; the per-session notification poller fires due
wakeups via ``_maybe_fire_tui_loop_tick``.
"""
from __future__ import annotations
import importlib
import threading
import time
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))
from hermes_cli import loops
loops._DB_CACHE.clear()
yield home
loops._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()
@pytest.fixture()
def session(server):
sid = "sid-loop-test"
session_key = "tui-loop-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 /loop ────────────────────────────────────────────
def test_loop_bare_shows_status_when_none_set(server, session):
sid, _, _ = session
r = _call(server, "command.dispatch", name="loop", arg="", session_id=sid)
assert r["result"]["type"] == "exec"
assert "No loop set" in r["result"]["output"]
def test_loop_set_persists(server, session):
sid, session_key, _ = session
r = _call(server, "command.dispatch", name="loop", arg="5m check the deploy", session_id=sid)
result = r["result"]
assert result["type"] == "exec"
assert "Loop set" in result["output"]
from hermes_cli.loops import LoopManager
mgr = LoopManager(session_key)
assert mgr.state is not None
assert mgr.state.prompt == "check the deploy"
assert mgr.state.status == "active"
assert mgr.state.interval_seconds == 300.0
def test_loop_proactive_alias_resolves(server, session):
sid, _, _ = session
r = _call(server, "command.dispatch", name="proactive", arg="5m ping", session_id=sid)
assert "Loop set" in r["result"]["output"]
def test_loop_pause_resume_stop(server, session):
sid, session_key, _ = session
_call(server, "command.dispatch", name="loop", arg="5m poll CI", session_id=sid)
r = _call(server, "command.dispatch", name="loop", arg="pause", session_id=sid)
assert "paused" in r["result"]["output"].lower()
r = _call(server, "command.dispatch", name="loop", arg="resume", session_id=sid)
assert "resumed" in r["result"]["output"].lower()
r = _call(server, "command.dispatch", name="loop", arg="stop", session_id=sid)
assert "stopped" in r["result"]["output"].lower()
from hermes_cli.loops import LoopManager
assert not LoopManager(session_key).has_loop()
def test_loop_requires_session(server):
r = _call(server, "command.dispatch", name="loop", arg="5m x", session_id="unknown")
assert "error" in r
assert r["error"]["code"] == 4001
# ── idle wakeup driver ────────────────────────────────────────────────
def test_tui_tick_fires_when_idle_and_due(server, session):
sid, session_key, s = session
from hermes_cli.loops import LoopManager, save_loop
mgr = LoopManager(session_key)
mgr.set("poll the build", interval_seconds=60)
mgr.state.next_due_at = time.time() - 1
save_loop(session_key, mgr.state)
fired = {}
def fake_submit(rid, sid_, session_, text, **kwargs):
fired["text"] = text
with patch.object(server, "_run_prompt_submit", fake_submit), \
patch.object(server, "_emit"):
server._maybe_fire_tui_loop_tick(sid, s)
assert "poll the build" in fired.get("text", "")
assert "[/loop wakeup #1" in fired["text"]
# Session claimed for the wakeup turn.
assert s["running"] is True
def test_tui_tick_defers_when_running(server, session):
sid, session_key, s = session
from hermes_cli.loops import LoopManager, save_loop
mgr = LoopManager(session_key)
mgr.set("poll", interval_seconds=60)
mgr.state.next_due_at = time.time() - 1
save_loop(session_key, mgr.state)
s["running"] = True
with patch.object(server, "_run_prompt_submit") as submit, \
patch.object(server, "_emit"):
server._maybe_fire_tui_loop_tick(sid, s)
submit.assert_not_called()
# Tick not consumed — still due for the next poll.
assert LoopManager(session_key).state.ticks_fired == 0
def test_tui_tick_defers_to_active_goal(server, session):
sid, session_key, s = session
from hermes_cli.goals import GoalManager
from hermes_cli.loops import LoopManager, save_loop
GoalManager(session_id=session_key).set("finish the feature")
mgr = LoopManager(session_key)
mgr.set("poll", interval_seconds=60)
mgr.state.next_due_at = time.time() - 1
save_loop(session_key, mgr.state)
with patch.object(server, "_run_prompt_submit") as submit, \
patch.object(server, "_emit"):
server._maybe_fire_tui_loop_tick(sid, s)
submit.assert_not_called()
assert s["running"] is False
def test_tui_tick_noop_when_not_due(server, session):
sid, session_key, s = session
from hermes_cli.loops import LoopManager
LoopManager(session_key).set("poll", interval_seconds=300)
with patch.object(server, "_run_prompt_submit") as submit, \
patch.object(server, "_emit"):
server._maybe_fire_tui_loop_tick(sid, s)
submit.assert_not_called()
assert s["running"] is False