mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
5365f971ef
Signed-off-by: phernandez <paul@basicmachines.co>
184 lines
5.6 KiB
Python
184 lines
5.6 KiB
Python
"""Tests for _BmMcpActor: lifecycle, call dispatch, timeout, shutdown."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import concurrent.futures
|
|
import json
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from tests.conftest import FakeSession, make_scripted_actor
|
|
|
|
|
|
# ---- Lifecycle ----
|
|
|
|
|
|
def test_new_actor_is_not_running(bm):
|
|
actor = bm._BmMcpActor(["fake-bm", "mcp"])
|
|
assert actor._running is False
|
|
|
|
|
|
def test_start_brings_actor_up(bm):
|
|
actor = make_scripted_actor(bm)
|
|
actor.start(timeout=5.0)
|
|
try:
|
|
assert actor._running is True
|
|
assert actor._session is not None
|
|
assert actor._stop_future is not None
|
|
assert actor._thread is not None and actor._thread.is_alive()
|
|
# Tools cache populated
|
|
names = {t["name"] for t in actor.list_tools()}
|
|
assert "search_notes" in names
|
|
finally:
|
|
actor.shutdown(timeout=2.0)
|
|
|
|
|
|
def test_start_is_idempotent_when_thread_alive(bm):
|
|
actor = make_scripted_actor(bm)
|
|
actor.start(timeout=5.0)
|
|
try:
|
|
first_thread = actor._thread
|
|
actor.start(timeout=5.0) # second call
|
|
assert actor._thread is first_thread # same thread, no new one spawned
|
|
finally:
|
|
actor.shutdown(timeout=2.0)
|
|
|
|
|
|
def test_start_init_error_raises(bm):
|
|
boom = ValueError("BM unreachable")
|
|
actor = make_scripted_actor(bm, raise_at_init=boom)
|
|
with pytest.raises(RuntimeError, match="BM unreachable"):
|
|
actor.start(timeout=5.0)
|
|
assert actor._running is False
|
|
|
|
|
|
def test_start_timeout_raises(bm):
|
|
"""If _ready never gets set within the timeout, start() raises TimeoutError."""
|
|
import asyncio
|
|
|
|
actor = bm._BmMcpActor(["fake-bm", "mcp"])
|
|
|
|
async def _hang_forever():
|
|
# Never call self._ready.set(); start() should time out waiting.
|
|
await asyncio.sleep(60)
|
|
|
|
actor._main = _hang_forever # type: ignore[assignment]
|
|
|
|
with pytest.raises(TimeoutError):
|
|
actor.start(timeout=0.5)
|
|
assert actor._running is False
|
|
|
|
|
|
# ---- Shutdown ----
|
|
|
|
|
|
def test_shutdown_before_start_is_noop(bm):
|
|
actor = bm._BmMcpActor(["fake-bm", "mcp"])
|
|
# Should not raise even though nothing is running
|
|
actor.shutdown(timeout=1.0)
|
|
assert actor._running is False
|
|
|
|
|
|
def test_shutdown_stops_running_actor(bm):
|
|
actor = make_scripted_actor(bm)
|
|
actor.start(timeout=5.0)
|
|
assert actor._thread is not None and actor._thread.is_alive()
|
|
actor.shutdown(timeout=5.0)
|
|
assert actor._running is False
|
|
# Thread should exit shortly after stop_future resolves
|
|
actor._thread.join(timeout=2.0)
|
|
assert not actor._thread.is_alive()
|
|
|
|
|
|
def test_shutdown_is_idempotent(bm):
|
|
actor = make_scripted_actor(bm)
|
|
actor.start(timeout=5.0)
|
|
actor.shutdown(timeout=2.0)
|
|
# Second call should not raise
|
|
actor.shutdown(timeout=2.0)
|
|
assert actor._running is False
|
|
|
|
|
|
# ---- call() dispatch ----
|
|
|
|
|
|
def test_call_before_start_raises(bm):
|
|
actor = bm._BmMcpActor(["fake-bm", "mcp"])
|
|
with pytest.raises(RuntimeError):
|
|
actor.call("search_notes", {})
|
|
|
|
|
|
def test_call_after_shutdown_raises(bm):
|
|
actor = make_scripted_actor(bm)
|
|
actor.start(timeout=5.0)
|
|
actor.shutdown(timeout=2.0)
|
|
with pytest.raises(RuntimeError, match="not running"):
|
|
actor.call("search_notes", {})
|
|
|
|
|
|
def test_call_dispatches_through_actor_loop(bm):
|
|
session = FakeSession(default_response={"results": [], "ok": True})
|
|
actor = make_scripted_actor(bm, session=session)
|
|
actor.start(timeout=5.0)
|
|
try:
|
|
out = actor.call("search_notes", {"query": "hi"}, timeout=5.0)
|
|
# Output is whatever _extract_mcp_text produces; we just verify it
|
|
# contains the response payload we configured.
|
|
assert "ok" in out
|
|
assert session.calls == [("search_notes", {"query": "hi"})]
|
|
finally:
|
|
actor.shutdown(timeout=2.0)
|
|
|
|
|
|
def test_call_returns_mcp_extracted_text(bm):
|
|
session = FakeSession(default_response={"permalink": "p/q/r", "title": "T"})
|
|
actor = make_scripted_actor(bm, session=session)
|
|
actor.start(timeout=5.0)
|
|
try:
|
|
out = actor.call("write_note", {"title": "T"}, timeout=5.0)
|
|
parsed = json.loads(out)
|
|
assert parsed["permalink"] == "p/q/r"
|
|
finally:
|
|
actor.shutdown(timeout=2.0)
|
|
|
|
|
|
def test_call_timeout_raises_and_cancels_coroutine(bm):
|
|
session = FakeSession(hang=True)
|
|
actor = make_scripted_actor(bm, session=session)
|
|
actor.start(timeout=5.0)
|
|
try:
|
|
t0 = time.monotonic()
|
|
with pytest.raises(concurrent.futures.TimeoutError):
|
|
actor.call("search_notes", {}, timeout=0.3)
|
|
elapsed = time.monotonic() - t0
|
|
# Sanity: we didn't accidentally wait the full session-side sleep
|
|
assert elapsed < 2.0
|
|
# Give the actor loop a moment to propagate the cancellation
|
|
deadline = time.monotonic() + 2.0
|
|
while time.monotonic() < deadline and not session.was_cancelled:
|
|
time.sleep(0.05)
|
|
assert session.was_cancelled is True, (
|
|
"Expected the underlying coroutine to be cancelled when call() times out"
|
|
)
|
|
finally:
|
|
actor.shutdown(timeout=2.0)
|
|
|
|
|
|
def test_list_tools_returns_independent_copy(bm):
|
|
actor = make_scripted_actor(bm)
|
|
actor.start(timeout=5.0)
|
|
try:
|
|
snapshot = actor.list_tools()
|
|
snapshot.append({"name": "tampered"})
|
|
assert "tampered" not in {t["name"] for t in actor.list_tools()}
|
|
finally:
|
|
actor.shutdown(timeout=2.0)
|
|
|
|
|
|
def test_actor_init_error_logs_and_marks_not_running(bm, caplog):
|
|
actor = make_scripted_actor(bm, raise_at_init=RuntimeError("server crashed"))
|
|
with pytest.raises(RuntimeError):
|
|
actor.start(timeout=5.0)
|
|
assert actor._running is False
|