Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
"""#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 == {}
|
||||
Reference in New Issue
Block a user