690b903f85
* chore: add pytest-asyncio in auto mode * test: migrate channel and stream tests to native async Convert run_async() wrapper tests to plain 'async def test_*' under pytest-asyncio auto mode. collect_events() in stream_v3_fakes becomes a coroutine awaited at every call site. * test: migrate command and model/middleware tests to native async Convert run_async() wrappers (import, alias, and fixture forms) to plain 'async def test_*'. Multi-call tests merge onto one loop as sequential awaits; none asserted on loop identity. * test: migrate TUI, notifier, gateway, and session tests to native async TUI/notifier/gateway files convert run_async wrappers to plain async tests. test_sessions.py's unittest.TestCase classes move to unittest.IsolatedAsyncioTestCase (pytest-asyncio does not await async methods on plain TestCase; converting blindly would have made ~70 tests silently vacuous). Its setUpClass keeps a one-shot asyncio.run() since IsolatedAsyncioTestCase has no async class-level hook. TestLoadingWidget in test_tui_widgets.py drops its TestCase base for the same reason. * test: replace direct asyncio.run() calls with native async tests Convert tests that called asyncio.run() (directly or via a local _run helper) to plain 'async def test_*'; delete the local helpers. * test: drop undeclared anyio markers and delete run_async helper The @pytest.mark.anyio tests relied on anyio being a transitive dep of httpx; auto-mode pytest-asyncio collects them natively. run_async() and its fixture are unreferenced after the migration, so remove them — pytest-asyncio's per-test loop teardown covers the pending-task cancellation the helper existed for (verified: full suite runs with no 'Event loop is closed' errors or destroyed-task warnings).
338 lines
12 KiB
Python
338 lines
12 KiB
Python
"""Tests for ``cli/_agent_loader``."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import threading
|
|
|
|
import pytest
|
|
|
|
from EvoScientist.cli._agent_loader import BackgroundAgentLoader, MCPProgressTracker
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# MCPProgressTracker
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestMCPProgressTracker:
|
|
def test_prime_empty_when_no_config(self, monkeypatch):
|
|
import EvoScientist.mcp as mcp_pkg
|
|
|
|
monkeypatch.setattr(mcp_pkg, "load_mcp_config", lambda: {})
|
|
t = MCPProgressTracker()
|
|
t.prime()
|
|
assert t.progress == {}
|
|
|
|
def test_prime_seeds_pending_entries(self, monkeypatch):
|
|
import EvoScientist.mcp as mcp_pkg
|
|
|
|
monkeypatch.setattr(mcp_pkg, "load_mcp_config", lambda: {"a": {}, "b": {}})
|
|
t = MCPProgressTracker()
|
|
t.prime()
|
|
assert t.progress == {"a": ("pending", ""), "b": ("pending", "")}
|
|
|
|
def test_prime_swallows_config_errors(self, monkeypatch):
|
|
import EvoScientist.mcp as mcp_pkg
|
|
|
|
def _boom():
|
|
raise RuntimeError("config broken")
|
|
|
|
monkeypatch.setattr(mcp_pkg, "load_mcp_config", _boom)
|
|
t = MCPProgressTracker()
|
|
t.prime()
|
|
assert t.progress == {}
|
|
|
|
def test_record_maps_events(self):
|
|
t = MCPProgressTracker()
|
|
assert t.record("start", "srv", "") == "pending"
|
|
assert t.record("success", "srv", "5") == "ok"
|
|
assert t.record("error", "srv", "timeout") == "error"
|
|
assert t.record("bogus", "srv", "") is None
|
|
assert t.progress == {"srv": ("error", "timeout")}
|
|
|
|
def test_start_does_not_overwrite_existing_state(self):
|
|
t = MCPProgressTracker()
|
|
t.record("success", "srv", "3")
|
|
t.record("start", "srv", "")
|
|
assert t.progress["srv"] == ("ok", "3")
|
|
|
|
def test_snapshot_is_independent_copy(self):
|
|
t = MCPProgressTracker()
|
|
t.record("success", "srv", "1")
|
|
snap = t.snapshot()
|
|
t.record("error", "srv", "oops")
|
|
assert snap == [("ok", "1")]
|
|
|
|
def test_totals(self):
|
|
t = MCPProgressTracker()
|
|
t.record("start", "a", "")
|
|
t.record("success", "b", "1")
|
|
t.record("error", "c", "boom")
|
|
done, total = t.totals()
|
|
assert (done, total) == (2, 3)
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# BackgroundAgentLoader
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_loader_fn(agent_value="AGENT", fail_with=None, capture=None):
|
|
"""Build a sync loader that records ``on_mcp_progress`` + kwargs."""
|
|
|
|
def _loader(*, on_mcp_progress=None, **kwargs):
|
|
if capture is not None:
|
|
capture["on_mcp_progress"] = on_mcp_progress
|
|
capture.setdefault("kwargs", []).append(kwargs)
|
|
if on_mcp_progress is not None:
|
|
on_mcp_progress("start", "srv", "")
|
|
on_mcp_progress("success", "srv", "1")
|
|
if fail_with is not None:
|
|
raise fail_with
|
|
return agent_value
|
|
|
|
return _loader
|
|
|
|
|
|
class _GatedThreadLoader:
|
|
"""Callable loader that blocks until tests explicitly release it."""
|
|
|
|
def __init__(self, agent_value="AGENT", progress_events=()):
|
|
self.agent_value = agent_value
|
|
self.progress_events = tuple(progress_events)
|
|
self.started = threading.Event()
|
|
self.release = threading.Event()
|
|
self.finished = threading.Event()
|
|
|
|
def __call__(self, *, on_mcp_progress=None):
|
|
self.started.set()
|
|
self.release.wait(timeout=1)
|
|
try:
|
|
if on_mcp_progress is not None:
|
|
for event in self.progress_events:
|
|
on_mcp_progress(*event)
|
|
return self.agent_value
|
|
finally:
|
|
self.finished.set()
|
|
|
|
|
|
async def _wait_for_event(event, timeout=1):
|
|
return await asyncio.to_thread(event.wait, timeout)
|
|
|
|
|
|
class TestBackgroundAgentLoaderStart:
|
|
async def test_start_creates_task_and_forwards_kwargs(self):
|
|
captured: dict = {}
|
|
loader = BackgroundAgentLoader(_make_loader_fn(capture=captured))
|
|
|
|
loader.start(workspace_dir="/ws", checkpointer="CK")
|
|
assert loader.task is not None
|
|
assert loader.is_pending
|
|
await loader.await_ready()
|
|
|
|
assert captured["kwargs"][0] == {"workspace_dir": "/ws", "checkpointer": "CK"}
|
|
|
|
async def test_start_bumps_load_id(self):
|
|
loader = BackgroundAgentLoader(_make_loader_fn())
|
|
|
|
assert loader._load_id == 0
|
|
loader.start()
|
|
assert loader._load_id == 1
|
|
loader.start()
|
|
assert loader._load_id == 2
|
|
await loader.await_ready()
|
|
|
|
async def test_start_cancels_in_flight_prior_task(self):
|
|
blocking = _GatedThreadLoader("LATE")
|
|
|
|
loader = BackgroundAgentLoader(blocking)
|
|
loader.start()
|
|
first_task = loader.task
|
|
assert first_task is not None
|
|
assert await _wait_for_event(blocking.started)
|
|
# Supersede immediately; asyncio.to_thread wrapper gets cancelled.
|
|
loader._loader_fn = _make_loader_fn("FRESH")
|
|
loader.start()
|
|
agent = await loader.await_ready()
|
|
assert agent == "FRESH"
|
|
blocking.release.set()
|
|
try:
|
|
await first_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
assert first_task.cancelled() or first_task.done()
|
|
|
|
|
|
class TestBackgroundAgentLoaderCallbacks:
|
|
async def test_progress_hook_sees_events_in_order(self):
|
|
events: list[tuple[str, str, str]] = []
|
|
loader = BackgroundAgentLoader(
|
|
_make_loader_fn(capture={}),
|
|
on_progress=lambda e, s, d: events.append((e, s, d)),
|
|
)
|
|
|
|
loader.start()
|
|
await loader.await_ready()
|
|
|
|
assert events == [("start", "srv", ""), ("success", "srv", "1")]
|
|
|
|
async def test_stale_progress_events_are_dropped(self):
|
|
"""A progress event fired after a newer `start` must not reach the hook."""
|
|
slow_loader = _GatedThreadLoader(
|
|
"slow-agent", progress_events=[("success", "from-slow", "1")]
|
|
)
|
|
seen: list[str] = []
|
|
|
|
def fast_loader(*, on_mcp_progress=None):
|
|
if on_mcp_progress is not None:
|
|
on_mcp_progress("success", "from-fast", "1")
|
|
return "fast-agent"
|
|
|
|
loader = BackgroundAgentLoader(
|
|
slow_loader, on_progress=lambda e, s, d: seen.append(s)
|
|
)
|
|
|
|
loader.start()
|
|
assert await _wait_for_event(slow_loader.started)
|
|
# Loader 1 waits so its progress event fires AFTER load 2 starts.
|
|
loader._loader_fn = fast_loader
|
|
loader.start()
|
|
await loader.await_ready()
|
|
slow_loader.release.set()
|
|
assert await _wait_for_event(slow_loader.finished)
|
|
|
|
assert "from-fast" in seen
|
|
assert "from-slow" not in seen
|
|
|
|
async def test_success_callback_fires_on_completion(self):
|
|
got = []
|
|
loader = BackgroundAgentLoader(
|
|
_make_loader_fn("MY_AGENT"),
|
|
on_success=lambda a: got.append(a),
|
|
)
|
|
|
|
loader.start()
|
|
await loader.await_ready()
|
|
await asyncio.sleep(0) # let done-callback run
|
|
|
|
assert got == ["MY_AGENT"]
|
|
|
|
async def test_failure_callback_fires_on_error(self):
|
|
err = RuntimeError("load failed")
|
|
got_failures = []
|
|
got_successes = []
|
|
loader = BackgroundAgentLoader(
|
|
_make_loader_fn(fail_with=err),
|
|
on_success=lambda a: got_successes.append(a),
|
|
on_failure=lambda e: got_failures.append(e),
|
|
)
|
|
|
|
loader.start()
|
|
with pytest.raises(RuntimeError, match="load failed"):
|
|
await loader.await_ready()
|
|
await asyncio.sleep(0)
|
|
|
|
assert got_failures == [err]
|
|
assert got_successes == []
|
|
|
|
|
|
class TestBackgroundAgentLoaderAwaitReady:
|
|
async def test_returns_cached_agent_without_reawaiting(self):
|
|
captured: dict = {}
|
|
loader = BackgroundAgentLoader(_make_loader_fn("A", capture=captured))
|
|
|
|
loader.start()
|
|
assert await loader.await_ready() == "A"
|
|
assert await loader.await_ready() == "A"
|
|
|
|
assert len(captured["kwargs"]) == 1
|
|
|
|
async def test_raises_if_started_not_called(self):
|
|
loader = BackgroundAgentLoader(_make_loader_fn())
|
|
|
|
with pytest.raises(RuntimeError, match="before start"):
|
|
await loader.await_ready()
|
|
|
|
async def test_reraises_real_error_on_subsequent_awaits(self):
|
|
"""After a failure, ``await_ready`` must keep raising the real exception —
|
|
not the "before start()" sentinel — until ``start`` is called again."""
|
|
|
|
def _fail(*, on_mcp_progress=None):
|
|
raise RuntimeError("bad MCP config")
|
|
|
|
loader = BackgroundAgentLoader(_fail)
|
|
|
|
loader.start()
|
|
with pytest.raises(RuntimeError, match="bad MCP config"):
|
|
await loader.await_ready()
|
|
with pytest.raises(RuntimeError, match="bad MCP config"):
|
|
await loader.await_ready()
|
|
|
|
async def test_needs_restart_flags_failed_load_for_retry(self):
|
|
calls = {"n": 0}
|
|
|
|
def flaky(*, on_mcp_progress=None):
|
|
calls["n"] += 1
|
|
if calls["n"] == 1:
|
|
raise RuntimeError("first attempt failed")
|
|
return "SECOND"
|
|
|
|
loader = BackgroundAgentLoader(flaky)
|
|
|
|
assert loader.needs_restart # never started
|
|
loader.start()
|
|
with pytest.raises(RuntimeError):
|
|
await loader.await_ready()
|
|
assert loader.needs_restart # failed, caller may retry
|
|
loader.start()
|
|
assert await loader.await_ready() == "SECOND"
|
|
assert not loader.needs_restart # success → no retry
|
|
|
|
|
|
class TestBackgroundAgentLoaderAdopt:
|
|
def test_adopt_seats_external_agent(self):
|
|
loader = BackgroundAgentLoader(_make_loader_fn())
|
|
loader.adopt("EXTERNAL")
|
|
assert loader.agent == "EXTERNAL"
|
|
assert not loader.is_pending
|
|
|
|
async def test_adopt_supersedes_in_flight_load(self):
|
|
"""A late background completion must not overwrite an adopted agent."""
|
|
slow_loader = _GatedThreadLoader("FROM_BACKGROUND")
|
|
|
|
loader = BackgroundAgentLoader(slow_loader)
|
|
|
|
loader.start()
|
|
assert await _wait_for_event(slow_loader.started)
|
|
loader.adopt("FROM_MODEL")
|
|
slow_loader.release.set()
|
|
assert await _wait_for_event(slow_loader.finished)
|
|
await asyncio.sleep(0)
|
|
assert loader.agent == "FROM_MODEL"
|
|
|
|
|
|
class TestBackgroundAgentLoaderIsPending:
|
|
def test_false_before_start(self):
|
|
loader = BackgroundAgentLoader(_make_loader_fn())
|
|
assert not loader.is_pending
|
|
|
|
async def test_false_after_completion(self):
|
|
loader = BackgroundAgentLoader(_make_loader_fn())
|
|
|
|
loader.start()
|
|
await loader.await_ready()
|
|
|
|
assert not loader.is_pending
|
|
|
|
async def test_true_between_start_and_completion(self):
|
|
wait_loader = _GatedThreadLoader("ok")
|
|
|
|
loader = BackgroundAgentLoader(wait_loader)
|
|
|
|
loader.start()
|
|
assert await _wait_for_event(wait_loader.started)
|
|
assert loader.is_pending
|
|
wait_loader.release.set()
|
|
await loader.await_ready()
|
|
assert not loader.is_pending
|