Files
aiturk-hermes-ide/tests/gateway/test_goal_resume_restart.py

207 lines
7.2 KiB
Python

"""#75362 — /goal resume must restart work, not just flip persisted state.
After a goal auto-pauses on turn-budget exhaustion, each surface's
``resume`` handler must schedule exactly one canonical
``GoalManager.next_continuation_prompt()`` turn through that surface's
existing input path:
- classic CLI → ``self._pending_input``
- messaging gateway → the adapter FIFO (``_enqueue_fifo``)
The TUI/Desktop ``command.dispatch`` boundary is covered in
``tests/tui_gateway/test_goal_command.py``.
"""
from __future__ import annotations
import queue
import uuid
from pathlib import Path
from unittest.mock import MagicMock, patch
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 goals
@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))
# get_hermes_home() prefers the context-local override over the env
# var, so a set_hermes_home_override() leaked by ANY earlier test in
# this xdist worker would silently point the goals DB at a dead tmp
# dir and make resume enqueue nothing (CI-only flake). Pin the
# override to THIS home so the fixture is immune to leaks.
from hermes_constants import (
reset_hermes_home_override,
set_hermes_home_override,
)
token = set_hermes_home_override(str(home))
goals._DB_CACHE.clear()
# Pre-warm the SessionDB cache from sync context so async GoalManager
# writes never race the bounded loop-thread bootstrap window on loaded
# CI runners (goal silently unpersisted; main run 33455779041).
goals._get_session_db()
yield home
try:
reset_hermes_home_override(token)
except Exception:
pass
goals._DB_CACHE.clear()
def _exhaust_budget(session_id: str, goal_text: str = "ship the benchmark"):
"""Set a 1-turn goal and drive it to budget-exhaustion auto-pause."""
mgr = goals.GoalManager(session_id)
mgr.set(goal_text, max_turns=1)
with patch(
"hermes_cli.goals.judge_goal",
return_value=("continue", "needs more steps", False, None, False),
):
decision = mgr.evaluate_after_turn("worked a bit")
assert decision["status"] == "paused"
assert decision["should_continue"] is False
assert "turn budget exhausted" in (mgr.state.paused_reason or "")
return mgr
# ──────────────────────────────────────────────────────────────────────
# Classic CLI
# ──────────────────────────────────────────────────────────────────────
def _make_cli(session_id: str):
from cli import HermesCLI
cli = HermesCLI.__new__(HermesCLI)
cli._pending_input = queue.Queue()
cli.session_id = session_id
cli.agent = MagicMock()
cli.agent.session_id = session_id
return cli
class TestCliResumeRestartsWork:
def test_resume_after_budget_exhaustion_queues_continuation(self, hermes_home):
sid = f"sid-cli-resume-{uuid.uuid4().hex}"
cli = _make_cli(sid)
_exhaust_budget(sid)
cli._handle_goal_command("goal resume")
assert not cli._pending_input.empty(), (
"/goal resume must queue the continuation prompt on _pending_input "
"— otherwise the goal sits idle until the user types something"
)
queued = cli._pending_input.get_nowait()
assert queued.startswith("[Continuing toward your standing goal]")
state = goals.GoalManager(sid).state
assert state.status == "active"
assert state.turns_used == 0
def test_resume_without_goal_queues_nothing(self, hermes_home):
sid = f"sid-cli-noresume-{uuid.uuid4().hex}"
cli = _make_cli(sid)
cli._handle_goal_command("goal resume")
assert cli._pending_input.empty()
# ──────────────────────────────────────────────────────────────────────
# Messaging gateway
# ──────────────────────────────────────────────────────────────────────
_GW_SID = "sid-gateway-goal-resume"
_GW_KEY = "agent:main:discord:channel:goal-resume"
class _FakeSessionEntry:
session_id = _GW_SID
class _FakeSessionStore:
def __init__(self):
self.entry = _FakeSessionEntry()
def get_or_create_session(self, source, **_kwargs):
return self.entry
def _generate_session_key(self, source):
return _GW_KEY
class _FakeAdapter:
def __init__(self):
self._pending_messages = {}
def _make_runner() -> tuple[GatewayRunner, _FakeAdapter]:
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="token")}
)
runner.session_store = _FakeSessionStore()
adapter = _FakeAdapter()
runner.adapters = {Platform.DISCORD: adapter}
runner._queued_events = {}
return runner, adapter
def _resume_event() -> MessageEvent:
return MessageEvent(
text="/goal resume",
message_type=MessageType.TEXT,
source=SessionSource(
platform=Platform.DISCORD,
chat_id="chat-goal-resume",
chat_type="channel",
user_id="user-goal-resume",
),
message_id="msg-goal-resume",
)
class TestGatewayResumeRestartsWork:
@pytest.mark.asyncio
async def test_resume_after_budget_exhaustion_enqueues_continuation(
self, hermes_home
):
runner, adapter = _make_runner()
_exhaust_budget(_GW_SID)
response = await GatewayRunner._handle_goal_command(runner, _resume_event())
assert "resume" in response.lower() or "Goal" in response
pending = adapter._pending_messages.get(_GW_KEY)
assert pending is not None, (
"/goal resume must enqueue the continuation on the adapter FIFO "
"— otherwise the goal sits idle until the next real user message"
)
assert pending.text.startswith("[Continuing toward your standing goal]")
# The pause/clear stale-work guard must recognize the queued turn as
# a synthetic goal continuation so it can be cleaned up on /goal pause.
assert GatewayRunner._is_goal_continuation_event(pending)
state = goals.GoalManager(_GW_SID).state
assert state.status == "active"
assert state.turns_used == 0
@pytest.mark.asyncio
async def test_resume_without_goal_enqueues_nothing(self, hermes_home):
runner, adapter = _make_runner()
response = await GatewayRunner._handle_goal_command(runner, _resume_event())
assert "No goal to resume" in response
assert adapter._pending_messages == {}