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>
424 lines
13 KiB
Python
424 lines
13 KiB
Python
"""
|
|
Gated integration tests against a real basic-memory MCP server.
|
|
|
|
These tests spin up the real `bm mcp` subprocess via the production actor and
|
|
exercise every tool through `handle_tool_call`, mirroring the production code
|
|
path. They are skipped unless BOTH:
|
|
|
|
BM_INTEGRATION=1
|
|
AND `bm` is installed AND `mcp` Python package is importable
|
|
|
|
A throwaway BM project is created for the test session and removed afterward,
|
|
so these tests never touch your real BM projects.
|
|
|
|
Run them with:
|
|
|
|
BM_INTEGRATION=1 uv run --with pytest --with mcp pytest tests/test_integration.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import uuid
|
|
|
|
import pytest
|
|
|
|
|
|
# ---- Gating ----
|
|
|
|
_INTEGRATION_ENABLED = os.environ.get("BM_INTEGRATION") == "1"
|
|
_BM_BIN = shutil.which("bm") or (
|
|
os.path.expanduser("~/.local/bin/bm")
|
|
if os.path.isfile(os.path.expanduser("~/.local/bin/bm"))
|
|
else None
|
|
)
|
|
|
|
try:
|
|
import mcp # noqa: F401
|
|
|
|
_MCP_OK = True
|
|
except Exception:
|
|
_MCP_OK = False
|
|
|
|
pytestmark = [
|
|
pytest.mark.skipif(
|
|
not _INTEGRATION_ENABLED,
|
|
reason="set BM_INTEGRATION=1 to run integration tests",
|
|
),
|
|
pytest.mark.skipif(_BM_BIN is None, reason="bm CLI not on PATH"),
|
|
pytest.mark.skipif(not _MCP_OK, reason="mcp Python package not installed"),
|
|
]
|
|
|
|
|
|
# ---- Session-scoped BM project ----
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def temp_bm_project():
|
|
"""Create a throwaway BM project for the session; remove when done."""
|
|
project_name = f"hermes-bm-test-{uuid.uuid4().hex[:8]}"
|
|
project_dir = tempfile.mkdtemp(prefix=f"{project_name}-")
|
|
# Register
|
|
subprocess.run(
|
|
[_BM_BIN, "project", "add", project_name, project_dir],
|
|
check=False,
|
|
capture_output=True,
|
|
timeout=20,
|
|
)
|
|
yield project_name, project_dir
|
|
# Tear down
|
|
subprocess.run(
|
|
[_BM_BIN, "project", "remove", project_name],
|
|
check=False,
|
|
capture_output=True,
|
|
timeout=20,
|
|
)
|
|
shutil.rmtree(project_dir, ignore_errors=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def provider(bm, temp_bm_project, tmp_path):
|
|
"""Initialized provider pointing at the temp project."""
|
|
project_name, project_dir = temp_bm_project
|
|
# Pre-write a config file in this test's hermes_home so initialize picks it up
|
|
cfg = {
|
|
"mode": "local",
|
|
"project": project_name,
|
|
"project_path": project_dir,
|
|
"capture_per_turn": True,
|
|
"capture_session_end": True,
|
|
"capture_folder": "test-sessions",
|
|
}
|
|
(tmp_path / "basic-memory.json").write_text(json.dumps(cfg))
|
|
|
|
p = bm.BasicMemoryProvider()
|
|
p.initialize(
|
|
session_id=f"integration-{uuid.uuid4().hex[:6]}",
|
|
hermes_home=str(tmp_path),
|
|
platform="cli",
|
|
)
|
|
if not p._initialized:
|
|
pytest.fail("Provider failed to initialize against the real bm MCP server")
|
|
yield p
|
|
p.shutdown()
|
|
|
|
|
|
def _parse_tool_result(raw):
|
|
try:
|
|
d = json.loads(raw)
|
|
except Exception:
|
|
return None
|
|
return d
|
|
|
|
|
|
# ---- Actor smoke ----
|
|
|
|
|
|
def test_actor_starts_and_lists_expected_tools(provider, bm):
|
|
tools = {t["name"] for t in provider._actor.list_tools()}
|
|
expected = set(bm._HERMES_TO_BM.values())
|
|
missing = expected - tools
|
|
assert not missing, f"BM MCP server missing tools we depend on: {missing}"
|
|
|
|
|
|
# ---- Tool surface ----
|
|
|
|
|
|
def test_bm_write_returns_full_permalink(provider, bm):
|
|
title = f"Integration Write Test {uuid.uuid4().hex[:6]}"
|
|
raw = provider.handle_tool_call(
|
|
"bm_write",
|
|
{
|
|
"title": title,
|
|
"content": f"# {title}\n\nbody.\n",
|
|
"folder": "tests",
|
|
"tags": ["integration"],
|
|
},
|
|
)
|
|
permalink = bm._extract_permalink(raw, "")
|
|
assert permalink, f"no permalink extracted from: {raw[:300]}"
|
|
# BM permalinks include the project prefix
|
|
assert permalink.split("/")[0] == provider._project, (
|
|
f"permalink should start with project name: {permalink}"
|
|
)
|
|
|
|
|
|
def test_bm_read_round_trips_a_written_note(provider, bm):
|
|
title = f"Read RT {uuid.uuid4().hex[:6]}"
|
|
body = f"# {title}\n\nMARKER-{uuid.uuid4().hex}\n"
|
|
raw = provider.handle_tool_call(
|
|
"bm_write",
|
|
{
|
|
"title": title,
|
|
"content": body,
|
|
"folder": "tests",
|
|
},
|
|
)
|
|
permalink = bm._extract_permalink(raw, "")
|
|
raw = provider.handle_tool_call("bm_read", {"identifier": permalink})
|
|
d = _parse_tool_result(raw)
|
|
text = (d or {}).get("text") or json.dumps(d or {})
|
|
assert title in text
|
|
|
|
|
|
def test_bm_edit_append_lands_in_note(provider, bm):
|
|
title = f"Append Test {uuid.uuid4().hex[:6]}"
|
|
raw = provider.handle_tool_call(
|
|
"bm_write",
|
|
{
|
|
"title": title,
|
|
"content": f"# {title}\nseed\n",
|
|
"folder": "tests",
|
|
},
|
|
)
|
|
permalink = bm._extract_permalink(raw, "")
|
|
marker = f"APPEND-MARKER-{uuid.uuid4().hex}"
|
|
provider.handle_tool_call(
|
|
"bm_edit",
|
|
{
|
|
"identifier": permalink,
|
|
"operation": "append",
|
|
"content": f"\n{marker}\n",
|
|
},
|
|
)
|
|
raw = provider.handle_tool_call("bm_read", {"identifier": permalink})
|
|
d = _parse_tool_result(raw)
|
|
text = (d or {}).get("text") or json.dumps(d or {})
|
|
assert marker in text
|
|
|
|
|
|
def test_bm_edit_replace_section_swaps_content(provider, bm):
|
|
title = f"ReplaceSection {uuid.uuid4().hex[:6]}"
|
|
body = f"# {title}\n\n## Notes\noriginal-body\n"
|
|
raw = provider.handle_tool_call(
|
|
"bm_write",
|
|
{
|
|
"title": title,
|
|
"content": body,
|
|
"folder": "tests",
|
|
},
|
|
)
|
|
permalink = bm._extract_permalink(raw, "")
|
|
new_marker = f"REPLACED-{uuid.uuid4().hex}"
|
|
provider.handle_tool_call(
|
|
"bm_edit",
|
|
{
|
|
"identifier": permalink,
|
|
"operation": "replace_section",
|
|
"section": "## Notes",
|
|
"content": new_marker,
|
|
},
|
|
)
|
|
raw = provider.handle_tool_call("bm_read", {"identifier": permalink})
|
|
d = _parse_tool_result(raw)
|
|
text = (d or {}).get("text") or json.dumps(d or {})
|
|
assert new_marker in text
|
|
assert "original-body" not in text
|
|
|
|
|
|
def test_bm_search_finds_a_freshly_written_note(provider, bm):
|
|
unique = f"SEARCH-MARKER-{uuid.uuid4().hex}"
|
|
title = f"Search Test {unique}"
|
|
provider.handle_tool_call(
|
|
"bm_write",
|
|
{
|
|
"title": title,
|
|
"content": f"# {title}\nbody.\n",
|
|
"folder": "tests",
|
|
},
|
|
)
|
|
raw = provider.handle_tool_call("bm_search", {"query": unique, "limit": 5})
|
|
d = _parse_tool_result(raw)
|
|
text = (d or {}).get("text") or json.dumps(d or {})
|
|
assert unique in text or title in text
|
|
|
|
|
|
def test_bm_context_returns_results(provider, bm):
|
|
title = f"Context Test {uuid.uuid4().hex[:6]}"
|
|
raw = provider.handle_tool_call(
|
|
"bm_write",
|
|
{
|
|
"title": title,
|
|
"content": f"# {title}\n",
|
|
"folder": "tests",
|
|
},
|
|
)
|
|
permalink = bm._extract_permalink(raw, "")
|
|
raw = provider.handle_tool_call(
|
|
"bm_context",
|
|
{
|
|
"url": f"memory://{permalink}",
|
|
"depth": 1,
|
|
},
|
|
)
|
|
d = _parse_tool_result(raw)
|
|
assert d is not None
|
|
# build_context returns a JSON dict with `results` (and other fields)
|
|
text_blob = json.dumps(d)
|
|
assert "results" in text_blob
|
|
|
|
|
|
def test_bm_move_relocates_note(provider, bm):
|
|
"""
|
|
BM permalinks are stable IDs that don't change on move — only the
|
|
file_path moves. So we verify by:
|
|
1. The move response itself reports the new destination
|
|
2. Reading by the original permalink still succeeds (note wasn't lost)
|
|
"""
|
|
title = f"Move Test {uuid.uuid4().hex[:6]}"
|
|
raw = provider.handle_tool_call(
|
|
"bm_write",
|
|
{
|
|
"title": title,
|
|
"content": f"# {title}\n",
|
|
"folder": "tests",
|
|
},
|
|
)
|
|
permalink = bm._extract_permalink(raw, "")
|
|
assert permalink, "expected a permalink from bm_write"
|
|
|
|
raw = provider.handle_tool_call(
|
|
"bm_move",
|
|
{
|
|
"identifier": permalink,
|
|
"new_folder": "tests/archive",
|
|
},
|
|
)
|
|
d = _parse_tool_result(raw)
|
|
move_text = (d or {}).get("text") or json.dumps(d or {})
|
|
# Move response text reports both the old and new locations
|
|
assert "moved successfully" in move_text.lower() or "moved" in move_text.lower(), (
|
|
f"move response missing success indicator: {move_text[:200]}"
|
|
)
|
|
assert "tests/archive" in move_text, f"move response missing new folder: {move_text[:200]}"
|
|
|
|
# Permalink is stable — reading by it should still work
|
|
raw = provider.handle_tool_call("bm_read", {"identifier": permalink})
|
|
d = _parse_tool_result(raw)
|
|
read_text = (d or {}).get("text") or json.dumps(d or {})
|
|
assert title in read_text, "note should still be readable after move"
|
|
|
|
|
|
def test_bm_delete_removes_note(provider, bm):
|
|
title = f"Delete Test {uuid.uuid4().hex[:6]}"
|
|
raw = provider.handle_tool_call(
|
|
"bm_write",
|
|
{
|
|
"title": title,
|
|
"content": f"# {title}\n",
|
|
"folder": "tests",
|
|
},
|
|
)
|
|
permalink = bm._extract_permalink(raw, "")
|
|
provider.handle_tool_call("bm_delete", {"identifier": permalink})
|
|
# Read should now indicate "not found"
|
|
raw = provider.handle_tool_call("bm_read", {"identifier": permalink})
|
|
d = _parse_tool_result(raw)
|
|
text = (d or {}).get("text") or json.dumps(d or {})
|
|
assert "not found" in text.lower() or "no notes found" in text.lower(), (
|
|
f"expected a 'not found' indication, got: {text[:200]}"
|
|
)
|
|
|
|
|
|
# ---- Capture pipeline ----
|
|
|
|
|
|
def test_sync_turn_writes_then_appends_to_same_session_note(provider, bm):
|
|
# First turn — creates the session note
|
|
provider.sync_turn("integration turn-1 user", "integration turn-1 assistant")
|
|
if provider._sync_thread:
|
|
provider._sync_thread.join(timeout=20.0)
|
|
sid_1 = provider._session_note_id
|
|
assert sid_1, "first sync_turn should set _session_note_id"
|
|
assert sid_1.startswith(provider._project + "/"), (
|
|
f"session_note_id should include project prefix, got: {sid_1}"
|
|
)
|
|
|
|
# Second turn — should append to the same note
|
|
marker = f"TURN-2-MARKER-{uuid.uuid4().hex}"
|
|
provider.sync_turn("integration turn-2 user", marker)
|
|
if provider._sync_thread:
|
|
provider._sync_thread.join(timeout=20.0)
|
|
sid_2 = provider._session_note_id
|
|
assert sid_2 == sid_1, "session_note_id should NOT change between turns"
|
|
|
|
# Verify both turn markers are present in the persisted note
|
|
raw = provider.handle_tool_call("bm_read", {"identifier": sid_1})
|
|
d = _parse_tool_result(raw)
|
|
text = (d or {}).get("text") or json.dumps(d or {})
|
|
assert "integration turn-1 user" in text
|
|
assert marker in text
|
|
|
|
|
|
def test_on_session_end_writes_summary_with_relations(provider, bm):
|
|
# Seed a session note via sync_turn
|
|
provider.sync_turn("first message", "first reply")
|
|
if provider._sync_thread:
|
|
provider._sync_thread.join(timeout=20.0)
|
|
sid = provider._session_note_id
|
|
assert sid
|
|
|
|
provider.on_session_end(
|
|
[
|
|
{"role": "user", "content": "first message"},
|
|
{"role": "assistant", "content": "first reply"},
|
|
]
|
|
)
|
|
|
|
# Search for the summary
|
|
raw = provider.handle_tool_call(
|
|
"bm_search",
|
|
{
|
|
"query": "Hermes Session Summary",
|
|
"limit": 5,
|
|
},
|
|
)
|
|
d = _parse_tool_result(raw)
|
|
text = (d or {}).get("text") or json.dumps(d or {})
|
|
assert "Hermes Session Summary" in text
|
|
|
|
|
|
def test_prefetch_against_real_bm(provider, bm):
|
|
# Seed a recognizable note
|
|
unique = f"PREFETCH-MARKER-{uuid.uuid4().hex}"
|
|
provider.handle_tool_call(
|
|
"bm_write",
|
|
{
|
|
"title": f"Prefetch Test {unique}",
|
|
"content": f"# Prefetch Test\n{unique}\n",
|
|
"folder": "tests",
|
|
},
|
|
)
|
|
|
|
# BM's FTS index is updated synchronously inside the write_note API
|
|
# path (knowledge_router.py:272), so this loop is really only smoothing
|
|
# over the round-trip cost of a few RPCs on a slow runner. prefetch
|
|
# explicitly requests search_type="text" so we don't get pulled onto
|
|
# BM's hybrid path, where vector indexing is async and would race the
|
|
# search.
|
|
budget_secs = 10.0
|
|
deadline = time.monotonic() + budget_secs
|
|
out = ""
|
|
attempts = 0
|
|
while time.monotonic() < deadline:
|
|
attempts += 1
|
|
out = provider.prefetch(unique)
|
|
if out:
|
|
break
|
|
time.sleep(0.25)
|
|
|
|
assert out, (
|
|
f"prefetch returned nothing after {attempts} attempt(s) over "
|
|
f"{budget_secs}s; provider._failure_count={provider._failure_count}, "
|
|
f"circuit_open={provider._is_circuit_open()}. "
|
|
f"Either BM didn't index the note in time or prefetch's actor.call "
|
|
f"is timing out internally."
|
|
)
|
|
assert "Basic Memory Recall" in out
|
|
assert unique in out or "Prefetch Test" in out
|