"""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