207 lines
7.2 KiB
Python
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 == {}
|