Files
hermes-agent/tests/gateway/test_loop_command.py
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

140 lines
4.3 KiB
Python

"""Gateway /loop command tests — dispatch, routing capture, mid-run guard."""
import time
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType
from gateway.run import GatewayRunner
from gateway.session import SessionSource
from hermes_cli import loops
class _FakeSessionEntry:
session_id = "sid-gateway-loop"
class _FakeSessionStore:
def __init__(self):
self.entry = _FakeSessionEntry()
def get_or_create_session(self, source):
return self.entry
def _generate_session_key(self, source):
return "agent:main:discord:channel:loop-test"
@pytest.fixture
def loop_env(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
loops._DB_CACHE.clear()
yield home
loops._DB_CACHE.clear()
def _make_runner():
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="token")}
)
runner.session_store = _FakeSessionStore()
runner.adapters = {}
runner._queued_events = {}
return runner
def _make_event(text: str) -> MessageEvent:
return MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=SessionSource(
platform=Platform.DISCORD,
chat_id="chat-loop",
chat_type="channel",
thread_id="thread-9",
user_id="user-loop",
),
message_id="msg-loop",
)
@pytest.mark.asyncio
async def test_gateway_loop_create_captures_route(loop_env):
runner = _make_runner()
response = await GatewayRunner._handle_loop_command(runner, _make_event("/loop 5m check the deploy"))
assert "Loop set" in response
assert "every 5m" in response
state = loops.load_loop("sid-gateway-loop")
assert state is not None
assert state.prompt == "check the deploy"
assert state.route["platform"] == "discord"
assert state.route["chat_id"] == "chat-loop"
assert state.route["thread_id"] == "thread-9"
@pytest.mark.asyncio
async def test_gateway_loop_status_pause_stop(loop_env):
runner = _make_runner()
await GatewayRunner._handle_loop_command(runner, _make_event("/loop 5m poll CI"))
status = await GatewayRunner._handle_loop_command(runner, _make_event("/loop status"))
assert "poll CI" in status
paused = await GatewayRunner._handle_loop_command(runner, _make_event("/loop pause"))
assert "paused" in paused.lower()
stopped = await GatewayRunner._handle_loop_command(runner, _make_event("/loop stop"))
assert "stopped" in stopped.lower()
@pytest.mark.asyncio
async def test_gateway_loop_goal_note_when_goal_active(loop_env):
from hermes_cli.goals import GoalManager
GoalManager(session_id="sid-gateway-loop").set("finish the migration")
runner = _make_runner()
response = await GatewayRunner._handle_loop_command(runner, _make_event("/loop 5m poll CI"))
assert "active /goal" in response
@pytest.mark.asyncio
async def test_post_turn_loop_completion_completes_inflight_tick(loop_env):
runner = _make_runner()
await GatewayRunner._handle_loop_command(runner, _make_event("/loop 5m poll CI"))
mgr = loops.LoopManager(session_id="sid-gateway-loop")
mgr.state.next_due_at = time.time() - 1
assert mgr.fire_tick() is not None
entry = _FakeSessionEntry()
await GatewayRunner._post_turn_loop_completion(
runner,
session_entry=entry,
source=None,
final_response="CI is done.\nLOOP_COMPLETE",
)
reloaded = loops.load_loop("sid-gateway-loop")
assert reloaded.status == "done"
@pytest.mark.asyncio
async def test_post_turn_loop_completion_noop_without_inflight_tick(loop_env):
runner = _make_runner()
await GatewayRunner._handle_loop_command(runner, _make_event("/loop 5m poll CI"))
entry = _FakeSessionEntry()
# No tick fired — the ordinary user turn must not consume loop state.
await GatewayRunner._post_turn_loop_completion(
runner,
session_entry=entry,
source=None,
final_response="regular reply LOOP_COMPLETE",
)
reloaded = loops.load_loop("sid-gateway-loop")
assert reloaded.status == "active"
assert reloaded.ticks_fired == 0