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>
638 lines
22 KiB
Python
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"
|