Files
2026-05-30 14:16:04 -05:00

638 lines
22 KiB
Python

"""Tests for BasicMemoryProvider with the MCP actor mocked out."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock
def test_is_available_no_mcp(bm, monkeypatch):
monkeypatch.setattr(bm, "_MCP_AVAILABLE", False)
p = bm.BasicMemoryProvider()
assert p.is_available() is False
def test_is_available_no_bm_no_uv(bm, monkeypatch):
"""No bm AND no uv → can't install, can't operate → unavailable."""
monkeypatch.setattr(bm, "_MCP_AVAILABLE", True)
monkeypatch.setattr(bm, "_bm_binary_path", lambda: None)
monkeypatch.setattr(bm, "_uv_binary_path", lambda: None)
p = bm.BasicMemoryProvider()
assert p.is_available() is False
def test_is_available_bm_present(bm, monkeypatch):
"""bm already installed → available regardless of uv."""
monkeypatch.setattr(bm, "_MCP_AVAILABLE", True)
monkeypatch.setattr(bm, "_bm_binary_path", lambda: "/fake/bm")
monkeypatch.setattr(bm, "_uv_binary_path", lambda: None)
p = bm.BasicMemoryProvider()
assert p.is_available() is True
def test_is_available_bm_missing_but_uv_present(bm, monkeypatch):
"""bm missing but uv available → we can install bm at init time → available."""
monkeypatch.setattr(bm, "_MCP_AVAILABLE", True)
monkeypatch.setattr(bm, "_bm_binary_path", lambda: None)
monkeypatch.setattr(bm, "_uv_binary_path", lambda: "/fake/uv")
p = bm.BasicMemoryProvider()
assert p.is_available() is True
def test_name(bm):
assert bm.BasicMemoryProvider().name == "basic-memory"
assert bm.BasicMemoryProvider().name == bm.PROVIDER_NAME
def test_get_tool_schemas_unconditional(bm):
"""
Regression: Hermes captures the schema list at *register* time, before
`initialize()` runs. If get_tool_schemas() returns [] when uninitialized,
Hermes builds _tool_to_provider with no entries for us and every
subsequent bm_* invocation returns "Unknown tool: bm_*" forever.
Schemas are static — return them unconditionally.
"""
# Fresh provider, never initialized, should still expose all 10 schemas
p = bm.BasicMemoryProvider()
assert p._initialized is False
schemas = p.get_tool_schemas()
assert len(schemas) == 10
names = {s["name"] for s in schemas}
assert names == {
"bm_search",
"bm_read",
"bm_write",
"bm_edit",
"bm_context",
"bm_delete",
"bm_move",
"bm_recent",
"bm_projects",
"bm_workspaces",
}
# Initialized provider also returns 10 (idempotent)
p._initialized = True
assert len(p.get_tool_schemas()) == 10
def test_get_tool_schemas_returns_independent_copies(bm):
"""Mutating the returned list shouldn't affect the next call."""
p = bm.BasicMemoryProvider()
schemas = p.get_tool_schemas()
schemas.clear()
assert len(p.get_tool_schemas()) == 10
def test_handle_tool_call_uninitialized(bm):
p = bm.BasicMemoryProvider()
out = json.loads(p.handle_tool_call("bm_search", {"query": "x"}))
assert "error" in out
def test_handle_tool_call_unknown_tool(bm):
p = bm.BasicMemoryProvider()
p._initialized = True
p._actor = MagicMock()
out = json.loads(p.handle_tool_call("bm_bogus", {}))
assert "error" in out
def test_handle_tool_call_dispatches(bm):
p = bm.BasicMemoryProvider()
p._initialized = True
p._project = "proj"
actor = MagicMock()
actor.call.return_value = json.dumps({"results": []})
p._actor = actor
out = p.handle_tool_call("bm_search", {"query": "hi", "limit": 3})
actor.call.assert_called_once()
bm_tool, bm_args = actor.call.call_args[0][:2]
assert bm_tool == "search_notes"
assert bm_args == {"project": "proj", "query": "hi", "page_size": 3}
def test_handle_tool_call_missing_arg(bm):
p = bm.BasicMemoryProvider()
p._initialized = True
p._actor = MagicMock()
out = json.loads(p.handle_tool_call("bm_write", {"title": "x"}))
assert "error" in out
def test_handle_tool_call_actor_failure(bm):
p = bm.BasicMemoryProvider()
p._initialized = True
p._project = "proj"
actor = MagicMock()
actor.call.side_effect = RuntimeError("boom")
p._actor = actor
out = json.loads(p.handle_tool_call("bm_search", {"query": "x"}))
assert "error" in out
assert p._failure_count == 1
def test_circuit_breaker_opens_after_5_failures(bm, monkeypatch):
p = bm.BasicMemoryProvider()
p._initialized = True
p._project = "proj"
actor = MagicMock()
actor.call.side_effect = RuntimeError("boom")
p._actor = actor
for _ in range(5):
p.handle_tool_call("bm_search", {"query": "x"})
assert p._failure_pause_until > 0
assert p._is_circuit_open() is True
def test_circuit_breaker_resets_after_pause(bm, monkeypatch):
p = bm.BasicMemoryProvider()
p._failure_count = 5
p._failure_pause_until = 1.0 # already in the past
monkeypatch.setattr(bm.time, "monotonic", lambda: 9999.0)
assert p._is_circuit_open() is False
assert p._failure_count == 0
def test_session_note_title_with_session_id(bm):
from datetime import datetime, timezone
p = bm.BasicMemoryProvider()
p._session_started_at = datetime(2026, 5, 10, 13, 5, tzinfo=timezone.utc)
p._session_id = "20260510_080249_571920"
title = p._session_note_title()
# Date appears once; trailing random component is the disambiguator
assert title == "Hermes Session 2026-05-10 1305 571920"
def test_session_note_title_no_session_id(bm):
from datetime import datetime, timezone
p = bm.BasicMemoryProvider()
p._session_started_at = datetime(2026, 5, 10, 13, 5, 42, tzinfo=timezone.utc)
p._session_id = ""
title = p._session_note_title()
# Falls back to seconds for disambiguation
assert title == "Hermes Session 2026-05-10 1305 42"
def test_session_note_title_short_session_id(bm):
from datetime import datetime, timezone
p = bm.BasicMemoryProvider()
p._session_started_at = datetime(2026, 5, 10, 13, 5, tzinfo=timezone.utc)
p._session_id = "abcdef"
title = p._session_note_title()
# No `_` in id → use last 6 chars
assert title == "Hermes Session 2026-05-10 1305 abcdef"
def test_system_prompt_block_uninitialized(bm):
p = bm.BasicMemoryProvider()
assert p.system_prompt_block() == ""
def test_system_prompt_block_mentions_tools(bm):
p = bm.BasicMemoryProvider()
p._initialized = True
p._project = "test-proj"
p._mode = "local"
out = p.system_prompt_block()
assert "bm_search" in out
assert "test-proj" in out
assert "local" in out
def test_system_prompt_block_steers_away_from_cli(bm):
"""
Felix's training data is biased toward `bm tool ...` CLI patterns. The
system_prompt_block must explicitly direct the model to the bm_* tools
AND give a reason (latency) so it has a justification for following the
directive. Without this nudge, agents reach for bash/terminal tools and
pay 1-2s per call instead of ~0.1s.
"""
p = bm.BasicMemoryProvider()
p._initialized = True
p._project = "test-proj"
p._mode = "local"
out = p.system_prompt_block().lower()
# Directive: don't use the bm CLI
assert "do not shell out" in out or "do not use the" in out or "do not run" in out
assert "bm" in out and "cli" in out
# Reason given (latency / capture bypass)
assert "mcp" in out # the persistent connection is the mechanism we cite
assert "spawn" in out or "process" in out # cold-start cost is mentioned
def test_save_config_writes_json(bm, tmp_path):
p = bm.BasicMemoryProvider()
p.save_config({"mode": "local", "project": "x", "capture_per_turn": "true"}, str(tmp_path))
written = json.loads((tmp_path / "basic-memory.json").read_text())
assert written["mode"] == "local"
assert written["project"] == "x"
assert written["capture_per_turn"] is True # coerced from "true"
def test_save_config_merges_existing(bm, tmp_path):
cfg = tmp_path / "basic-memory.json"
cfg.write_text(json.dumps({"mode": "cloud", "project": "old"}))
p = bm.BasicMemoryProvider()
p.save_config({"project": "new"}, str(tmp_path))
after = json.loads(cfg.read_text())
assert after["mode"] == "cloud" # preserved
assert after["project"] == "new" # updated
def test_load_config_missing_returns_empty(bm, tmp_path):
assert bm._load_config(str(tmp_path)) == {}
def test_load_config_corrupt_returns_empty(bm, tmp_path):
(tmp_path / "basic-memory.json").write_text("{not json")
assert bm._load_config(str(tmp_path)) == {}
def test_get_config_schema_shape(bm):
schema = bm.BasicMemoryProvider().get_config_schema()
keys = {entry["key"] for entry in schema}
assert {
"mode",
"project",
"project_path",
"capture_per_turn",
"capture_session_end",
"capture_folder",
}.issubset(keys)
def test_register_appends_to_active_providers(bm):
fake_ctx = MagicMock()
bm._active_providers.clear()
bm.register(fake_ctx)
fake_ctx.register_memory_provider.assert_called_once()
assert len(bm._active_providers) == 1
bm._active_providers.clear()
def test_register_also_registers_bundled_skill(bm):
"""Plugin's bundled SKILL.md should auto-register when `hermes plugins install`
drops the repo into ~/.hermes/plugins/. Avoids the manual symlink step."""
fake_ctx = MagicMock()
bm._active_providers.clear()
bm.register(fake_ctx)
fake_ctx.register_skill.assert_called_once()
args, kwargs = fake_ctx.register_skill.call_args
# First positional arg is the bare skill name
assert args[0] == "basic-memory"
# Second positional arg is the SKILL.md path; it should resolve to a real file
assert args[1].name == "SKILL.md"
assert args[1].is_file()
bm._active_providers.clear()
def test_register_tolerates_old_hermes_without_register_skill(bm):
"""Older Hermes versions don't have ctx.register_skill; we shouldn't crash."""
class _OldCtx:
def __init__(self):
self.calls = []
def register_memory_provider(self, provider):
self.calls.append(("memory", provider))
ctx = _OldCtx()
bm._active_providers.clear()
bm.register(ctx) # must not raise
assert any(c[0] == "memory" for c in ctx.calls)
bm._active_providers.clear()
def test_register_swallows_register_skill_errors(bm, caplog):
"""If register_skill raises (path validation, ABC mismatch, etc.) we log
and continue — don't break the memory-provider registration."""
fake_ctx = MagicMock()
fake_ctx.register_skill.side_effect = ValueError("invalid skill name")
bm._active_providers.clear()
with caplog.at_level("WARNING"):
bm.register(fake_ctx)
fake_ctx.register_memory_provider.assert_called_once()
assert "register_skill failed" in caplog.text
bm._active_providers.clear()
# ---- Edge cases ----
def test_handle_tool_call_with_none_args(bm):
"""args=None must not crash; should be coerced to {} and surface a missing-arg error."""
p = bm.BasicMemoryProvider()
p._initialized = True
p._actor = MagicMock()
out = json.loads(p.handle_tool_call("bm_search", None)) # type: ignore[arg-type]
assert "error" in out
def test_handle_tool_call_unknown_does_not_invoke_actor(bm):
p = bm.BasicMemoryProvider()
p._initialized = True
p._actor = MagicMock()
p.handle_tool_call("bm_does_not_exist", {"x": 1})
p._actor.call.assert_not_called()
def test_translate_args_unknown_tool_raises(bm):
"""Unknown tool names should raise KeyError so callers handle it explicitly."""
import pytest
with pytest.raises(KeyError):
bm._translate_args("not_a_tool", {}, "proj")
# ---- Version metadata ----
def test_module_version_present(bm):
import re
assert hasattr(bm, "__version__")
assert isinstance(bm.__version__, str)
# Matches the Python release versions written by scripts/update_versions.py.
assert re.fullmatch(r"\d+\.\d+\.\d+(?:(?:b|rc)\d+)?", bm.__version__), bm.__version__
def test_module_version_matches_plugin_yaml(bm):
"""plugin.yaml ships to Hermes; __version__ is what tooling reads. Keep them in sync."""
import os
import re
plugin_yaml = os.path.join(
os.path.dirname(os.path.abspath(bm.__file__)),
"plugin.yaml",
)
text = open(plugin_yaml).read()
m = re.search(r"^\s*version\s*:\s*(\S+)\s*$", text, re.MULTILINE)
assert m is not None, "plugin.yaml is missing a version field"
assert m.group(1) == bm.__version__, (
f"plugin.yaml version ({m.group(1)}) doesn't match __version__ ({bm.__version__})"
)
# ---- uv bootstrap ----
def test_install_bm_via_uv_no_uv(bm, monkeypatch):
"""If uv isn't available, install returns None without trying to spawn."""
monkeypatch.setattr(bm, "_uv_binary_path", lambda: None)
assert bm._install_bm_via_uv() is None
def test_install_bm_via_uv_runs_uv_tool_install(bm, monkeypatch):
"""Install shells out to `uv tool install basic-memory`."""
calls: list = []
class _Result:
returncode = 0
stdout = b""
stderr = b""
def _fake_run(argv, **kwargs):
calls.append((argv, kwargs))
return _Result()
monkeypatch.setattr(bm, "_uv_binary_path", lambda: "/fake/uv")
monkeypatch.setattr(bm.subprocess, "run", _fake_run)
monkeypatch.setattr(bm, "_bm_binary_path", lambda: "/fake/bm-after-install")
result = bm._install_bm_via_uv()
assert result == "/fake/bm-after-install"
assert len(calls) == 1
argv = calls[0][0]
assert argv[0] == "/fake/uv"
assert argv[1:] == ["tool", "install", "basic-memory", "--quiet"]
def test_install_bm_via_uv_failed_returncode(bm, monkeypatch):
"""Non-zero exit logs and returns None — doesn't pretend success."""
class _Result:
returncode = 2
stdout = b""
stderr = b"network unreachable"
monkeypatch.setattr(bm, "_uv_binary_path", lambda: "/fake/uv")
monkeypatch.setattr(bm.subprocess, "run", lambda *a, **kw: _Result())
assert bm._install_bm_via_uv() is None
def test_install_bm_via_uv_subprocess_exception(bm, monkeypatch):
"""If subprocess raises (timeout, OSError, etc.) we degrade to None, not crash."""
monkeypatch.setattr(bm, "_uv_binary_path", lambda: "/fake/uv")
def _raise(*a, **kw):
raise OSError("boom")
monkeypatch.setattr(bm.subprocess, "run", _raise)
assert bm._install_bm_via_uv() is None
def test_initialize_invokes_uv_install_when_bm_missing(bm, monkeypatch, tmp_path):
"""Cold-start path: bm absent, uv present → initialize triggers install."""
install_calls: list = []
def _fake_install():
install_calls.append(True)
return None # install reports failure; initialize logs and returns
monkeypatch.setattr(bm, "_MCP_AVAILABLE", True)
monkeypatch.setattr(bm, "_bm_binary_path", lambda: None)
monkeypatch.setattr(bm, "_uv_binary_path", lambda: "/fake/uv")
monkeypatch.setattr(bm, "_install_bm_via_uv", _fake_install)
p = bm.BasicMemoryProvider()
p.initialize(session_id="test", hermes_home=str(tmp_path))
assert install_calls == [True], "expected initialize() to attempt the install"
assert p._initialized is False # install reported failure → don't start actor
def test_initialize_skips_uv_install_when_bm_present(bm, monkeypatch, tmp_path):
"""Steady-state path: bm already installed → no install attempt."""
install_calls: list = []
monkeypatch.setattr(bm, "_bm_binary_path", lambda: "/fake/bm")
def _fake_install():
install_calls.append(True)
return "/should-not-be-called"
monkeypatch.setattr(bm, "_install_bm_via_uv", _fake_install)
monkeypatch.setattr(bm, "_MCP_AVAILABLE", False) # short-circuit actor start
p = bm.BasicMemoryProvider()
p.initialize(session_id="test", hermes_home=str(tmp_path))
assert install_calls == [], "should not invoke install when bm is already present"
def test_initialize_bails_when_no_bm_no_uv(bm, monkeypatch, tmp_path, caplog):
"""No bm, no uv → log clear error, don't try to install, don't initialize."""
monkeypatch.setattr(bm, "_MCP_AVAILABLE", True)
monkeypatch.setattr(bm, "_bm_binary_path", lambda: None)
monkeypatch.setattr(bm, "_uv_binary_path", lambda: None)
install_calls: list = []
monkeypatch.setattr(bm, "_install_bm_via_uv", lambda: install_calls.append(True) or None)
p = bm.BasicMemoryProvider()
with caplog.at_level("ERROR"):
p.initialize(session_id="test", hermes_home=str(tmp_path))
assert install_calls == [] # never attempted — no uv to call
assert p._initialized is False
assert "uv is not installed" in caplog.text or "uv" in caplog.text.lower()
# ---- Defaults: stay out of bm's app dir ----
def test_default_project_name_is_hermes_memory(bm):
"""The default project name no longer carries a hostname suffix.
Each machine has its own isolated local store with this same name."""
assert bm._default_project() == "hermes-memory"
def test_default_project_path_is_in_user_space(bm):
"""~/.basic-memory/ is reserved for bm's app state. Projects live in user space."""
p = bm._default_project_path()
assert ".basic-memory" not in p, (
f"default project path must not live inside ~/.basic-memory/, got: {p}"
)
assert p.rstrip("/").endswith("hermes-memory")
# ---- bm config introspection ----
def test_bm_known_projects_missing_file(bm, monkeypatch, tmp_path):
"""When bm has never been run, return None (callers should treat as 'unknown')."""
monkeypatch.setattr(bm, "_bm_config_path", lambda: tmp_path / "config.json")
assert bm._bm_known_projects() is None
def test_bm_known_projects_corrupt_file(bm, monkeypatch, tmp_path):
cfg = tmp_path / "config.json"
cfg.write_text("{not json")
monkeypatch.setattr(bm, "_bm_config_path", lambda: cfg)
assert bm._bm_known_projects() is None
def test_bm_known_projects_returns_dict(bm, monkeypatch, tmp_path):
cfg = tmp_path / "config.json"
cfg.write_text(json.dumps({"projects": {"main": {}, "hermes-memory": {}}}))
monkeypatch.setattr(bm, "_bm_config_path", lambda: cfg)
result = bm._bm_known_projects()
assert isinstance(result, dict)
assert set(result.keys()) == {"main", "hermes-memory"}
def test_bm_known_projects_handles_non_dict_root(bm, monkeypatch, tmp_path):
cfg = tmp_path / "config.json"
cfg.write_text(json.dumps(["not a dict"]))
monkeypatch.setattr(bm, "_bm_config_path", lambda: cfg)
assert bm._bm_known_projects() is None
# ---- Project verification ----
def test_verify_project_registered_no_bm_config(bm, monkeypatch):
"""No bm config yet → assume registration is fine; let downstream surface real failures."""
monkeypatch.setattr(bm, "_bm_known_projects", lambda: None)
p = bm.BasicMemoryProvider()
p._project = "anything-goes"
assert p._verify_project_registered() is True
def test_verify_project_registered_present(bm, monkeypatch):
monkeypatch.setattr(bm, "_bm_known_projects", lambda: {"hermes-memory": {}, "main": {}})
p = bm.BasicMemoryProvider()
p._project = "hermes-memory"
assert p._verify_project_registered() is True
def test_verify_project_registered_missing(bm, monkeypatch):
monkeypatch.setattr(bm, "_bm_known_projects", lambda: {"main": {}, "other": {}})
p = bm.BasicMemoryProvider()
p._project = "hermes-memory-cloud"
assert p._verify_project_registered() is False
def test_log_missing_project_local_hint_includes_path(bm, caplog):
p = bm.BasicMemoryProvider()
p._mode = "local"
p._project = "hermes-memory"
p._project_path = "/tmp/somewhere"
with caplog.at_level("ERROR"):
p._log_missing_project()
msg = caplog.text
assert "hermes-memory" in msg
assert "/tmp/somewhere" in msg
assert "--cloud" not in msg
def test_log_missing_project_cloud_hint_uses_cloud_flag(bm, caplog):
p = bm.BasicMemoryProvider()
p._mode = "cloud"
p._project = "hermes-memory-cloud"
with caplog.at_level("ERROR"):
p._log_missing_project()
msg = caplog.text
assert "hermes-memory-cloud" in msg
assert "--cloud" in msg
# ---- initialize() bail-out on missing project ----
def test_initialize_bails_when_project_missing(bm, monkeypatch, tmp_path):
"""If bm config says the project doesn't exist, refuse to initialize."""
# bm config exists, but our project isn't in it
bm_cfg = tmp_path / ".basic-memory" / "config.json"
bm_cfg.parent.mkdir(parents=True)
bm_cfg.write_text(json.dumps({"projects": {"main": {}}}))
monkeypatch.setattr(bm, "_bm_config_path", lambda: bm_cfg)
# Cloud mode so _ensure_local_project doesn't auto-create
plugin_cfg = tmp_path / "basic-memory.json"
plugin_cfg.write_text(json.dumps({"mode": "cloud", "project": "not-registered"}))
p = bm.BasicMemoryProvider()
p.initialize(session_id="test", hermes_home=str(tmp_path))
assert p._initialized is False
assert p._actor is None
def test_initialize_proceeds_when_bm_config_absent(bm, monkeypatch, tmp_path):
"""If bm config doesn't exist (fresh install), don't false-reject — let actor try."""
monkeypatch.setattr(bm, "_bm_config_path", lambda: tmp_path / "no-such" / "config.json")
monkeypatch.setattr(bm, "_MCP_AVAILABLE", False) # shortcut: actor won't actually start
plugin_cfg = tmp_path / "basic-memory.json"
plugin_cfg.write_text(json.dumps({"mode": "cloud", "project": "anything"}))
p = bm.BasicMemoryProvider()
# We don't fully assert _initialized here because the actor won't start without
# MCP — but we DO assert _verify_project_registered didn't gate us out before
# actor-start was attempted.
p.initialize(session_id="test", hermes_home=str(tmp_path))
# Initialization fails at actor-start (MCP unavailable), not at verify.
assert p._initialized is False # expected — actor couldn't start
# _project should have been set despite the failure (proves we got past verify)
assert p._project == "anything"