Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
View File
@@ -0,0 +1,18 @@
"""Tests for the ByteRover memory provider config gates."""
from plugins.memory.byterover import ByteRoverMemoryProvider
def test_auto_extract_false_skips_sync_turn(monkeypatch):
calls = []
provider = ByteRoverMemoryProvider({"auto_extract": False})
provider.initialize("session-1")
monkeypatch.setattr("plugins.memory.byterover._run_brv", lambda *args, **kwargs: calls.append((args, kwargs)))
provider.sync_turn("please remember this detail", "acknowledged")
assert calls == []
assert provider._sync_thread is None
@@ -0,0 +1,67 @@
"""Tests for config-schema loading from memory provider plugin dirs."""
import plugins.memory.config_schema as config_schema
from plugins.memory.config_schema import get_provider_config_schema
def test_unknown_provider_is_none():
assert get_provider_config_schema("builtin") is None
def test_plugin_without_schema_is_none():
# mem0 is a real plugin dir that declares no config_schema.py.
assert get_provider_config_schema("mem0") is None
def test_schemas_are_cached_per_provider():
assert get_provider_config_schema("honcho") is get_provider_config_schema("honcho")
def test_cache_keys_on_schema_path_not_name(monkeypatch, tmp_path):
# User-installed plugins are per-profile; two profiles' plugins sharing a
# name must not answer for each other.
import plugins.memory as memory
schemas = {}
for label in ("A", "B"):
plugin_dir = tmp_path / label / "custom"
plugin_dir.mkdir(parents=True)
(plugin_dir / "config_schema.py").write_text(
"from plugins.memory.config_schema import ProviderConfigSchema\n"
f'CONFIG_SCHEMA = ProviderConfigSchema(name="custom", label="{label}")\n',
encoding="utf-8",
)
schemas[label] = plugin_dir
monkeypatch.setattr(config_schema, "_SCHEMA_CACHE", {})
monkeypatch.setattr(memory, "find_provider_dir", lambda name: schemas["A"])
assert get_provider_config_schema("custom").label == "A"
monkeypatch.setattr(memory, "find_provider_dir", lambda name: schemas["B"])
assert get_provider_config_schema("custom").label == "B"
def test_broken_schema_is_not_cached(monkeypatch, tmp_path):
# A load failure must retry on the next request, not pin an empty panel.
broken_dir = tmp_path / "broken"
broken_dir.mkdir()
schema_file = broken_dir / "config_schema.py"
schema_file.write_text("this is not python(", encoding="utf-8")
monkeypatch.setattr(config_schema, "_SCHEMA_CACHE", {})
import plugins.memory as memory
monkeypatch.setattr(memory, "find_provider_dir", lambda name: broken_dir)
assert get_provider_config_schema("broken") is None
assert not config_schema._SCHEMA_CACHE
schema_file.write_text(
"from plugins.memory.config_schema import ProviderConfigSchema\n"
'CONFIG_SCHEMA = ProviderConfigSchema(name="broken", label="Broken")\n',
encoding="utf-8",
)
recovered = get_provider_config_schema("broken")
assert recovered is not None
assert recovered.label == "Broken"
@@ -0,0 +1,221 @@
"""Discovery parity for out-of-tree memory providers.
Upstream policy closed ``plugins/memory/`` to new providers, so every new
memory backend now lives outside this tree. These tests cover the two sources
that reach it — project-local directories and pip entry points — and the
integration points a directory install gets for free but a pip install
historically did not: the dashboard config panel, the provider's CLI
subcommands, and the ``memory.provider`` dropdown.
"""
from __future__ import annotations
import importlib.metadata
import sys
import textwrap
from pathlib import Path
import pytest
import plugins.memory as memory_plugins
PROVIDER_SOURCE = """\
from agent.memory_provider import MemoryProvider
class Provider(MemoryProvider):
@property
def name(self):
return "{name}"
def is_available(self):
return True
def initialize(self, *a, **kw):
pass
def get_tool_schemas(self):
return []
def register(ctx):
ctx.register_memory_provider(Provider())
"""
class FakeEntryPoint:
"""Mirrors the importlib.metadata EntryPoint surface discovery uses."""
group = "hermes_agent.memory_providers"
def __init__(self, name, value):
self.name = name
self.value = value
def load(self):
import importlib
module_name, _, attr = self.value.partition(":")
module = importlib.import_module(module_name)
return getattr(module, attr) if attr else module
class FakeEntryPoints(list):
def select(self, *, group):
return [ep for ep in self if ep.group == group]
@pytest.fixture
def entry_points(monkeypatch):
"""Install a replaceable entry-point set for the memory group."""
registry = FakeEntryPoints()
monkeypatch.setattr(importlib.metadata, "entry_points", lambda: registry)
return registry
def _write_provider_dir(root: Path, name: str) -> Path:
provider = root / name
provider.mkdir(parents=True)
(provider / "__init__.py").write_text(PROVIDER_SOURCE.format(name=name), encoding="utf-8")
return provider
# ---------------------------------------------------------------------------
# Project-local providers
# ---------------------------------------------------------------------------
def test_project_dir_is_ignored_without_opt_in(tmp_path, monkeypatch):
"""A repo you merely cd into must not be able to offer a memory backend."""
_write_provider_dir(tmp_path / ".hermes" / "plugins", "projectmem")
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("HERMES_ENABLE_PROJECT_PLUGINS", raising=False)
assert "projectmem" not in memory_plugins.list_memory_provider_names()
assert memory_plugins.find_provider_dir("projectmem") is None
def test_project_dir_is_discovered_when_opted_in(tmp_path, monkeypatch):
provider = _write_provider_dir(tmp_path / ".hermes" / "plugins", "projectmem")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", "1")
assert "projectmem" in memory_plugins.list_memory_provider_names()
assert memory_plugins.find_provider_dir("projectmem") == provider
def test_bundled_still_wins_over_project(tmp_path, monkeypatch):
"""Precedence here is bundled-first, the reverse of the general
PluginManager's later-wins order. A provider is activated by name, so a
directory dropped into the working tree must not be able to shadow a
shipped one and silently redirect the agent's memory."""
_write_provider_dir(tmp_path / ".hermes" / "plugins", "honcho")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", "1")
resolved = memory_plugins.find_provider_dir("honcho")
assert resolved == Path(memory_plugins.__file__).parent / "honcho"
# ---------------------------------------------------------------------------
# Pip entry-point providers
# ---------------------------------------------------------------------------
def test_entry_point_provider_is_listed(entry_points, tmp_path, monkeypatch):
"""list_memory_provider_names() fills the dashboard's memory.provider
dropdown. Enumerating entry points reads distribution metadata without
executing any of it, so this stays safe to call at import time."""
entry_points.append(FakeEntryPoint("pipmem", "pipmem_pkg"))
assert "pipmem" in memory_plugins.list_memory_provider_names()
def test_find_provider_dir_resolves_a_package_entry_point(entry_points, tmp_path, monkeypatch):
"""Without a directory, a pip-installed provider silently loses its
dashboard config panel and its `hermes <provider>` subcommands — both are
read from disk rather than imported."""
package = tmp_path / "pipmem_pkg"
package.mkdir()
(package / "__init__.py").write_text(PROVIDER_SOURCE.format(name="pipmem"), encoding="utf-8")
(package / "config_schema.py").write_text("CONFIG_SCHEMA = None\n", encoding="utf-8")
monkeypatch.syspath_prepend(str(tmp_path))
entry_points.append(FakeEntryPoint("pipmem", "pipmem_pkg:register"))
assert memory_plugins.find_provider_dir("pipmem") == package
def test_resolving_an_entry_point_does_not_import_it(entry_points, tmp_path, monkeypatch):
"""Discovery runs before the operator has chosen a provider. Importing
every installed candidate would execute third-party code on the strength of
a package merely being present."""
package = tmp_path / "sideeffect_pkg"
package.mkdir()
(package / "__init__.py").write_text(
textwrap.dedent(
"""\
import pathlib
pathlib.Path(__file__).with_name("IMPORTED").write_text("x")
"""
),
encoding="utf-8",
)
monkeypatch.syspath_prepend(str(tmp_path))
entry_points.append(FakeEntryPoint("sideeffect", "sideeffect_pkg"))
assert memory_plugins.find_provider_dir("sideeffect") == package
assert not (package / "IMPORTED").exists()
assert "sideeffect_pkg" not in sys.modules
def test_bare_module_entry_point_has_no_directory(entry_points, tmp_path, monkeypatch):
"""A single-file provider has nowhere to put a sibling config_schema.py, so
it resolves to None rather than handing back the whole site-packages root."""
(tmp_path / "flatmem.py").write_text(PROVIDER_SOURCE.format(name="flatmem"), encoding="utf-8")
monkeypatch.syspath_prepend(str(tmp_path))
entry_points.append(FakeEntryPoint("flatmem", "flatmem"))
assert memory_plugins.find_provider_dir("flatmem") is None
# ---------------------------------------------------------------------------
# Registration surface
# ---------------------------------------------------------------------------
def test_a_secondary_registration_cannot_cost_the_provider(tmp_path, monkeypatch):
"""register_auxiliary_task used to raise AttributeError on the collector —
which the loader caught, discarded the registered provider, and replaced
with a bare second instance built by the subclass scan. A silent downgrade
that looked like success."""
plugins_root = tmp_path / "plugins"
provider = _write_provider_dir(plugins_root, "auxmem")
(provider / "__init__.py").write_text(
PROVIDER_SOURCE.format(name="auxmem").replace(
" ctx.register_memory_provider(Provider())\n",
" instance = Provider()\n"
" instance.marked = True\n"
" ctx.register_memory_provider(instance)\n"
" ctx.register_auxiliary_task(\n"
" 'auxmem_filter', display_name='Aux', description='d'\n"
" )\n",
),
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
loaded = memory_plugins.load_memory_provider("auxmem")
assert loaded is not None
assert loaded.name == "auxmem"
# The instance register() handed over, not a replacement.
assert getattr(loaded, "marked", False)
def test_activation_is_not_gated_on_plugins_enabled(tmp_path, monkeypatch):
"""Memory providers are activated by naming them in memory.provider. Using
a real PluginContext for secondary registrations must not start also
requiring the plugin in plugins.enabled — that would break every existing
user-installed provider."""
_write_provider_dir(tmp_path / "plugins", "gatedmem")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
assert memory_plugins.load_memory_provider("gatedmem") is not None
@@ -0,0 +1,51 @@
"""Tests for Hindsight's declared config surface."""
from plugins.memory.config_schema import (
KIND_SECRET,
KIND_SELECT,
get_provider_config_schema,
)
def test_hindsight_is_declared():
provider = get_provider_config_schema("hindsight")
assert provider is not None
assert provider.label == "Hindsight"
assert {field.key for field in provider.fields} == {
"mode",
"api_key",
"api_url",
"bank_id",
"recall_budget",
}
def test_fields_are_all_inline():
provider = get_provider_config_schema("hindsight")
assert provider is not None
# Hindsight is simple enough to render fully in the compact panel, so it
# never grows a Full config… modal.
assert all(field.inline for field in provider.fields)
def test_mode_gating_is_expressed_as_select_options():
provider = get_provider_config_schema("hindsight")
assert provider is not None
mode = next(field for field in provider.fields if field.key == "mode")
assert mode.kind == KIND_SELECT
assert mode.allowed_values() == {"cloud", "local_external"}
# local_embedded is intentionally unsupported on desktop.
assert "local_embedded" not in mode.allowed_values()
def test_api_key_is_a_secret_bound_to_env():
provider = get_provider_config_schema("hindsight")
assert provider is not None
api_key = next(field for field in provider.fields if field.key == "api_key")
assert api_key.kind == KIND_SECRET
assert api_key.is_secret is True
assert api_key.env_key == "HINDSIGHT_API_KEY"
@@ -0,0 +1,80 @@
"""Regression tests: the embedded Hindsight profile env file carries the
plaintext ``HINDSIGHT_API_LLM_API_KEY`` and must be created/kept owner-only
(0600), and must not survive a failed post-write permission validation.
"""
import os
import stat
from pathlib import Path
import pytest
from plugins.memory.hindsight import (
_embedded_profile_env_path,
_materialize_embedded_profile_env,
)
@pytest.fixture(autouse=True)
def _isolated_home(tmp_path, monkeypatch):
isolated_home = tmp_path / "user-home"
monkeypatch.setattr(Path, "home", classmethod(lambda cls: isolated_home))
return isolated_home
_CONFIG = {
"profile": "hermes",
"llm_provider": "openai",
"llm_model": "gpt-4o-mini",
}
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not enforced on Windows")
def test_fresh_profile_env_is_owner_only_despite_permissive_umask():
old_umask = os.umask(0o022)
try:
profile_env = _materialize_embedded_profile_env(
_CONFIG, llm_api_key="sk-hindsight-secret"
)
finally:
os.umask(old_umask)
assert profile_env.exists()
assert stat.S_IMODE(profile_env.stat().st_mode) == 0o600
assert "HINDSIGHT_API_LLM_API_KEY=sk-hindsight-secret\n" in profile_env.read_text(
encoding="utf-8"
)
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not enforced on Windows")
def test_rewrite_tightens_existing_world_readable_profile_env():
profile_env = _embedded_profile_env_path(_CONFIG)
profile_env.parent.mkdir(parents=True)
profile_env.write_text("HINDSIGHT_API_LLM_API_KEY=stale\n", encoding="utf-8")
os.chmod(profile_env, 0o644)
_materialize_embedded_profile_env(_CONFIG, llm_api_key="sk-current")
assert stat.S_IMODE(profile_env.stat().st_mode) == 0o600
assert "HINDSIGHT_API_LLM_API_KEY=sk-current\n" in profile_env.read_text(
encoding="utf-8"
)
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits are not enforced on Windows")
def test_secret_file_removed_when_permission_validation_fails(monkeypatch):
"""If the post-write permission check cannot verify 0600, the plaintext
key file must not be left behind."""
import plugins.memory.hindsight as hs
def _fail_validation(profile_env):
raise PermissionError(f"not owner-only: {profile_env}")
monkeypatch.setattr(hs, "_validate_profile_env_permissions", _fail_validation)
with pytest.raises(PermissionError):
_materialize_embedded_profile_env(_CONFIG, llm_api_key="sk-doomed")
assert not _embedded_profile_env_path(_CONFIG).exists(), (
"secret env file must be cleaned up when validation fails"
)
@@ -0,0 +1,56 @@
"""NousResearch/hermes-agent#7718 — actionable message when local_embedded
runtime (`hindsight-all`) is missing.
`local_embedded` imports `from hindsight import HindsightEmbedded`, provided
only by `hindsight-all`. When it's absent the provider disables itself; the
disable warning should point the user at the fix rather than just echoing
`No module named 'hindsight'`.
"""
import sys
import plugins.memory.hindsight as hs
from plugins.memory.hindsight import HindsightMemoryProvider, _local_runtime_hint
def test_hint_for_missing_hindsight_all():
hint = _local_runtime_hint("No module named 'hindsight'")
assert "hindsight-all" in hint
assert "hermes memory setup" in hint
assert sys.executable in hint
def test_hint_for_missing_hindsight_embed():
hint = _local_runtime_hint("No module named 'hindsight_embed.daemon_embed_manager'")
assert "hindsight-all" in hint
def test_no_hint_for_unrelated_runtime_error():
# e.g. the NumPy-on-old-CPU failure _check_local_runtime also guards against
assert _local_runtime_hint("Illegal instruction (NumPy SIMD)") == ""
assert _local_runtime_hint(None) == ""
# unavailable_reason() — surfaces the hint through the reachable path (#7718):
# is_available() gates initialize() out, so the hint must come from here.
def test_unavailable_reason_surfaces_hint_for_local_embedded(monkeypatch):
monkeypatch.setattr(hs, "_load_config", lambda: {"mode": "local_embedded"})
monkeypatch.setattr(hs, "_check_local_runtime", lambda: (False, "No module named 'hindsight'"))
reason = HindsightMemoryProvider().unavailable_reason()
assert "hindsight-all" in reason
assert reason == reason.strip() # no leading/trailing whitespace
def test_unavailable_reason_empty_for_cloud(monkeypatch):
monkeypatch.setattr(hs, "_load_config", lambda: {"mode": "cloud"})
# Should not even probe the runtime for a cloud provider.
monkeypatch.setattr(hs, "_check_local_runtime", lambda: (_ for _ in ()).throw(AssertionError("probed")))
assert HindsightMemoryProvider().unavailable_reason() == ""
def test_unavailable_reason_empty_when_runtime_present(monkeypatch):
monkeypatch.setattr(hs, "_load_config", lambda: {"mode": "local_embedded"})
monkeypatch.setattr(hs, "_check_local_runtime", lambda: (True, None))
assert HindsightMemoryProvider().unavailable_reason() == ""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,260 @@
"""Tests for the Hindsight setup-wizard starter-template step."""
import json
from contextlib import contextmanager
import pytest
from plugins.memory.hindsight import templates as tpl
_CATALOG = {
"templates": [
{"id": "conversation", "name": "Conversation", "integrations": ["litellm", "hermes"],
"manifest_file": "templates/conversation.json"},
{"id": "coding-agent", "name": "Coding Agent", "integrations": ["claude-code"],
"manifest_file": "templates/coding-agent.json"},
{"id": "hermes-gateway-bot", "name": "Gateway Bot", "integrations": ["hermes"],
"manifest_file": "templates/hermes-gateway-bot.json"},
]
}
def test_fetch_hermes_templates_filters_to_hermes(monkeypatch):
monkeypatch.setattr(tpl, "_get_json", lambda url: _CATALOG)
entries = tpl.fetch_hermes_templates("https://example/templates.json")
ids = [e["id"] for e in entries]
assert ids == ["conversation", "hermes-gateway-bot"] # coding-agent excluded
def test_fetch_manifest_resolves_relative_url(monkeypatch):
seen = {}
def _fake(url):
seen["url"] = url
return {"version": "1"}
monkeypatch.setattr(tpl, "_get_json", _fake)
tpl.fetch_manifest(
{"manifest_file": "templates/hermes-gateway-bot.json"},
"https://raw.example/data/templates.json",
)
assert seen["url"] == "https://raw.example/data/templates/hermes-gateway-bot.json"
def test_apply_template_posts_to_import_endpoint(monkeypatch):
captured = {}
@contextmanager
def _fake_open(req, timeout=None):
captured["url"] = req.full_url
captured["method"] = req.get_method()
captured["auth"] = req.get_header("Authorization")
captured["body"] = json.loads(req.data.decode("utf-8"))
class _Resp:
def read(self):
return b""
yield _Resp()
monkeypatch.setattr(tpl, "open_credentialed_url", _fake_open)
tpl.apply_template("https://api.hindsight.vectorize.io/", "hermes", "hsk_abc", {"version": "1"})
assert captured["url"] == "https://api.hindsight.vectorize.io/v1/default/banks/hermes/import"
assert captured["method"] == "POST"
assert captured["auth"] == "Bearer hsk_abc"
assert captured["body"] == {"version": "1"}
def test_apply_template_omits_auth_when_no_key(monkeypatch):
captured = {}
@contextmanager
def _fake_open(req, timeout=None):
captured["auth"] = req.get_header("Authorization")
class _Resp:
def read(self):
return b""
yield _Resp()
monkeypatch.setattr(tpl, "open_credentialed_url", _fake_open)
tpl.apply_template("http://localhost:8888", "hermes", None, {"version": "1"})
assert captured["auth"] is None
def _select_returning(idx):
def _select(title, items, default=0, cancel_returns=None):
return idx
return _select
def _select_seq(*returns):
it = iter(returns)
def _select(title, items, default=0, cancel_returns=None):
return next(it)
return _select
def test_supported_for_mode():
assert tpl.supported_for_mode("cloud") is True
assert tpl.supported_for_mode("local_external") is True
assert tpl.supported_for_mode("local_embedded") is False
assert tpl.supported_for_mode("local") is False
def test_run_template_step_applies_selected(monkeypatch):
monkeypatch.setattr(tpl, "fetch_hermes_templates", lambda url=None: [
{"id": "hermes-gateway-bot", "name": "Gateway Bot", "manifest_file": "templates/x.json"},
])
monkeypatch.setattr(tpl, "fetch_manifest", lambda entry, url=None: {"version": "1"})
monkeypatch.setattr(tpl, "probe_existing_customization", lambda *a: False)
applied = {}
monkeypatch.setattr(tpl, "apply_template",
lambda api_url, bank_id, api_key, manifest: applied.update(bank=bank_id))
result = tpl.run_template_step(
api_url="https://api", bank_id="hermes", api_key="k",
select=_select_returning(0), cancelled=-1, log=lambda *_: None,
)
assert result == "hermes-gateway-bot"
assert applied["bank"] == "hermes"
def test_run_template_step_blank_selection_skips(monkeypatch):
entries = [{"id": "hermes-gateway-bot", "name": "Gateway Bot", "manifest_file": "templates/x.json"}]
monkeypatch.setattr(tpl, "fetch_hermes_templates", lambda url=None: entries)
called = {"applied": False}
monkeypatch.setattr(tpl, "apply_template",
lambda *a, **k: called.update(applied=True))
# index len(entries) == the "Blank" row
result = tpl.run_template_step(
api_url="https://api", bank_id="hermes", api_key="k",
select=_select_returning(len(entries)), cancelled=-1, log=lambda *_: None,
)
assert result is None
assert called["applied"] is False
def test_run_template_step_no_templates_is_noop(monkeypatch):
monkeypatch.setattr(tpl, "fetch_hermes_templates", lambda url=None: [])
result = tpl.run_template_step(
api_url="https://api", bank_id="hermes", api_key="k",
select=_select_returning(0), cancelled=-1, log=lambda *_: None,
)
assert result is None
def test_run_template_step_swallows_fetch_errors(monkeypatch):
def _boom(url=None):
raise RuntimeError("network down")
monkeypatch.setattr(tpl, "fetch_hermes_templates", _boom)
# must not raise
assert tpl.run_template_step(
api_url="https://api", bank_id="hermes", api_key="k",
select=_select_returning(0), cancelled=-1, log=lambda *_: None,
) is None
def test_run_template_step_swallows_apply_errors(monkeypatch):
# gap 1: a failed apply (e.g. 401 for an OAuth-only user) must not crash setup.
import urllib.error
monkeypatch.setattr(tpl, "fetch_hermes_templates", lambda url=None: [
{"id": "hermes-gateway-bot", "name": "Gateway Bot", "manifest_file": "templates/x.json"},
])
monkeypatch.setattr(tpl, "fetch_manifest", lambda entry, url=None: {"version": "1"})
monkeypatch.setattr(tpl, "probe_existing_customization", lambda *a: False)
def _raise(*a, **k):
raise urllib.error.HTTPError("u", 401, "Unauthorized", {}, None)
monkeypatch.setattr(tpl, "apply_template", _raise)
logs = []
result = tpl.run_template_step(
api_url="https://api", bank_id="hermes", api_key=None,
select=_select_returning(0), cancelled=-1, log=logs.append,
)
assert result is None
assert any("Could not apply" in line for line in logs)
def _fake_open(payload):
@contextmanager
def _cm(req, timeout=None):
class _Resp:
def read(self):
return json.dumps(payload).encode("utf-8")
yield _Resp()
return _cm
def test_probe_existing_customization_true_when_bank_has_config(monkeypatch):
monkeypatch.setattr(tpl, "open_credentialed_url",
_fake_open({"version": "1", "bank": {"reflect_mission": "x"}}))
assert tpl.probe_existing_customization("https://api", "hermes", "k") is True
def test_probe_existing_customization_false_when_empty(monkeypatch):
monkeypatch.setattr(tpl, "open_credentialed_url",
_fake_open({"version": "1"}))
assert tpl.probe_existing_customization("https://api", "hermes", "k") is False
def test_probe_existing_customization_false_on_error(monkeypatch):
def _boom(req, timeout=None):
raise OSError("no bank")
monkeypatch.setattr(tpl, "open_credentialed_url", _boom)
assert tpl.probe_existing_customization("https://api", "missing", None) is False
def _wire_apply(monkeypatch, customized):
monkeypatch.setattr(tpl, "fetch_hermes_templates", lambda url=None: [
{"id": "hermes-gateway-bot", "name": "Gateway Bot", "manifest_file": "templates/x.json"},
])
monkeypatch.setattr(tpl, "fetch_manifest", lambda entry, url=None: {"version": "1"})
monkeypatch.setattr(tpl, "probe_existing_customization", lambda *a: customized)
called = {"applied": False}
monkeypatch.setattr(tpl, "apply_template", lambda *a, **k: called.update(applied=True))
return called
def test_warns_and_keeps_existing_when_declined(monkeypatch):
called = _wire_apply(monkeypatch, customized=True)
# first select = pick template (0); second select = confirm -> "Keep existing" (1)
result = tpl.run_template_step(
api_url="https://api", bank_id="hermes", api_key="k",
select=_select_seq(0, 1), cancelled=-1, log=lambda *_: None,
)
assert result is None
assert called["applied"] is False
def test_warns_then_applies_when_confirmed(monkeypatch):
called = _wire_apply(monkeypatch, customized=True)
# pick template (0), confirm "Apply" (0)
result = tpl.run_template_step(
api_url="https://api", bank_id="hermes", api_key="k",
select=_select_seq(0, 0), cancelled=-1, log=lambda *_: None,
)
assert result == "hermes-gateway-bot"
assert called["applied"] is True
def test_fresh_bank_skips_the_warning(monkeypatch):
called = _wire_apply(monkeypatch, customized=False)
# only ONE select call (no confirm) — _select_seq with a single value proves it
result = tpl.run_template_step(
api_url="https://api", bank_id="hermes", api_key="k",
select=_select_seq(0), cancelled=-1, log=lambda *_: None,
)
assert result == "hermes-gateway-bot"
assert called["applied"] is True
@@ -0,0 +1,139 @@
"""Regression tests for #57682 — holographic auto_extract harvested
context-compaction handoff summaries into fact_store, and ran even when
configured off.
Two compounding defects:
1. Gate: the plugin's config schema declares ``auto_extract`` as a string enum
(``"false"``/``"true"``), and the ``on_session_end`` gate used plain
truthiness — ``not "false"`` is ``False`` — so extraction ran despite being
explicitly configured off.
2. Eligibility: ``_auto_extract_facts`` scanned every ``role == "user"``
message. Compaction handoff summaries can be inserted as ``role="user"``
messages, and their prose reliably matches the decision patterns
(``we decided/agreed/chose``, ``the project uses/needs/requires``), so the
compactor's own output was stored as a durable ``project`` fact on every
session rollover that followed a compaction.
"""
import pytest
from agent.context_compressor import (
COMPRESSED_SUMMARY_METADATA_KEY,
SUMMARY_PREFIX,
_MERGED_PRIOR_CONTEXT_HEADER,
_MERGED_SUMMARY_DELIMITER,
is_compaction_summary_message,
)
from plugins.memory.holographic import HolographicMemoryProvider
def _make_provider(tmp_path, **config):
base = {"db_path": str(tmp_path / "memory_store.db"), "hrr_dim": 64}
base.update(config)
provider = HolographicMemoryProvider(config=base)
provider.initialize(session_id="test-session")
return provider
def _fact_contents(provider):
return [f["content"] for f in provider._store.list_facts(limit=100)]
def _user(content, **extra):
msg = {"role": "user", "content": content}
msg.update(extra)
return msg
DECISION_MSG = "we decided to use PostgreSQL for the persistence layer"
SUMMARY_MSG = (
f"{SUMMARY_PREFIX}\n## Historical Task Snapshot\n"
"The project uses a kanban board for all dispatch. "
"We agreed to route reviews through the fan-in consumer."
)
# ---------------------------------------------------------------------------
# Defect 1 — string-boolean gate
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("off_value", [False, "false", "False", "no", "off", "0", None, ""])
def test_auto_extract_off_values_disable_extraction(tmp_path, off_value):
provider = _make_provider(tmp_path, auto_extract=off_value)
provider.on_session_end([_user(DECISION_MSG)])
assert _fact_contents(provider) == []
provider.shutdown()
# ---------------------------------------------------------------------------
# Defect 2 — compaction summaries harvested as facts
# ---------------------------------------------------------------------------
def test_compaction_summary_not_harvested(tmp_path):
"""The exact failure mode from #57682: summary prose matches the decision
patterns but must not become a fact."""
provider = _make_provider(tmp_path, auto_extract=True)
provider.on_session_end([_user(SUMMARY_MSG)])
assert _fact_contents(provider) == []
provider.shutdown()
def test_metadata_marked_summary_not_harvested(tmp_path):
"""In-process summaries carry COMPRESSED_SUMMARY_METADATA_KEY even if a
future prefix rewrite changes the content sentinel."""
provider = _make_provider(tmp_path, auto_extract=True)
marked = _user(DECISION_MSG, **{COMPRESSED_SUMMARY_METADATA_KEY: True})
provider.on_session_end([marked])
assert _fact_contents(provider) == []
provider.shutdown()
def test_merged_into_tail_summary_suffix_not_harvested_prefix_content_ignored(tmp_path):
"""Merge-into-tail summaries embed the handoff prefix after the delimiter,
not at the start of the message. The wrapped pre-delimiter segment here has
no fact-pattern match, so nothing is harvested from either side."""
provider = _make_provider(tmp_path, auto_extract=True)
merged = _user(
f"{_MERGED_PRIOR_CONTEXT_HEADER}\nplease fix the login bug\n"
f"{_MERGED_SUMMARY_DELIMITER}\n{SUMMARY_MSG}"
)
provider.on_session_end([merged])
assert _fact_contents(provider) == []
provider.shutdown()
def test_real_user_messages_still_extracted_alongside_summary(tmp_path):
"""The guard must skip only the summary, not suppress extraction for the
genuine user turns around it."""
provider = _make_provider(tmp_path, auto_extract=True)
provider.on_session_end(
[
_user(SUMMARY_MSG),
_user("I prefer tabs over spaces for indentation"),
]
)
facts = _fact_contents(provider)
assert len(facts) == 1
assert "tabs over spaces" in facts[0]
provider.shutdown()
# ---------------------------------------------------------------------------
# is_compaction_summary_message — public helper contract
# ---------------------------------------------------------------------------
def test_helper_detects_prefix_metadata_and_merged_forms():
assert is_compaction_summary_message(_user(SUMMARY_MSG))
assert is_compaction_summary_message(
_user("anything", **{COMPRESSED_SUMMARY_METADATA_KEY: True})
)
assert is_compaction_summary_message(
_user(f"prior tail\n{_MERGED_SUMMARY_DELIMITER}\n{SUMMARY_MSG}")
)
assert not is_compaction_summary_message(_user(DECISION_MSG))
assert not is_compaction_summary_message(_user(""))
@@ -0,0 +1,240 @@
"""Tests for FactRetriever FTS5 query sanitization.
These tests cover the fix where raw natural-language queries passed to
FTS5 MATCH were AND-joined by default, dropping recall to zero on any
multi-word prose query. The sanitizer drops stopwords and OR-joins the
remaining content tokens as phrase literals.
"""
from __future__ import annotations
import pytest
pytest.importorskip("numpy") # retrieval module imports numpy indirectly
from plugins.memory.holographic.retrieval import FactRetriever
from plugins.memory.holographic.store import MemoryStore
# ---------------------------------------------------------------------------
# _sanitize_fts_query — unit tests (no DB required)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"query,expected_tokens",
[
# stopwords dropped
("what happened with the deployment rollback", {"happened", "deployment", "rollback"}),
# single content word passes through
("compaction", {"compaction"}),
# all stopwords → falls back to raw
("the and of", None), # None = sentinel for fallback-to-raw
# empty string → empty output
("", ""),
# FTS5 operator characters stripped
("context: length-probe", {"context", "lengthprobe"}),
# trailing punctuation stripped by tokenizer
("hello, world!", {"hello", "world"}),
],
)
def test_sanitize_fts_query_extracts_content_tokens(query, expected_tokens):
result = FactRetriever._sanitize_fts_query(query)
if expected_tokens == "":
assert result == ""
return
if expected_tokens is None:
# Pathological case: all stopwords — should fall back to raw query
assert result == query
return
# OR-joined phrase literals: `"tok1" OR "tok2" OR ...`
# Extract the tokens between quotes, order-independent.
import re
matches = re.findall(r'"([^"]+)"', result)
assert set(matches) == expected_tokens, f"got {result!r}"
# ---------------------------------------------------------------------------
# Integration test — actually run _fts_candidates against an in-memory DB
# ---------------------------------------------------------------------------
@pytest.fixture
def retriever_with_facts(tmp_path):
"""MemoryStore seeded with a few facts for retrieval tests."""
db_path = tmp_path / "test_facts.db"
store = MemoryStore(str(db_path))
store.add_fact(
content="The Thursday deployment rollback failed because of stale migration state.",
category="project",
)
store.add_fact(
content="Compaction settings tuned to 0.85 threshold.",
category="tool",
)
store.add_fact(
content="Venice.ai advertises availableContextTokens inside model_spec.",
category="tool",
)
retriever = FactRetriever(store=store)
yield retriever
store.close()
def test_prefetch_recovers_prose_query(retriever_with_facts):
"""A natural-language query should now match the relevant fact.
Before the sanitizer fix, 'what happened with the deployment rollback'
returned zero hits because FTS5 required every token to co-occur.
"""
results = retriever_with_facts.search(
"what happened with the deployment rollback"
)
assert len(results) >= 1
# The top hit should be the deployment rollback fact
assert "deployment rollback" in results[0]["content"].lower()
# ---------------------------------------------------------------------------
# Loop-invariant encode hoists (perf) — search/probe/related must encode
# constant vectors ONCE per call, not once per candidate/row.
# encode_text/encode_atom are deterministic (SHA-256 counter blocks), so the
# hoisted vectors are bit-identical to the per-iteration values they replace.
# ---------------------------------------------------------------------------
from plugins.memory.holographic import holographic as hrr
@pytest.fixture
def hoisted_retriever(tmp_path):
"""30 facts with HRR vectors, default dim (smaller dims trip an
inhomogeneous-shape edge in the fact encoder).
NOTE: a real tmp_path db, NOT ":memory:" — MemoryStore resolves the
path and shares one process-wide connection per file, so ":memory:"
becomes a literal ./:memory: file that leaks state across runs (and
the NULL-vector test below would permanently corrupt it)."""
store = MemoryStore(str(tmp_path / "hoist_store.db"))
for i in range(30):
store.add_fact(
content=f"deploy target {i} setting alpha beta gamma option {i % 7}",
category="fact" if i % 2 else "preference",
tags=f"entity_{i % 5} deploy",
)
retriever = FactRetriever(store=store)
yield retriever
store.close()
def _counting_spy(monkeypatch, attr):
calls = []
real = getattr(hrr, attr)
def wrapper(*args, **kwargs):
calls.append(args)
return real(*args, **kwargs)
monkeypatch.setattr(hrr, attr, wrapper)
return calls
def test_encode_functions_are_deterministic():
"""Soundness premise of the hoists: same input -> identical vector."""
import numpy as np
assert np.array_equal(hrr.encode_text("deploy target", 1024),
hrr.encode_text("deploy target", 1024))
assert np.array_equal(hrr.encode_atom("__hrr_role_content__", 1024),
hrr.encode_atom("__hrr_role_content__", 1024))
def test_search_encodes_query_vector_once(hoisted_retriever, monkeypatch):
calls = _counting_spy(monkeypatch, "encode_text")
results = hoisted_retriever.search("deploy target setting")
assert results # the HRR path actually engaged
assert len(calls) == 1, (
f"query vector encoded {len(calls)}x in one search() — "
"loop-invariant hoist regressed"
)
def test_search_results_bit_identical_to_unhoisted(hoisted_retriever):
"""Parity: hoisted search() must produce the exact pre-fix results.
Replicates the pre-fix loop (query vector encoded per candidate) as the
reference and compares full scored output for exact equality.
"""
r = hoisted_retriever
query = "deploy target setting"
new_results = r.search(query)
# --- pre-fix reference ---
candidates = r._fts_candidates(query, None, 0.3, 10 * 3)
query_tokens = r._tokenize(query)
scored = []
for fact in candidates:
content_tokens = r._tokenize(fact["content"])
tag_tokens = r._tokenize(fact.get("tags", ""))
all_tokens = content_tokens | tag_tokens
jaccard = r._jaccard_similarity(query_tokens, all_tokens)
fts_score = fact.get("fts_rank", 0.0)
if r.hrr_weight > 0 and fact.get("hrr_vector"):
fact_vec = hrr.bytes_to_phases(fact["hrr_vector"])
query_vec = hrr.encode_text(query, r.hrr_dim) # per-candidate
hrr_sim = (hrr.similarity(query_vec, fact_vec) + 1.0) / 2.0
else:
hrr_sim = 0.5
relevance = (r.fts_weight * fts_score
+ r.jaccard_weight * jaccard
+ r.hrr_weight * hrr_sim)
fact["score"] = relevance * fact["trust_score"]
scored.append(fact)
scored.sort(key=lambda x: x["score"], reverse=True)
old_results = scored[:10]
for fact in old_results:
fact.pop("hrr_vector", None)
assert new_results == old_results
def test_related_encodes_role_atoms_once(hoisted_retriever, monkeypatch):
calls = _counting_spy(monkeypatch, "encode_atom")
results = hoisted_retriever.related("entity_1")
assert results
role_calls = [a for a in calls
if a and str(a[0]).startswith("__hrr_role_")]
assert len(role_calls) == 2, (
f"role atoms encoded {len(role_calls)}x in one related() — "
"expected exactly 2 (role_entity + role_content, hoisted)"
)
def test_probe_encodes_role_atom_once(hoisted_retriever, monkeypatch):
calls = _counting_spy(monkeypatch, "encode_atom")
results = hoisted_retriever.probe("entity_1")
assert results
role_content_calls = [a for a in calls
if a and a[0] == "__hrr_role_content__"]
assert len(role_content_calls) == 1, (
f"role_content atom encoded {len(role_content_calls)}x in one "
"probe() — loop-invariant hoist regressed"
)
def test_search_without_vectors_never_encodes(hoisted_retriever, monkeypatch):
"""Migrated DBs can have FTS candidates with NULL hrr_vector
(MemoryStore._init_db adds the column without backfilling existing
facts). The lazy hoist must not encode a query vector nothing will
use — pre-fix main encoded only beneath fact.get('hrr_vector')."""
store = hoisted_retriever.store
store._conn.execute("UPDATE facts SET hrr_vector = NULL")
store._conn.commit()
calls = _counting_spy(monkeypatch, "encode_text")
results = hoisted_retriever.search("deploy target setting")
assert results # candidates exist; neutral hrr_sim=0.5 path
assert calls == [], (
f"encode_text called {len(calls)}x with zero vector candidates — "
"lazy hoist regressed to eager"
)
@@ -0,0 +1,130 @@
"""Regression test for #44037 — holographic provider leaked its SQLite
connection to GC on shutdown instead of closing it.
The corruption-mechanism framing in #44037 (TLS bytes written into the DB via
an fd-recycle race) was not reproducible from the code: dropping a sqlite
connection flushes valid pages through SQLite's own VFS, never TLS framing, and
the provider is at most a *releaser* of DB fds, not the TLS-flushing owner.
But the underlying resource-hygiene bug is real and is what this test pins:
``HolographicMemoryProvider.shutdown()`` must call ``MemoryStore.close()`` so
the ``check_same_thread=False`` connection's fd is released deterministically
on shutdown, rather than at a non-deterministic GC time on an arbitrary thread.
"""
import sqlite3
import pytest
from plugins.memory.holographic import HolographicMemoryProvider
def _make_provider(tmp_path):
db_path = str(tmp_path / "memory_store.db")
provider = HolographicMemoryProvider(config={"db_path": db_path, "hrr_dim": 64})
provider.initialize(session_id="test-session")
return provider
def test_shutdown_closes_store_connection(tmp_path):
provider = _make_provider(tmp_path)
store = provider._store
assert store is not None
conn = store._conn
# Connection is live before shutdown.
conn.execute("SELECT 1").fetchone()
provider.shutdown()
# References are dropped...
assert provider._store is None
assert provider._retriever is None
# ...AND the underlying connection was actually closed (not left to GC).
with pytest.raises(sqlite3.ProgrammingError):
conn.execute("SELECT 1")
def test_release_all_under_closes_connections_inside_directory_only(tmp_path):
"""Regression test for #88347 — profile delete must break refcounted handles.
``close()`` alone can never free a database held by a live agent (refs >= 1),
and on Windows an open SQLite handle makes rmtree of the profile directory
fail with WinError 32. ``release_all_under`` force-closes exactly the shared
connections under the doomed directory and leaves everything else alone.
"""
from plugins.memory.holographic.store import MemoryStore
profile_dir = tmp_path / "profiles" / "default-2"
profile_dir.mkdir(parents=True)
inside = MemoryStore(db_path=profile_dir / "memory_store.db", hrr_dim=64)
outside = MemoryStore(db_path=tmp_path / "other" / "memory_store.db", hrr_dim=64)
inside_conn = inside._conn
outside_conn = outside._conn
# Both live before the release; the inside one is held "forever" (refs=1,
# like a live agent's provider — nobody calls close()).
inside_conn.execute("SELECT 1").fetchone()
outside_conn.execute("SELECT 1").fetchone()
try:
released = MemoryStore.release_all_under(profile_dir)
assert released == 1
# The doomed connection is really closed despite refs > 0 ...
with pytest.raises(sqlite3.ProgrammingError):
inside_conn.execute("SELECT 1")
# ... and the registry entry is gone, so a second release finds
# nothing (the CLI-process no-op case).
assert str((profile_dir / "memory_store.db").resolve()) not in MemoryStore._shared
assert MemoryStore.release_all_under(profile_dir) == 0
# A new store on the same path reopens fresh instead of reusing
# the dead connection.
reopened = MemoryStore(db_path=profile_dir / "memory_store.db", hrr_dim=64)
reopened._conn.execute("SELECT 1").fetchone()
# The sibling outside the directory is untouched.
outside_conn.execute("SELECT 1").fetchone()
finally:
inside.close()
reopened.close()
outside.close()
def test_stale_holder_close_does_not_evict_fresh_registry_entry(tmp_path):
"""Follow-up to #88347 — a stale holder's late ``close()`` must be inert.
After ``release_all_under`` force-closes a profile's connection, a store
re-created on the same path registers a FRESH shared entry under the same
key. If the stale holder (whose entry was force-closed) then calls
``close()``, it must not pop the fresh entry out of the registry — that
would let a third store open a SECOND connection to the same database and
silently reintroduce the multi-writer contention the registry prevents.
"""
from plugins.memory.holographic.store import MemoryStore
profile_dir = tmp_path / "profiles" / "default-2"
profile_dir.mkdir(parents=True)
db_path = profile_dir / "memory_store.db"
stale = MemoryStore(db_path=db_path, hrr_dim=64)
assert MemoryStore.release_all_under(profile_dir) == 1
fresh = MemoryStore(db_path=db_path, hrr_dim=64)
key = fresh._key
fresh_entry = MemoryStore._shared[key]
# The stale holder's late close must leave the fresh entry registered...
stale.close()
assert MemoryStore._shared.get(key) is fresh_entry
# ...and a third store must attach to the SAME shared connection.
third = MemoryStore(db_path=db_path, hrr_dim=64)
try:
assert third._conn is fresh._conn
finally:
third.close()
fresh.close()
# Normal last-holder close still evicts its own entry.
assert key not in MemoryStore._shared
@@ -0,0 +1,227 @@
"""Tests for the holographic MemoryStore shared-connection registry.
MemoryStore instances pointing at the same database file must share one
process-wide SQLite connection and one re-entrant lock. Multiple providers
coexist in a single process (the main agent plus every delegate_task
subagent); when each instance owned a private connection they raced as
independent WAL writers and intermittently failed with "database is locked".
Covers: connection sharing/refcounting, close() semantics, cross-instance
visibility, concurrent multi-instance writers, and write-lock release after
a failed write.
"""
import sqlite3
import threading
import pytest
from plugins.memory.holographic.store import MemoryStore
@pytest.fixture(autouse=True)
def _clean_shared_registry():
"""Each test starts and ends with an empty shared-connection registry."""
# Drop any leakage from earlier tests in the same process.
for entry in list(MemoryStore._shared.values()):
try:
entry["conn"].close()
except sqlite3.Error:
pass
MemoryStore._shared.clear()
yield
leaked = list(MemoryStore._shared)
for entry in list(MemoryStore._shared.values()):
try:
entry["conn"].close()
except sqlite3.Error:
pass
MemoryStore._shared.clear()
assert not leaked, f"test leaked shared connections: {leaked}"
@pytest.fixture
def db_path(tmp_path):
return tmp_path / "memory_store.db"
class TestSharedConnection:
def test_same_path_shares_one_connection(self, db_path):
a = MemoryStore(db_path)
b = MemoryStore(db_path)
try:
assert a._conn is b._conn
assert a._lock is b._lock
assert len(MemoryStore._shared) == 1
assert MemoryStore._shared[str(a.db_path)]["refs"] == 2
finally:
a.close()
b.close()
def test_different_paths_get_distinct_connections(self, tmp_path):
a = MemoryStore(tmp_path / "one.db")
b = MemoryStore(tmp_path / "two.db")
try:
assert a._conn is not b._conn
assert len(MemoryStore._shared) == 2
finally:
a.close()
b.close()
def test_symlinked_path_shares_connection(self, tmp_path):
"""A symlink to the same DB file must hit the same registry entry —
otherwise two connections to one file silently reintroduce the
multi-writer contention the registry exists to prevent."""
real_dir = tmp_path / "real"
real_dir.mkdir()
link_dir = tmp_path / "link"
link_dir.symlink_to(real_dir)
a = MemoryStore(real_dir / "memory_store.db")
b = MemoryStore(link_dir / "memory_store.db")
try:
assert a._conn is b._conn
assert len(MemoryStore._shared) == 1
finally:
a.close()
b.close()
def test_writes_visible_across_instances(self, db_path):
a = MemoryStore(db_path)
b = MemoryStore(db_path)
try:
fact_id = a.add_fact("Hermes likes shared connections", category="test")
facts = b.list_facts(category="test")
assert [f["fact_id"] for f in facts] == [fact_id]
finally:
a.close()
b.close()
def test_schema_initialised_once_per_connection(self, db_path):
a = MemoryStore(db_path)
b = MemoryStore(db_path) # must not re-run schema init / WAL probe
try:
assert MemoryStore._shared[str(a.db_path)]["ready"] is True
b.add_fact("schema still works")
finally:
a.close()
b.close()
class TestCloseSemantics:
def test_closing_one_instance_keeps_sibling_alive(self, db_path):
a = MemoryStore(db_path)
b = MemoryStore(db_path)
a.close()
try:
# The shared connection must survive the sibling's close().
fact_id = b.add_fact("survivor write")
assert fact_id > 0
finally:
b.close()
def test_last_close_releases_connection(self, db_path):
a = MemoryStore(db_path)
b = MemoryStore(db_path)
conn = a._conn
a.close()
b.close()
assert MemoryStore._shared == {}
with pytest.raises(sqlite3.ProgrammingError):
conn.execute("SELECT 1")
def test_close_is_idempotent(self, db_path):
a = MemoryStore(db_path)
b = MemoryStore(db_path)
a.close()
a.close() # double close must not steal b's reference
try:
b.add_fact("still alive after double close")
assert MemoryStore._shared[str(b.db_path)]["refs"] == 1
finally:
b.close()
def test_context_manager_releases_reference(self, db_path):
with MemoryStore(db_path) as store:
store.add_fact("context managed")
assert MemoryStore._shared == {}
def test_reopen_after_full_close(self, db_path):
with MemoryStore(db_path) as store:
store.add_fact("first lifetime")
with MemoryStore(db_path) as store:
facts = store.list_facts()
assert [f["content"] for f in facts] == ["first lifetime"]
class TestConcurrency:
def test_concurrent_multi_instance_writers(self, db_path):
"""Many instances writing from many threads must never hit
'database is locked' — the failure mode of per-instance connections."""
n_threads, n_facts = 8, 15
errors: list[BaseException] = []
def writer(idx: int) -> None:
store = MemoryStore(db_path)
try:
for i in range(n_facts):
store.add_fact(f"fact thread={idx} seq={i}", category="load")
except BaseException as exc: # noqa: BLE001 - recorded for assert
errors.append(exc)
finally:
store.close()
threads = [threading.Thread(target=writer, args=(i,)) for i in range(n_threads)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors, f"concurrent writers failed: {errors[:3]}"
with MemoryStore(db_path) as store:
facts = store.list_facts(category="load", limit=500)
assert len(facts) == n_threads * n_facts
assert MemoryStore._shared == {}
def test_failed_write_does_not_pin_write_lock(self, db_path, monkeypatch):
"""A write that raises mid-method must not leave an open transaction
holding the SQLite write lock (autocommit isolation_level=None)."""
broken = MemoryStore(db_path)
sibling = MemoryStore(db_path)
try:
monkeypatch.setattr(
MemoryStore,
"_rebuild_bank",
lambda self, category: (_ for _ in ()).throw(RuntimeError("boom")),
)
with pytest.raises(RuntimeError, match="boom"):
broken.add_fact("write that fails after the INSERT")
monkeypatch.undo()
# No dangling transaction: the connection reports autocommit state
# and the sibling can write immediately.
assert broken._conn.in_transaction is False
sibling.add_fact("sibling write right after the failure")
finally:
broken.close()
sibling.close()
class TestProviderShutdown:
"""The provider's shutdown() must release its shared connection, not just
drop the reference. Leaving finalization to GC keeps the connection (and
its write lock) alive on a long-running gateway, which is exactly the
"database is locked" contention the shared-connection registry removes."""
def test_shutdown_releases_shared_connection(self, db_path):
from plugins.memory.holographic import HolographicMemoryProvider
provider = HolographicMemoryProvider(config={"db_path": str(db_path)})
provider.initialize("session-shutdown")
assert MemoryStore._shared[str(db_path)]["refs"] == 1
provider.shutdown()
assert provider._store is None
assert MemoryStore._shared == {}
@@ -0,0 +1,125 @@
"""Regression tests for #76414: `hermes honcho peers` showed "(not set)"
for every non-default profile.
_all_profile_host_configs() built the per-profile host key inline as
f"{HOST}.{profile}" ("hermes.work") while every other reader/writer —
profile_host_key(), resolve_active_host(), honcho status/enable/sync and
the runtime plugin — uses the underscore form ("hermes_work"). The lookup
always missed, so cmd_peers fell back to "(not set)" and leaked the raw
malformed key into the AI-peer column.
These tests drive the real cmd_peers / _all_profile_host_configs against
a real honcho.json (temp HERMES_HOME, no network).
"""
import io
import json
import sys
from types import SimpleNamespace
from unittest.mock import patch
import pytest
import plugins.memory.honcho.cli as honcho_cli
@pytest.fixture
def honcho_home(tmp_path, monkeypatch):
cfg = {
"peerName": "alice",
"hosts": {
"hermes": {"peerName": "alice", "aiPeer": "hermes"},
"hermes_work": {"peerName": "alice", "aiPeer": "hermes"},
"hermes_my_profile": {"peerName": "bob", "aiPeer": "hermes"},
},
}
path = tmp_path / "honcho.json"
path.write_text(json.dumps(cfg))
monkeypatch.setattr(honcho_cli, "_config_path", lambda: path)
return tmp_path
def _peers_output(profiles):
buf = io.StringIO()
old = sys.stdout
sys.stdout = buf
try:
honcho_cli.cmd_peers(SimpleNamespace())
finally:
sys.stdout = old
return buf.getvalue()
class TestAllProfileHostConfigs:
def test_profile_host_keys_match_writer_form(self, honcho_home, monkeypatch):
"""The lookup key must be profile_host_key()'s underscore form —
the same one honcho sync/enable/status and the runtime write to."""
monkeypatch.setattr(
"hermes_cli.profiles.list_profiles",
lambda: [SimpleNamespace(name="default"), SimpleNamespace(name="work")],
)
rows = honcho_cli._all_profile_host_configs()
by_name = {name: (host, block) for name, host, block in rows}
host, block = by_name["work"]
assert host == "hermes_work" # not "hermes.work"
assert block.get("peerName") == "alice" # the populated block was found
def test_sanitized_profile_names_resolve(self, honcho_home, monkeypatch):
"""Profiles needing sanitization (dots/spaces in the name) also
resolve — profile_host_key maps 'my.profile' -> 'hermes_my_profile';
the inline dot form never could."""
monkeypatch.setattr(
"hermes_cli.profiles.list_profiles",
lambda: [SimpleNamespace(name="default"),
SimpleNamespace(name="my.profile")],
)
rows = honcho_cli._all_profile_host_configs()
by_name = {name: block for name, _, block in rows}
assert by_name["my.profile"].get("peerName") == "bob"
def test_legacy_dot_form_host_key_still_readable(self, honcho_home, monkeypatch):
"""Back-compat: honcho.json files with LEGACY dot-form host keys
("hermes.work") must keep working — the README promises those keys
stay readable, and _host_block() exists precisely for that fallback.
A bare hosts.get(profile_host_key(...)) would regress them."""
path = honcho_home / "honcho.json"
cfg = json.loads(path.read_text())
del cfg["hosts"]["hermes_work"]
cfg["hosts"]["hermes.work"] = {"peerName": "carol", "aiPeer": "hermes"}
path.write_text(json.dumps(cfg))
monkeypatch.setattr(
"hermes_cli.profiles.list_profiles",
lambda: [SimpleNamespace(name="default"), SimpleNamespace(name="work")],
)
rows = honcho_cli._all_profile_host_configs()
by_name = {name: block for name, _, block in rows}
assert by_name["work"].get("peerName") == "carol"
class TestCmdPeers:
def test_peers_shows_populated_identity_not_host_key_leak(
self, honcho_home, monkeypatch):
"""Issue #76414's visible symptom: the AI-peer column showed the
raw malformed key 'hermes.work' (or '(not set)')."""
monkeypatch.setattr(
"hermes_cli.profiles.list_profiles",
lambda: [SimpleNamespace(name="default"), SimpleNamespace(name="work")],
)
out = _peers_output(SimpleNamespace())
assert "hermes.work" not in out
assert "(not set)" not in out
# work row shows the populated block's values
work_line = [l for l in out.splitlines() if l.strip().startswith("work")][0]
assert "alice" in work_line and "hermes" in work_line
def test_peers_falls_back_cleanly_when_block_missing(
self, honcho_home, monkeypatch):
"""A profile with no host block still falls back to the top-level
peerName and the (well-formed) host key — not a crash or a leak."""
monkeypatch.setattr(
"hermes_cli.profiles.list_profiles",
lambda: [SimpleNamespace(name="default"), SimpleNamespace(name="new")],
)
out = _peers_output(SimpleNamespace())
assert "hermes.new" not in out # well-formed key, no dot-form leak
new_line = [l for l in out.splitlines() if l.strip().startswith("new")][0]
assert "alice" in new_line # top-level peerName fallback
@@ -0,0 +1,90 @@
"""Tests for Honcho's declared config surface."""
from plugins.memory.config_schema import (
KIND_BOOL,
KIND_JSON,
KIND_NUMBER,
KIND_SECRET,
KIND_SELECT,
STORAGE_HONCHO_HOST_BLOCK,
get_provider_config_schema,
)
# The curated set shown in the compact panel; everything else lives in the modal.
INLINE_KEYS = {
"apiKey",
"baseUrl",
"environment",
"workspace",
"peerName",
"aiPeer",
"sessionStrategy",
}
def test_honcho_is_declared():
provider = get_provider_config_schema("honcho")
assert provider is not None
assert provider.label == "Honcho"
assert provider.storage == STORAGE_HONCHO_HOST_BLOCK
# Field keys are unique, and the curated inline set is present.
keys = [field.key for field in provider.fields]
assert len(keys) == len(set(keys))
assert INLINE_KEYS <= set(keys)
def test_inline_fields_are_the_curated_subset():
provider = get_provider_config_schema("honcho")
assert provider is not None
assert {field.key for field in provider.inline_fields()} == INLINE_KEYS
# The modal-only fields are a non-empty remainder.
non_inline = {f.key for f in provider.fields} - INLINE_KEYS
assert {"writeFrequency", "recallMode", "userPeerAliases"} <= non_inline
def test_declares_the_new_field_kinds():
provider = get_provider_config_schema("honcho")
assert provider is not None
by_key = {f.key: f for f in provider.fields}
assert by_key["saveMessages"].kind == KIND_BOOL
assert by_key["dialecticMaxChars"].kind == KIND_NUMBER
assert by_key["userPeerAliases"].kind == KIND_JSON
assert by_key["recallMode"].allowed_values() == {"hybrid", "context", "tools"}
assert by_key["observationMode"].allowed_values() == {"directional", "unified"}
def test_selects_constrain_their_values():
provider = get_provider_config_schema("honcho")
assert provider is not None
environment = next(f for f in provider.fields if f.key == "environment")
assert environment.kind == KIND_SELECT
# Honcho SDK only accepts local/production; "demo" is not a valid environment.
assert environment.allowed_values() == {"production", "local"}
strategy = next(f for f in provider.fields if f.key == "sessionStrategy")
assert strategy.allowed_values() == {"per-directory", "per-repo", "per-session", "global"}
def test_api_key_is_a_secret_bound_to_env():
provider = get_provider_config_schema("honcho")
assert provider is not None
api_key = next(f for f in provider.fields if f.key == "apiKey")
assert api_key.kind == KIND_SECRET
assert api_key.is_secret is True
assert api_key.env_key == "HONCHO_API_KEY"
def test_root_scoped_fields_are_exactly_the_global_ones():
provider = get_provider_config_schema("honcho")
assert provider is not None
scopes = {f.key: f.scope for f in provider.fields}
root_keys = {k for k, scope in scopes.items() if scope == "root"}
# baseUrl, timeout and sessions live at the config root in Honcho's schema;
# everything else is per-profile host-scoped.
assert root_keys == {"baseUrl", "timeout", "sessions"}
+680
View File
@@ -0,0 +1,680 @@
"""Tests for Mem0Backend abstraction — PlatformBackend, OSSBackend, SelfHostedBackend."""
import copy
import importlib
import json
import os
import sys
import types
from dataclasses import dataclass, field
from types import SimpleNamespace
import pytest
from plugins.memory.mem0._backend import (
Mem0Backend,
PlatformBackend,
OSSBackend,
SelfHostedBackend,
)
class FakePlatformClient:
"""Fake MemoryClient for PlatformBackend tests."""
def __init__(self):
self.calls = []
def search(self, query, **kwargs):
self.calls.append(("search", query, kwargs))
return {"results": [{"id": "m1", "memory": "fact1", "score": 0.9}]}
def get_all(self, **kwargs):
self.calls.append(("get_all", kwargs))
return {"count": 1, "next": None, "results": [{"id": "m1", "memory": "fact1"}]}
def add(self, messages, **kwargs):
self.calls.append(("add", messages, kwargs))
return {"status": "PENDING", "event_id": "evt-1"}
def update(self, **kwargs):
self.calls.append(("update", kwargs))
return {"id": kwargs["memory_id"], "text": kwargs["text"]}
def delete(self, **kwargs):
self.calls.append(("delete", kwargs))
class TestPlatformBackend:
def _make(self):
client = FakePlatformClient()
backend = PlatformBackend.__new__(PlatformBackend)
backend._client = client
return backend, client
def test_search_forwards_params(self):
backend, client = self._make()
result = backend.search("test query", filters={"user_id": "u1"}, top_k=5)
assert client.calls[0][0] == "search"
assert client.calls[0][1] == "test query"
assert client.calls[0][2]["filters"] == {"user_id": "u1"}
assert client.calls[0][2]["top_k"] == 5
def test_add_forwards_kwargs(self):
backend, client = self._make()
msgs = [{"role": "user", "content": "hi"}]
result = backend.add(msgs, user_id="u1", agent_id="hermes", infer=False)
call = client.calls[0]
assert call[2]["user_id"] == "u1"
assert call[2]["infer"] is False
# metadata kwarg should be omitted entirely when not provided so we
# don't surprise older mem0 client versions with an unknown kwarg.
assert "metadata" not in call[2]
def test_update_forwards(self):
backend, client = self._make()
backend.update("m1", "new text")
assert client.calls[0][1] == {"memory_id": "m1", "text": "new text"}
def test_delete_forwards(self):
backend, client = self._make()
backend.delete("m1")
assert client.calls[0][1] == {"memory_id": "m1"}
class FakeOSSMemory:
"""Fake mem0.Memory for OSSBackend tests."""
def __init__(self):
self.calls = []
def search(self, query, **kwargs):
self.calls.append(("search", query, kwargs))
return {"results": [{"id": "m1", "memory": "fact1", "score": 0.8}]}
def get_all(self, **kwargs):
self.calls.append(("get_all", kwargs))
return {"results": [{"id": "m1", "memory": "fact1"}]}
def add(self, messages, **kwargs):
self.calls.append(("add", messages, kwargs))
return {"results": [{"id": "m1", "memory": "fact1", "event": "ADD"}]}
def update(self, memory_id, **kwargs):
self.calls.append(("update", memory_id, kwargs))
return {"message": "Memory updated successfully!"}
def delete(self, memory_id):
self.calls.append(("delete", memory_id))
return {"message": "Memory deleted successfully!"}
@dataclass
class _FakeMem0State:
factory_registrations: list = field(default_factory=list)
from_config_calls: int = 0
clients: list = field(default_factory=list)
requests: list = field(default_factory=list)
def _install_fake_mem0(monkeypatch):
"""Install a small mem0 2.0.10-shaped surface for OSS backend tests."""
state = _FakeMem0State()
class BaseLlmConfig:
def __init__(
self,
model=None,
temperature=0.1,
api_key=None,
max_tokens=2000,
top_p=0.1,
top_k=1,
enable_vision=False,
vision_details="auto",
reasoning_effort=None,
http_client_proxies=None,
is_reasoning_model=None,
**kwargs,
):
self.model = model
self.temperature = temperature
self.api_key = api_key
self.max_tokens = max_tokens
self.top_p = top_p
self.top_k = top_k
self.enable_vision = enable_vision
self.vision_details = vision_details
self.reasoning_effort = reasoning_effort
self.http_client_proxies = http_client_proxies
self.is_reasoning_model = is_reasoning_model
for name, value in kwargs.items():
setattr(self, name, value)
class OpenAIConfig(BaseLlmConfig):
def __init__(
self,
model=None,
temperature=0.1,
api_key=None,
max_tokens=2000,
top_p=0.1,
top_k=1,
enable_vision=False,
vision_details="auto",
reasoning_effort=None,
http_client_proxies=None,
is_reasoning_model=None,
openai_base_url=None,
models=None,
route="fallback",
openrouter_base_url=None,
site_url=None,
app_name=None,
store=None,
response_callback=None,
):
super().__init__(
model=model,
temperature=temperature,
api_key=api_key,
max_tokens=max_tokens,
top_p=top_p,
top_k=top_k,
enable_vision=enable_vision,
vision_details=vision_details,
reasoning_effort=reasoning_effort,
http_client_proxies=http_client_proxies,
is_reasoning_model=is_reasoning_model,
)
self.openai_base_url = openai_base_url
self.models = models
self.route = route
self.openrouter_base_url = openrouter_base_url
self.site_url = site_url
self.app_name = app_name
self.store = store
self.response_callback = response_callback
class LLMBase:
def __init__(self, config=None):
self.config = config or BaseLlmConfig()
if not hasattr(self.config, "model"):
raise ValueError("Configuration must have a 'model' attribute")
def _get_supported_params(self, **kwargs):
if self.config.is_reasoning_model:
return {
name: kwargs[name]
for name in ("messages", "response_format", "tools", "tool_choice")
if name in kwargs
}
params = {
"temperature": self.config.temperature,
"top_p": self.config.top_p,
"max_tokens": self.config.max_tokens,
}
params.update(kwargs)
return params
class OpenAILLM(LLMBase):
@staticmethod
def _parse_response(response, tools):
if not tools:
return response.choices[0].message.content
parsed = {
"content": response.choices[0].message.content,
"tool_calls": [],
}
for tool_call in response.choices[0].message.tool_calls or []:
parsed["tool_calls"].append(
{
"name": tool_call.function.name,
"arguments": json.loads(tool_call.function.arguments),
}
)
return parsed
class Factory:
provider_to_class = {
"openai": ("mem0.llms.openai.OpenAILLM", OpenAIConfig),
"ollama": ("mem0.llms.openai.OpenAILLM", BaseLlmConfig),
}
@classmethod
def register_provider(cls, name, class_path, config_class=None):
cls.provider_to_class[name] = (
class_path,
config_class or BaseLlmConfig,
)
state.factory_registrations.append((name, class_path, config_class))
@classmethod
def create(cls, provider_name, config=None, **kwargs):
class_path, config_class = cls.provider_to_class[provider_name]
if config is None:
config = config_class(**kwargs)
elif isinstance(config, dict):
config = config_class(**config)
module_name, class_name = class_path.rsplit(".", 1)
llm_class = getattr(importlib.import_module(module_name), class_name)
return llm_class(config)
class MemoryConfig:
def __init__(self, **config):
llm = config["llm"]
if llm["provider"] not in {"openai", "ollama"}:
raise ValueError(
f"Unsupported LLM provider: {llm['provider']}"
)
self.llm = SimpleNamespace(
provider=llm["provider"],
config=copy.deepcopy(llm.get("config", {})),
)
embedder = config["embedder"]
self.embedder = SimpleNamespace(
provider=embedder["provider"],
config=copy.deepcopy(embedder.get("config", {})),
)
vector_store = config["vector_store"]
self.vector_store = SimpleNamespace(
provider=vector_store["provider"],
config=copy.deepcopy(vector_store.get("config", {})),
)
self.version = config.get("version", "v1.1")
class Memory:
instances = []
def __init__(self, config):
self.config = config
self.llm = Factory.create(config.llm.provider, config.llm.config)
self.embedding_model = SimpleNamespace(
provider=config.embedder.provider,
config=config.embedder.config,
)
self.vector_store = SimpleNamespace(
provider=config.vector_store.provider,
config=config.vector_store.config,
)
type(self).instances.append(self)
@classmethod
def from_config(cls, config):
# This mirrors mem0 2.0.10: validation rejects the private provider
# before the factory gets a chance to resolve its registration.
state.from_config_calls += 1
return cls(MemoryConfig(**config))
class FakeOpenAI:
def __init__(self, *, api_key, base_url):
self.api_key = api_key
self.base_url = base_url
state.clients.append(self)
self.chat = SimpleNamespace(
completions=SimpleNamespace(create=self._create)
)
def _create(self, **params):
state.requests.append(params)
return SimpleNamespace(
choices=[
SimpleNamespace(
message=SimpleNamespace(
content="direct answer",
tool_calls=[
SimpleNamespace(
function=SimpleNamespace(
name="remember",
arguments='{"fact": "tea"}',
)
)
],
)
)
]
)
package_names = {
"mem0": types.ModuleType("mem0"),
"mem0.configs": types.ModuleType("mem0.configs"),
"mem0.configs.llms": types.ModuleType("mem0.configs.llms"),
"mem0.llms": types.ModuleType("mem0.llms"),
"mem0.utils": types.ModuleType("mem0.utils"),
"mem0.configs.base": types.ModuleType("mem0.configs.base"),
"mem0.configs.llms.base": types.ModuleType("mem0.configs.llms.base"),
"mem0.configs.llms.openai": types.ModuleType("mem0.configs.llms.openai"),
"mem0.llms.base": types.ModuleType("mem0.llms.base"),
"mem0.llms.openai": types.ModuleType("mem0.llms.openai"),
"mem0.utils.factory": types.ModuleType("mem0.utils.factory"),
"openai": types.ModuleType("openai"),
}
setattr(package_names["mem0"], "Memory", Memory)
setattr(package_names["mem0.configs.base"], "MemoryConfig", MemoryConfig)
setattr(package_names["mem0.configs.llms.base"], "BaseLlmConfig", BaseLlmConfig)
setattr(package_names["mem0.configs.llms.openai"], "OpenAIConfig", OpenAIConfig)
setattr(package_names["mem0.llms.base"], "LLMBase", LLMBase)
setattr(package_names["mem0.llms.openai"], "OpenAILLM", OpenAILLM)
setattr(package_names["mem0.utils.factory"], "LlmFactory", Factory)
setattr(package_names["openai"], "OpenAI", FakeOpenAI)
for name, module in package_names.items():
if name in {"mem0", "mem0.configs", "mem0.configs.llms", "mem0.llms", "mem0.utils"}:
module.__path__ = []
monkeypatch.setitem(sys.modules, name, module)
# The class-path registration imports this module after the fake mem0
# surface is installed, so it binds to the test doubles above.
monkeypatch.delitem(
sys.modules, "plugins.memory.mem0._openai_llm", raising=False
)
return state, Memory, Factory
class TestOSSBackend:
def _make(self):
memory = FakeOSSMemory()
backend = OSSBackend.__new__(OSSBackend)
backend._memory = memory
return backend, memory
def test_legacy_api_base_aliases_are_normalized_before_mem0_init(self, monkeypatch):
state, Memory, factory = _install_fake_mem0(monkeypatch)
raw = {
"llm": {
"provider": "openai",
"config": {
"model": "gpt-5-mini",
"api_key": "openai-sentinel",
"api_base": "https://llm.example/v1",
},
},
"embedder": {
"provider": "ollama",
"config": {"model": "nomic-embed-text", "api_base": "http://ollama:11434"},
},
"vector_store": {"provider": "qdrant", "config": {}},
}
before = copy.deepcopy(raw)
environment = dict(os.environ)
OSSBackend(raw)
assert len(Memory.instances) == 1
captured = Memory.instances[0].config
assert captured.llm.provider == "hermes_openai"
assert captured.llm.config["openai_base_url"] == "https://llm.example/v1"
assert captured.embedder.provider == "ollama"
assert captured.embedder.config["ollama_base_url"] == "http://ollama:11434"
assert "api_base" not in captured.llm.config
assert "api_base" not in captured.embedder.config
assert factory.provider_to_class["hermes_openai"][1].__name__ == "OpenAIConfig"
assert len(state.factory_registrations) == 1
assert state.from_config_calls == 0
assert raw == before
assert dict(os.environ) == environment
def test_direct_openai_uses_openai_credentials_and_request_shape(self, monkeypatch):
state, _, factory = _install_fake_mem0(monkeypatch)
monkeypatch.setenv("OPENROUTER_API_KEY", "router-sentinel")
monkeypatch.setenv("OPENAI_API_KEY", "env-openai-sentinel")
module = importlib.import_module("plugins.memory.mem0._openai_llm")
callback_calls = []
config = factory.provider_to_class["openai"][1](
model="gpt-5-mini",
api_key="configured-openai-sentinel",
openai_base_url="https://openai.example/v1",
models=["router-model"],
route="lowest-latency",
site_url="https://hermes.example",
app_name="Hermes",
store=True,
response_callback=lambda *args: callback_calls.append(args),
)
adapter = module.DirectOpenAILLM(config)
assert adapter.config.is_reasoning_model is True
tools = [
{
"type": "function",
"function": {"name": "remember", "parameters": {}},
}
]
result = adapter.generate_response(
[{"role": "user", "content": "remember tea"}],
response_format={"type": "json_object"},
tools=tools,
tool_choice="required",
)
assert len(state.clients) == 1
client = state.clients[0]
assert client.api_key == "configured-openai-sentinel"
assert client.base_url == "https://openai.example/v1"
request = state.requests[0]
assert request["model"] == "gpt-5-mini"
assert request["tools"] == tools
assert request["tool_choice"] == "required"
assert request["response_format"] == {"type": "json_object"}
assert request["store"] is True
assert "models" not in request
assert "route" not in request
assert "extra_headers" not in request
assert "temperature" not in request
assert "top_p" not in request
assert "max_tokens" not in request
assert result == {
"content": "direct answer",
"tool_calls": [{"name": "remember", "arguments": {"fact": "tea"}}],
}
assert len(callback_calls) == 1
assert callback_calls[0][0] is adapter
assert callback_calls[0][2] == request
def test_direct_openai_preserves_explicit_non_reasoning_override(self, monkeypatch):
state, _, factory = _install_fake_mem0(monkeypatch)
config = factory.provider_to_class["openai"][1](
model="gpt-5-mini",
api_key="configured-openai-sentinel",
is_reasoning_model=False,
)
module = importlib.import_module("plugins.memory.mem0._openai_llm")
adapter = module.DirectOpenAILLM(config)
adapter.generate_response([{"role": "user", "content": "remember tea"}])
assert adapter.config.is_reasoning_model is False
request = state.requests[0]
assert request["temperature"] == 0.1
assert request["top_p"] == 0.1
assert request["max_tokens"] == 2000
def test_direct_openai_defaults_missing_model_to_reasoning_safe_mini(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "environment-openai-sentinel")
_install_fake_mem0(monkeypatch)
module = importlib.import_module("plugins.memory.mem0._openai_llm")
adapter = module.DirectOpenAILLM()
assert adapter.config.model == "gpt-5-mini"
assert adapter.config.is_reasoning_model is True
def test_direct_openai_uses_openai_environment_when_config_omits_values(self, monkeypatch):
state, _, factory = _install_fake_mem0(monkeypatch)
monkeypatch.setenv("OPENROUTER_API_KEY", "router-sentinel")
monkeypatch.setenv("OPENAI_API_KEY", "env-openai-sentinel")
monkeypatch.setenv("OPENAI_BASE_URL", "https://env-openai.example/v1")
module = importlib.import_module("plugins.memory.mem0._openai_llm")
config = factory.provider_to_class["openai"][1](model="gpt-5-mini")
adapter = module.DirectOpenAILLM(config)
assert len(state.clients) == 1
assert state.clients[0].api_key == "env-openai-sentinel"
assert state.clients[0].base_url == "https://env-openai.example/v1"
def test_missing_openai_key_fails_before_client_and_hides_router_secret(self, monkeypatch):
state, _, factory = _install_fake_mem0(monkeypatch)
router_secret = "router-secret-sentinel"
monkeypatch.setenv("OPENROUTER_API_KEY", router_secret)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
module = importlib.import_module("plugins.memory.mem0._openai_llm")
config = factory.provider_to_class["openai"][1](
model="gpt-5-mini",
api_key=None,
)
with pytest.raises(ValueError) as exc_info:
module.DirectOpenAILLM(config)
assert "OpenAI API key" in str(exc_info.value)
assert router_secret not in str(exc_info.value)
assert state.clients == []
assert state.requests == []
def test_registration_is_idempotent_and_clients_keep_instance_config(self, monkeypatch):
state, Memory, factory = _install_fake_mem0(monkeypatch)
first = {
"llm": {
"provider": "openai",
"config": {
"model": "gpt-5-mini",
"api_key": "first-openai-sentinel",
"openai_base_url": "https://first.example/v1",
},
},
"embedder": {"provider": "ollama", "config": {}},
"vector_store": {"provider": "qdrant", "config": {}},
}
second = {
"llm": {
"provider": "openai",
"config": {
"model": "gpt-5-mini",
"api_key": "second-openai-sentinel",
"openai_base_url": "https://second.example/v1",
},
},
"embedder": {"provider": "ollama", "config": {}},
"vector_store": {"provider": "qdrant", "config": {}},
}
first_before = copy.deepcopy(first)
second_before = copy.deepcopy(second)
OSSBackend(first)
OSSBackend(second)
assert len(state.factory_registrations) == 1
assert factory.provider_to_class["hermes_openai"][0].endswith(
"_openai_llm.DirectOpenAILLM"
)
assert [
(client.api_key, client.base_url) for client in state.clients
] == [
("first-openai-sentinel", "https://first.example/v1"),
("second-openai-sentinel", "https://second.example/v1"),
]
assert len(Memory.instances) == 2
assert state.from_config_calls == 0
assert first == first_before
assert second == second_before
def test_ollama_bypasses_direct_openai_adapter(self, monkeypatch):
state, Memory, factory = _install_fake_mem0(monkeypatch)
raw = {
"llm": {
"provider": "ollama",
"config": {
"model": "llama3.1:8b",
"api_base": "http://ollama:11434",
},
},
"embedder": {
"provider": "ollama",
"config": {
"model": "nomic-embed-text",
"api_base": "http://ollama:11434",
},
},
"vector_store": {"provider": "qdrant", "config": {}},
}
before = copy.deepcopy(raw)
OSSBackend(raw)
assert len(Memory.instances) == 1
assert state.from_config_calls == 1
assert Memory.instances[0].config.llm.provider == "ollama"
assert Memory.instances[0].config.embedder.provider == "ollama"
assert "hermes_openai" not in factory.provider_to_class
assert state.clients == []
assert raw == before
httpx = pytest.importorskip("httpx")
class _StubServer:
"""Records requests and serves the real self-hosted server's response shapes."""
def __init__(self, rows=10):
self.requests = []
self._rows = [{"id": f"m{i}", "memory": f"f{i}"} for i in range(rows)]
def handler(self, request):
self.requests.append(request)
path, method = request.url.path, request.method
if path == "/search" and method == "POST":
return httpx.Response(200, json={"results": [{"id": "m1", "memory": "tea", "score": 0.9}]})
if path == "/memories" and method == "GET":
top_k = int(request.url.params.get("top_k", len(self._rows)))
return httpx.Response(200, json={"results": self._rows[:top_k]})
if path == "/memories" and method == "POST":
return httpx.Response(200, json={"results": [{"id": "new", "memory": "stored", "event": "ADD"}]})
if path.startswith("/memories/") and method in ("PUT", "DELETE"):
if path.endswith("/missing"): # server 404s unknown ids
return httpx.Response(404, json={"detail": "Memory not found"})
verb = "updated" if method == "PUT" else "Memory deleted successfully"
return httpx.Response(200, json={"message": verb})
return httpx.Response(404, json={"detail": "not found"})
def _backend(server, api_key="adminkey", host="http://sh:8888"):
"""Build a SelfHostedBackend routed through the stub transport.
Uses the real __init__ (via the injectable ``transport`` kwarg) so the
constructor's header/base_url setup is exercised by every test here.
"""
return SelfHostedBackend(
api_key, host, transport=httpx.MockTransport(server.handler)
)
class TestSelfHostedBackend:
# --- constructor / auth setup (the crux of the bug) -------------------
def test_init_uses_x_api_key_not_token_auth(self):
b = SelfHostedBackend("adminkey", "http://sh:8888")
assert b._client.headers["x-api-key"] == "adminkey"
assert "authorization" not in b._client.headers # NOT the cloud 'Token' scheme
# --- search ----------------------------------------------------------
# --- add / update / delete ------------------------------------------
# --- error propagation (feeds the plugin's circuit breaker) ----------
def test_http_error_raises(self):
s = _StubServer()
with pytest.raises(httpx.HTTPStatusError):
_backend(s).delete("missing") # 404 -> raise_for_status; 'not found' won't trip breaker
@@ -0,0 +1,131 @@
"""Integration coverage for Hermes' pinned Mem0 OSS boundary."""
import copy
import os
from types import SimpleNamespace
import pytest
pytest.importorskip("mem0", reason="requires the existing mem0 extra")
def test_openai_backend_uses_real_mem0_config_and_factory(monkeypatch, tmp_path):
mem0_dir = tmp_path / "mem0"
monkeypatch.setenv("MEM0_DIR", str(mem0_dir))
monkeypatch.setenv("OPENAI_API_KEY", "environment-openai-sentinel")
monkeypatch.setenv("OPENROUTER_API_KEY", "router-sentinel")
import openai
from mem0.memory import main as memory_main
from mem0.utils.factory import LlmFactory
from plugins.memory.mem0._backend import OSSBackend
from plugins.memory.mem0._openai_llm import DirectOpenAILLM
clients = []
requests = []
class FakeOpenAI:
def __init__(self, *, api_key, base_url):
self.api_key = api_key
self.base_url = base_url
self.chat = SimpleNamespace(
completions=SimpleNamespace(create=self._create)
)
clients.append(self)
@staticmethod
def _create(**params):
requests.append(params)
return SimpleNamespace(
choices=[
SimpleNamespace(
message=SimpleNamespace(
content="direct answer",
tool_calls=None,
)
)
]
)
class DummyVectorStore:
pass
class DummyDB:
def __init__(self, _path):
pass
monkeypatch.setattr(
LlmFactory,
"provider_to_class",
dict(LlmFactory.provider_to_class),
)
monkeypatch.setattr(openai, "OpenAI", FakeOpenAI)
monkeypatch.setattr(
memory_main.EmbedderFactory,
"create",
lambda *_args, **_kwargs: object(),
)
monkeypatch.setattr(
memory_main.VectorStoreFactory,
"create",
lambda *_args, **_kwargs: DummyVectorStore(),
)
monkeypatch.setattr(memory_main, "SQLiteManager", DummyDB)
monkeypatch.setattr(memory_main, "MEM0_TELEMETRY", False)
monkeypatch.setattr(memory_main, "capture_event", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
OSSBackend,
"_recreate_collection_if_dims_changed",
staticmethod(lambda *_args, **_kwargs: None),
)
config = {
"llm": {
"provider": "openai",
"config": {
"model": "gpt-5-mini",
"api_key": "configured-openai-sentinel",
"openai_base_url": "https://openai.example/v1",
"models": ["router-model"],
"route": "lowest-latency",
},
},
"embedder": {
"provider": "ollama",
"config": {
"model": "nomic-embed-text",
"ollama_base_url": "http://ollama.example:11434",
"embedding_dims": 768,
},
},
"vector_store": {
"provider": "qdrant",
"config": {
"collection_name": "mem0",
"path": str(tmp_path / "qdrant"),
},
},
}
original_config = copy.deepcopy(config)
environment = dict(os.environ)
backend = OSSBackend(config)
result = backend._memory.llm.generate_response(
[{"role": "user", "content": "remember tea"}]
)
assert isinstance(backend._memory.llm, DirectOpenAILLM)
assert len(clients) == 1
assert clients[0].api_key == "configured-openai-sentinel"
assert clients[0].base_url == "https://openai.example/v1"
assert requests == [
{
"model": "gpt-5-mini",
"messages": [{"role": "user", "content": "remember tea"}],
}
]
assert result == "direct answer"
assert config == original_config
assert dict(os.environ) == environment
@@ -0,0 +1,78 @@
"""Tests for OSS provider definitions and validation."""
import pytest
from plugins.memory.mem0._oss_providers import (
LLM_PROVIDERS,
EMBEDDER_PROVIDERS,
VECTOR_PROVIDERS,
KNOWN_DIMS,
validate_oss_config,
)
class TestProviderDefinitions:
def test_llm_providers_have_required_keys(self):
for pid, p in LLM_PROVIDERS.items():
assert "label" in p
assert "needs_key" in p
assert "default_model" in p
def test_embedder_providers_have_required_keys(self):
for pid, p in EMBEDDER_PROVIDERS.items():
assert "label" in p
assert "needs_key" in p
assert "default_model" in p
assert "dims" in p
def test_vector_providers_have_required_keys(self):
for pid, p in VECTOR_PROVIDERS.items():
assert "label" in p
assert "default_config" in p
def test_known_dims_covers_defaults(self):
for pid, p in EMBEDDER_PROVIDERS.items():
assert p["default_model"] in KNOWN_DIMS
class TestValidation:
def test_valid_openai_config(self):
cfg = {
"llm": {"provider": "openai", "config": {"model": "gpt-4o-mini"}},
"embedder": {"provider": "openai", "config": {"model": "text-embedding-3-small"}},
"vector_store": {"provider": "qdrant", "config": {"path": "/tmp/test"}},
}
errors = validate_oss_config(cfg)
assert errors == []
def test_unknown_llm_provider(self):
cfg = {
"llm": {"provider": "gemini", "config": {}},
"embedder": {"provider": "openai", "config": {}},
"vector_store": {"provider": "qdrant", "config": {}},
}
errors = validate_oss_config(cfg)
assert any("llm" in e.lower() for e in errors)
def test_missing_llm_section(self):
cfg = {
"embedder": {"provider": "openai", "config": {}},
"vector_store": {"provider": "qdrant", "config": {}},
}
errors = validate_oss_config(cfg)
assert any("llm" in e.lower() for e in errors)
def test_pgvector_needs_user(self):
cfg = {
"llm": {"provider": "openai", "config": {}},
"embedder": {"provider": "openai", "config": {}},
"vector_store": {"provider": "pgvector", "config": {"host": "localhost"}},
}
errors = validate_oss_config(cfg)
assert any("user" in e.lower() for e in errors)
+255
View File
@@ -0,0 +1,255 @@
"""Tests for Mem0 setup wizard — flag parsing, config building, validation."""
import json
import sys
import types
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock
from plugins.memory.mem0._setup import (
parse_flags,
build_oss_config,
_write_env,
_prompt_api_key,
post_setup,
_check_qdrant_path,
_check_ollama,
_check_pgvector,
)
def _inject_fake_hermes_cli(monkeypatch):
"""Inject fake hermes_cli modules so yaml/curses aren't required."""
fake_config_mod = types.ModuleType("hermes_cli.config")
fake_config_mod.save_config = lambda c: None
fake_setup_mod = types.ModuleType("hermes_cli.memory_setup")
fake_setup_mod._curses_select = lambda *a, **kw: 0
fake_setup_mod._prompt = lambda label, default=None, secret=False: default or ""
fake_hermes_cli = types.ModuleType("hermes_cli")
fake_hermes_cli.config = fake_config_mod
fake_hermes_cli.memory_setup = fake_setup_mod
monkeypatch.setitem(sys.modules, "hermes_cli", fake_hermes_cli)
monkeypatch.setitem(sys.modules, "hermes_cli.config", fake_config_mod)
monkeypatch.setitem(sys.modules, "hermes_cli.memory_setup", fake_setup_mod)
monkeypatch.setattr("plugins.memory.mem0._setup._curses_select", lambda *a, **kw: 0)
monkeypatch.setattr("plugins.memory.mem0._setup._prompt", lambda label, default=None, secret=False: default or "")
return fake_config_mod
class TestParseFlags:
def test_mode_platform(self):
flags = parse_flags(["--mode", "platform", "--api-key", "sk-test"])
assert flags["mode"] == "platform"
assert flags["api_key"] == "sk-test"
def test_no_flags_returns_empty_mode(self):
flags = parse_flags([])
assert flags["mode"] == ""
def test_oss_vector_path_flag(self):
flags = parse_flags(["--mode", "oss", "--oss-vector-path", "/data/qdrant"])
assert flags["oss_vector_path"] == "/data/qdrant"
class TestBuildOSSConfig:
def test_openai_defaults(self):
flags = parse_flags(["--mode", "oss", "--oss-llm-key", "sk-oai"])
oss, env_writes = build_oss_config(flags)
assert oss["llm"]["provider"] == "openai"
assert oss["llm"]["config"]["model"] == "gpt-5-mini"
assert oss["llm"]["config"]["is_reasoning_model"] is True
assert oss["embedder"]["provider"] == "openai"
assert oss["embedder"]["config"]["model"] == "text-embedding-3-small"
assert oss["vector_store"]["provider"] == "qdrant"
assert env_writes["OPENAI_API_KEY"] == "sk-oai"
def test_explicit_gpt_5_mini_is_reasoning_model(self):
flags = parse_flags([
"--mode", "oss", "--oss-llm-key", "sk-oai",
"--oss-llm-model", "gpt-5-mini",
])
oss, _ = build_oss_config(flags)
assert oss["llm"]["config"]["model"] == "gpt-5-mini"
assert oss["llm"]["config"]["is_reasoning_model"] is True
def test_custom_openai_model_is_not_forced_to_reasoning(self):
flags = parse_flags([
"--mode", "oss", "--oss-llm-key", "sk-oai",
"--oss-llm-model", "gpt-5.2",
])
oss, _ = build_oss_config(flags)
assert oss["llm"]["config"]["model"] == "gpt-5.2"
assert "is_reasoning_model" not in oss["llm"]["config"]
def test_ollama_no_key_needed(self):
flags = parse_flags(["--mode", "oss", "--oss-llm", "ollama", "--oss-embedder", "ollama"])
oss, env_writes = build_oss_config(flags)
assert oss["llm"]["provider"] == "ollama"
assert "model" in oss["llm"]["config"]
assert oss["llm"]["config"]["ollama_base_url"] == "http://localhost:11434"
assert "is_reasoning_model" not in oss["llm"]["config"]
assert oss["embedder"]["config"]["ollama_base_url"] == "http://localhost:11434"
assert env_writes == {}
def test_embedder_reuses_llm_key(self):
"""When LLM and embedder share same provider, key written once."""
flags = parse_flags(["--mode", "oss", "--oss-llm-key", "sk-oai"])
_, env_writes = build_oss_config(flags)
assert env_writes == {"OPENAI_API_KEY": "sk-oai"}
def test_different_embedder_needs_separate_key(self):
flags = parse_flags([
"--mode", "oss",
"--oss-llm", "ollama",
"--oss-embedder", "openai", "--oss-embedder-key", "sk-oai",
])
_, env_writes = build_oss_config(flags)
assert env_writes == {"OPENAI_API_KEY": "sk-oai"}
def test_pgvector_config(self):
flags = parse_flags([
"--mode", "oss", "--oss-llm-key", "sk-oai",
"--oss-vector", "pgvector",
"--oss-vector-host", "db.local", "--oss-vector-port", "5433",
"--oss-vector-user", "pg", "--oss-vector-dbname", "memdb",
])
oss, _ = build_oss_config(flags)
vs = oss["vector_store"]
assert vs["provider"] == "pgvector"
assert vs["config"]["host"] == "db.local"
assert vs["config"]["port"] == 5433
assert vs["config"]["user"] == "pg"
def test_known_dims_auto_set(self):
flags = parse_flags(["--mode", "oss", "--oss-llm-key", "sk-oai"])
oss, _ = build_oss_config(flags)
dims = oss["embedder"]["config"].get("embedding_dims")
assert dims == 1536
def test_custom_qdrant_path(self):
flags = parse_flags([
"--mode", "oss", "--oss-llm-key", "sk-oai",
"--oss-vector-path", "/data/qdrant",
])
oss, _ = build_oss_config(flags)
assert oss["vector_store"]["config"]["path"] == "/data/qdrant"
class TestWriteEnv:
def test_write_new_vars(self, tmp_path):
env_path = tmp_path / ".env"
_write_env(env_path, {"OPENAI_API_KEY": "sk-test"})
content = env_path.read_text()
assert "OPENAI_API_KEY=sk-test" in content
def test_update_existing_var(self, tmp_path):
env_path = tmp_path / ".env"
env_path.write_text("OPENAI_API_KEY=old\nOTHER=keep\n")
_write_env(env_path, {"OPENAI_API_KEY": "new"})
content = env_path.read_text()
assert "OPENAI_API_KEY=new" in content
assert "OTHER=keep" in content
assert "old" not in content
def test_preserves_non_ascii_existing_lines(self, tmp_path):
"""Existing non-ASCII .env content must survive the read-modify-write
as UTF-8 (the locale codec would crash/mangle it on Windows)."""
env_path = tmp_path / ".env"
env_path.write_bytes("PROXY_NOTE=café-zürich-完了\n".encode("utf-8"))
_write_env(env_path, {"OPENAI_API_KEY": "sk-test"})
content = env_path.read_text(encoding="utf-8")
assert "PROXY_NOTE=café-zürich-完了" in content
assert "OPENAI_API_KEY=sk-test" in content
def test_updates_first_key_with_bom(self, tmp_path):
"""A Notepad-edited .env carries a BOM; the first key must still be
matched/updated in place, not duplicated."""
env_path = tmp_path / ".env"
env_path.write_bytes("OPENAI_API_KEY=old\n".encode("utf-8"))
_write_env(env_path, {"OPENAI_API_KEY": "new"})
content = env_path.read_text(encoding="utf-8")
assert content.count("OPENAI_API_KEY=") == 1
assert "OPENAI_API_KEY=new" in content
class TestPromptApiKey:
def test_existing_key_found_behind_bom(self, tmp_path, monkeypatch):
"""The masked-current-value lookup must see a key on the BOM'd first
line of a Notepad-edited .env instead of prompting from scratch."""
env_path = tmp_path / ".env"
env_path.write_bytes("OPENAI_API_KEY=sk-existing\n".encode("utf-8"))
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
prompts: list[str] = []
def _fake_getpass(prompt):
prompts.append(prompt)
return ""
monkeypatch.setattr("plugins.memory.mem0._setup.getpass.getpass", _fake_getpass)
_prompt_api_key("OpenAI", "OPENAI_API_KEY", str(tmp_path))
assert len(prompts) == 1
assert "current: ...ting" in prompts[0]
class TestPostSetup:
def test_platform_flag_mode(self, tmp_path, monkeypatch):
monkeypatch.setattr("sys.argv", ["hermes", "--mode", "platform", "--api-key", "sk-test"])
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
_inject_fake_hermes_cli(monkeypatch)
config = {"memory": {}}
post_setup(str(tmp_path), config)
assert config["memory"]["provider"] == "mem0"
env_content = (tmp_path / ".env").read_text()
assert "MEM0_API_KEY=sk-test" in env_content
mem0_json = json.loads((tmp_path / "mem0.json").read_text())
assert mem0_json["mode"] == "platform"
def test_selfhosted_flag_mode(self, tmp_path, monkeypatch):
monkeypatch.setattr("sys.argv", [
"hermes", "--mode", "selfhosted",
"--host", "http://localhost:8888/", "--api-key", "admin-key",
])
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
_inject_fake_hermes_cli(monkeypatch)
monkeypatch.setattr("plugins.memory.mem0._setup._check_selfhosted_server", lambda h: None)
config = {"memory": {}}
post_setup(str(tmp_path), config)
assert config["memory"]["provider"] == "mem0"
env_content = (tmp_path / ".env").read_text()
assert "MEM0_API_KEY=admin-key" in env_content
mem0_json = json.loads((tmp_path / "mem0.json").read_text())
assert mem0_json["host"] == "http://localhost:8888" # trailing slash stripped
assert mem0_json["user_id"] == "hermes-user"
class TestDryRun:
def test_dry_run_flag_parsed(self):
flags = parse_flags(["--mode", "oss", "--oss-llm-key", "sk-oai", "--dry-run"])
assert flags["dry_run"] is True
class TestConnectivityChecks:
def test_qdrant_path_writable(self, tmp_path):
ok, msg = _check_qdrant_path(str(tmp_path / "qdrant"))
assert ok is True
+411
View File
@@ -0,0 +1,411 @@
"""Tests for Mem0 v3 API — new tool names, paginated responses, update/delete tools."""
import json
import threading
import time
import pytest
import plugins.memory.mem0 as mem0_plugin
from plugins.memory.mem0 import Mem0MemoryProvider
class FakeBackend:
"""Fake Mem0Backend for provider-level tests."""
def __init__(self, search_results=None, all_results=None):
self._search_results = search_results or []
self._all_results = all_results or {"results": [], "count": 0}
self.captured = []
def search(self, query, *, filters, top_k=10, rerank=True):
self.captured.append(("search", query, {"filters": filters, "top_k": top_k, "rerank": rerank}))
return self._search_results
def get_all(self, *, filters, page=1, page_size=100):
self.captured.append(("get_all", {"filters": filters, "page": page, "page_size": page_size}))
return self._all_results
def add(self, messages, *, user_id, agent_id, infer=False, metadata=None):
self.captured.append((
"add",
messages,
{"user_id": user_id, "agent_id": agent_id, "infer": infer, "metadata": metadata},
))
return {"status": "PENDING", "event_id": "evt-test-123"}
def update(self, memory_id, text):
self.captured.append(("update", memory_id, text))
return {"result": "Memory updated.", "memory_id": memory_id}
def delete(self, memory_id):
self.captured.append(("delete", memory_id))
return {"result": "Memory deleted.", "memory_id": memory_id}
class TestMem0V3Tools:
"""Test v3 tool names and response handling."""
def _make_provider(self, monkeypatch, backend):
provider = Mem0MemoryProvider()
provider.initialize("test-session")
provider._user_id = "u123"
provider._agent_id = "hermes"
provider._backend = backend
return provider
def test_search_returns_ids(self, monkeypatch):
backend = FakeBackend(search_results=[{"id": "mem-1", "memory": "foo", "score": 0.9}])
provider = self._make_provider(monkeypatch, backend)
result = json.loads(provider.handle_tool_call("mem0_search", {"query": "test"}))
assert result["results"][0]["id"] == "mem-1"
def test_add_uses_content_param(self, monkeypatch):
backend = FakeBackend()
provider = self._make_provider(monkeypatch, backend)
result = json.loads(provider.handle_tool_call("mem0_add", {"content": "user likes dark mode"}))
assert len(backend.captured) == 1
call = backend.captured[0]
assert call[2]["infer"] is False
assert call[2]["user_id"] == "u123"
assert call[2]["agent_id"] == "hermes"
assert "event_id" in result
def test_old_tool_names_return_unknown(self, monkeypatch):
backend = FakeBackend()
provider = self._make_provider(monkeypatch, backend)
result = json.loads(provider.handle_tool_call("mem0_profile", {}))
assert "error" in result
result = json.loads(provider.handle_tool_call("mem0_conclude", {}))
assert "error" in result
class TestMem0UpdateDelete:
def _make_provider(self, monkeypatch, backend):
provider = Mem0MemoryProvider()
provider.initialize("test-session")
provider._user_id = "u123"
provider._agent_id = "hermes"
provider._backend = backend
return provider
def test_update_calls_sdk(self, monkeypatch):
backend = FakeBackend()
provider = self._make_provider(monkeypatch, backend)
result = json.loads(provider.handle_tool_call(
"mem0_update", {"memory_id": "mem-1", "text": "updated fact"}
))
assert backend.captured[0][1] == "mem-1"
assert backend.captured[0][2] == "updated fact"
assert result["result"] == "Memory updated."
assert result["memory_id"] == "mem-1"
def test_delete_calls_sdk(self, monkeypatch):
backend = FakeBackend()
provider = self._make_provider(monkeypatch, backend)
result = json.loads(provider.handle_tool_call(
"mem0_delete", {"memory_id": "mem-1"}
))
assert backend.captured[0][1] == "mem-1"
assert result["result"] == "Memory deleted."
class TestMem0ErrorHandling:
def _make_provider(self, monkeypatch, backend):
provider = Mem0MemoryProvider()
provider.initialize("test-session")
provider._user_id = "u123"
provider._agent_id = "hermes"
provider._backend = backend
return provider
class TestMem0V3Internal:
def _make_provider(self, monkeypatch, backend):
provider = Mem0MemoryProvider()
provider.initialize("test-session")
provider._user_id = "u123"
provider._agent_id = "hermes"
provider._backend = backend
return provider
def test_sync_turn_explicit_kwargs(self, monkeypatch):
backend = FakeBackend()
provider = self._make_provider(monkeypatch, backend)
provider.sync_turn("user said", "assistant replied", session_id="s1")
provider._sync_thread.join(timeout=2)
assert len(backend.captured) == 1
call = backend.captured[0]
assert call[2]["user_id"] == "u123"
assert call[2]["agent_id"] == "hermes"
assert call[2]["infer"] is True
class TestMem0Prefetch:
"""prefetch() must recall on the CURRENT question, synchronously.
The old implementation ignored its ``query`` and returned whatever a
background ``queue_prefetch`` had warmed from the PREVIOUS turn — so the
first turn injected nothing and later turns injected stale, off-topic
memories. These lock the corrected behaviour.
"""
def _make_provider(self, backend):
provider = Mem0MemoryProvider()
provider.initialize("test-session")
provider._user_id = "u123"
provider._agent_id = "hermes"
provider._backend = backend
return provider
def test_prefetch_searches_current_query(self):
backend = FakeBackend(search_results=[{"id": "m1", "memory": "user prefers dark mode"}])
provider = self._make_provider(backend)
result = provider.prefetch("what theme do I like?")
kind, query, opts = backend.captured[0]
assert kind == "search"
assert query == "what theme do I like?"
assert opts["filters"] == {"user_id": "u123"}
assert opts["top_k"] == 10
assert opts["rerank"] is False
assert "## Mem0 Memory" in result
assert "user prefers dark mode" in result
def test_on_turn_start_queues_current_query(self):
backend = FakeBackend(search_results=[{"id": "m1", "memory": "lives in Berlin"}])
provider = self._make_provider(backend)
provider.on_turn_start(1, "where do I live?")
provider._prefetch_thread.join(timeout=1)
result = provider.prefetch("where do I live?")
assert "lives in Berlin" in result
assert len([c for c in backend.captured if c[0] == "search"]) == 1
def test_slow_prefetch_returns_quickly(self, monkeypatch):
entered = threading.Event()
release = threading.Event()
search_returned = threading.Event()
class SlowBackend(FakeBackend):
def search(self, query, *, filters, top_k=10, rerank=True):
entered.set()
try:
release.wait(30)
return super().search(
query, filters=filters, top_k=top_k, rerank=rerank
)
finally:
search_returned.set()
monkeypatch.setattr(mem0_plugin, "_PREFETCH_WAIT_SECS", 0.01)
provider = self._make_provider(
SlowBackend(search_results=[{"id": "m1", "memory": "lives in Berlin"}])
)
# DETERMINISTIC non-blocking witness — replaces `assert elapsed < 0.1`.
#
# The old form slept 0.2s in the backend and asserted prefetch returned
# in under 0.1s. That makes the OS scheduler part of the assertion: on
# a loaded box thread startup alone can eat the 100ms budget, so the
# inequality flips with nothing wrong in the code under test. Observed
# failing in a full-directory run of tests/plugins/memory.
#
# The real contract is that prefetch gives up on the slow backend
# instead of waiting for it. Assert it directly: the backend search is
# STILL PARKED (release unset, so `search_returned` cannot be set). If
# prefetch ever waited for the backend, the search would have returned
# first and this fails. No wall-clock constant.
assert provider.prefetch("where do I live?") == ""
assert entered.wait(30), "prefetch never reached the backend"
assert not search_returned.is_set(), (
"prefetch blocked on the slow backend: the backend search had "
"already returned by the time prefetch did"
)
release.set()
provider._prefetch_thread.join(timeout=30)
assert "lives in Berlin" in provider.prefetch("where do I live?")
def test_queue_prefetch_fires_no_search(self):
# prefetch is synchronous now, so the post-turn warm is redundant and
# must not fire a wasted backend search.
backend = FakeBackend(search_results=[{"id": "m1", "memory": "x"}])
provider = self._make_provider(backend)
provider.queue_prefetch("previous turn text")
assert backend.captured == []
class TestMem0V3Config:
def test_tool_schemas_four_tools(self):
provider = Mem0MemoryProvider()
schemas = provider.get_tool_schemas()
names = [s["name"] for s in schemas]
assert names == ["mem0_search", "mem0_add", "mem0_update", "mem0_delete"]
def test_system_prompt_new_tool_names(self):
provider = Mem0MemoryProvider()
provider._user_id = "test"
block = provider.system_prompt_block()
assert "mem0_search" in block
assert "mem0_add" in block
assert "mem0_update" in block
assert "mem0_delete" in block
assert "mem0_list" not in block
assert "mem0_profile" not in block
assert "mem0_conclude" not in block
class TestMem0ModeSwitch:
def test_default_mode_is_platform(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("MEM0_API_KEY", "test-key")
provider = Mem0MemoryProvider()
provider.initialize("test")
assert provider._mode == "platform"
def test_missing_mode_key_defaults_platform(self, monkeypatch, tmp_path):
"""Backward compat: old mem0.json without mode key works."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
config_path = tmp_path / "mem0.json"
config_path.write_text('{"user_id": "old-user"}')
monkeypatch.setenv("MEM0_API_KEY", "test-key")
provider = Mem0MemoryProvider()
provider.initialize("test")
assert provider._mode == "platform"
assert provider._user_id == "old-user"
def test_is_available_platform_needs_key(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("MEM0_API_KEY", raising=False)
provider = Mem0MemoryProvider()
assert provider.is_available() is False
class TestMem0UserIdResolution:
"""user_id resolution: configured override > gateway-native id > placeholder.
Same human across CLI / Telegram / Discord / Slack / etc. should map to
the same memory store when MEM0_USER_ID is set, and only fall back to the
gateway-native id when it isn't.
"""
def _provider(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("MEM0_API_KEY", "test-key")
provider = Mem0MemoryProvider()
# Skip backend instantiation — we only care about identity resolution.
provider._create_backend = lambda: None # type: ignore[method-assign]
return provider
def test_env_override_beats_gateway_native_id(self, monkeypatch, tmp_path):
monkeypatch.setenv("MEM0_USER_ID", "ryan@example.com")
provider = self._provider(monkeypatch, tmp_path)
provider.initialize("test", user_id="123456789", platform="telegram")
assert provider._user_id == "ryan@example.com"
def test_file_override_beats_gateway_native_id(self, monkeypatch, tmp_path):
monkeypatch.delenv("MEM0_USER_ID", raising=False)
(tmp_path / "mem0.json").write_text('{"user_id": "ryan@example.com"}')
provider = self._provider(monkeypatch, tmp_path)
provider.initialize("test", user_id="123456789", platform="telegram")
assert provider._user_id == "ryan@example.com"
def test_unset_falls_back_to_gateway_native_id(self, monkeypatch, tmp_path):
monkeypatch.delenv("MEM0_USER_ID", raising=False)
provider = self._provider(monkeypatch, tmp_path)
provider.initialize("test", user_id="123456789", platform="telegram")
assert provider._user_id == "123456789"
def test_legacy_placeholder_in_config_does_not_override_kwargs(self, monkeypatch, tmp_path):
# Setup wizard historically wrote {"user_id": "hermes-user"} as the
# suggested default. Treat that placeholder as unset so users on
# gateways still get gateway-native ids — not silent collisions.
monkeypatch.delenv("MEM0_USER_ID", raising=False)
(tmp_path / "mem0.json").write_text('{"user_id": "hermes-user"}')
provider = self._provider(monkeypatch, tmp_path)
provider.initialize("test", user_id="123456789", platform="telegram")
assert provider._user_id == "123456789"
class TestMem0WriteMetadata:
"""Writes carry metadata.channel so per-channel filtered views are possible
without coupling identity to the channel.
"""
def _make_provider(self, channel: str = "cli"):
provider = Mem0MemoryProvider()
provider._user_id = "u123"
provider._agent_id = "hermes"
provider._channel = channel
provider._backend = FakeBackend()
return provider
class _SentinelBackend:
def __init__(self, *args):
self.args = args
class TestCreateBackendRouting:
"""_create_backend() must pick the backend matching the configured mode/host."""
def _provider(self, monkeypatch, *, mode="platform", api_key="k", host=""):
# Neutralize lazy-install so the routing decision is all we exercise.
monkeypatch.setattr("tools.lazy_deps.ensure", lambda *a, **k: None, raising=False)
provider = Mem0MemoryProvider()
provider._mode = mode
provider._api_key = api_key
provider._host = host
provider._config = {"oss": {"vector_store": {"provider": "qdrant"}}}
return provider
def test_routes_to_selfhosted_when_host_set(self, monkeypatch):
captured = {}
class SH(_SentinelBackend):
def __init__(self, api_key, host):
captured["args"] = (api_key, host)
monkeypatch.setattr("plugins.memory.mem0._backend.SelfHostedBackend", SH)
provider = self._provider(monkeypatch, host="http://sh:8888", api_key="adminkey")
backend = provider._create_backend()
assert isinstance(backend, SH)
assert captured["args"] == ("adminkey", "http://sh:8888")
def test_oss_mode_takes_precedence_over_host(self, monkeypatch):
class OB(_SentinelBackend):
def __init__(self, cfg):
pass
monkeypatch.setattr("plugins.memory.mem0._backend.OSSBackend", OB)
provider = self._provider(monkeypatch, mode="oss", host="http://sh:8888")
assert isinstance(provider._create_backend(), OB)
def test_prompt_label_matches_routing_when_oss_and_host_both_set(self, monkeypatch):
# system_prompt_block must mirror _create_backend precedence: with both
# mode=oss and host set, OSS wins the routing, so the prompt must label
# OSS — not "self-hosted (HTTP API)". Guards the prompt-vs-routing lie.
provider = self._provider(monkeypatch, mode="oss", host="http://sh:8888")
provider._user_id = "test"
block = provider.system_prompt_block()
assert "OSS" in block
assert "HTTP API" not in block
class TestSelfHostedConfig:
"""Config plumbing for self-hosted (MEM0_HOST env + is_available)."""
def test_load_config_reads_mem0_host_env(self, monkeypatch):
monkeypatch.setenv("MEM0_HOST", "http://localhost:8888")
assert mem0_plugin._load_config()["host"] == "http://localhost:8888"
@@ -0,0 +1,250 @@
"""Regression tests: supermemory + mem0 memory providers must lazy-install
their SDKs like honcho/hindsight.
Both providers ship a third-party SDK (``supermemory`` / ``mem0ai``) that is
NOT a core dependency. Before this fix they imported the SDK directly with no
``tools.lazy_deps.ensure()`` preflight and had no ``LAZY_DEPS`` allowlist
entry. On the published Docker image the agent venv is sealed
(``HERMES_DISABLE_LAZY_INSTALLS=1``) and lazy installs are redirected to a
writable durable target (``HERMES_LAZY_INSTALL_TARGET``). honcho/hindsight
route through ``ensure()`` and therefore install fine on a hosted instance;
supermemory/mem0 never called it, so the SDK was never installed there and
the provider silently reported itself unavailable.
These tests pin the contract:
1. Both features are in the ``LAZY_DEPS`` allowlist (without an entry,
``ensure()`` raises ``FeatureUnavailable`` — the original silent-dark bug).
2. Each provider's SDK-import chokepoint actually calls ``ensure(<feature>)``.
3. supermemory's ``is_available()`` no longer gates on the SDK being
importable (the chicken-and-egg trap that stopped the provider loading at
all on a sealed venv, so ``initialize()``/``ensure()`` never ran).
4. The real sealed-venv durable-target gate accepts the new features (the
exact hosted-Fly condition the user hit).
The pip subprocess is never actually run — ``_venv_pip_install`` /
``_is_satisfied`` are stubbed so we exercise the real ``ensure()`` control
flow without touching PyPI.
"""
from __future__ import annotations
import os
import pytest
import tools.lazy_deps as ld
MEMORY_FEATURES = ("memory.supermemory", "memory.mem0")
# ---------------------------------------------------------------------------
# 1. Allowlist contract — the core regression.
# ---------------------------------------------------------------------------
class TestAllowlistEntries:
@pytest.mark.parametrize("feature", MEMORY_FEATURES)
def test_feature_is_allowlisted(self, feature):
# Without an allowlist entry, ensure() raises FeatureUnavailable with
# "not in LAZY_DEPS" — which is exactly why the SDK never installed on
# a hosted instance before this fix.
assert feature in ld.LAZY_DEPS, (
f"{feature!r} missing from LAZY_DEPS — its SDK can never "
f"lazy-install on a sealed Docker venv."
)
def test_supermemory_spec_package(self):
specs = ld.LAZY_DEPS["memory.supermemory"]
assert any(ld._pkg_name_from_spec(s) == "supermemory" for s in specs)
def test_mem0_spec_package(self):
# mem0's pip package is ``mem0ai`` (imports as ``mem0``).
specs = ld.LAZY_DEPS["memory.mem0"]
assert any(ld._pkg_name_from_spec(s) == "mem0ai" for s in specs)
@pytest.mark.parametrize("feature", MEMORY_FEATURES)
def test_unknown_feature_would_raise_without_entry(self, feature, monkeypatch):
# Demonstrate the failure mode the allowlist entry prevents: a feature
# NOT in LAZY_DEPS raises rather than installing.
monkeypatch.setattr(ld, "_allow_lazy_installs", lambda: True)
with pytest.raises(ld.FeatureUnavailable, match="not in LAZY_DEPS"):
ld.ensure(feature + ".typo", prompt=False)
# ---------------------------------------------------------------------------
# 2. Import sites call ensure().
# ---------------------------------------------------------------------------
class TestSupermemoryEnsureCalled:
def test_client_construction_calls_ensure(self, monkeypatch):
"""_SupermemoryClient.__init__ must call ensure('memory.supermemory')
before importing the SDK."""
from plugins.memory.supermemory import _SupermemoryClient
calls = []
monkeypatch.setattr(
ld, "ensure",
lambda feature, **kw: calls.append((feature, kw)),
)
# Stub the SDK so construction doesn't need the real package. The
# client does ``from supermemory import Supermemory`` right after
# ensure(); inject a fake module.
import sys
import types
fake = types.ModuleType("supermemory")
fake.Supermemory = lambda **kw: object()
monkeypatch.setitem(sys.modules, "supermemory", fake)
_SupermemoryClient(api_key="k", timeout=5.0, container_tag="hermes")
assert ("memory.supermemory", {"prompt": False}) in calls, (
"supermemory client did not call ensure('memory.supermemory', "
f"prompt=False); calls={calls}"
)
class TestMem0EnsureCalled:
def test_create_backend_calls_ensure(self, monkeypatch):
"""SupermemoryMemoryProvider-style mem0 provider must call
ensure('memory.mem0') in _create_backend before importing the SDK."""
from plugins.memory.mem0 import Mem0MemoryProvider
calls = []
monkeypatch.setattr(
ld, "ensure",
lambda feature, **kw: calls.append((feature, kw)),
)
prov = Mem0MemoryProvider()
# Platform mode is the default; force a known mode and stub the backend
# import so we isolate the ensure() call.
prov._mode = "platform"
prov._api_key = "k"
import sys
import types
fake = types.ModuleType("mem0")
fake.MemoryClient = lambda **kw: object()
fake.Memory = object
monkeypatch.setitem(sys.modules, "mem0", fake)
# _backend imports ``from mem0 import MemoryClient`` lazily inside
# PlatformBackend.__init__, so the fake module satisfies it.
prov._create_backend()
assert ("memory.mem0", {"prompt": False}) in calls, (
f"mem0 _create_backend did not call ensure('memory.mem0', "
f"prompt=False); calls={calls}"
)
# ---------------------------------------------------------------------------
# 3. supermemory is_available() chicken-and-egg fix.
# ---------------------------------------------------------------------------
class TestSupermemoryIsAvailable:
def test_available_with_key_even_when_sdk_absent(self, monkeypatch):
"""With the key set but the SDK not importable, is_available() must
still return True — otherwise the provider never loads on a sealed
venv and ensure() (which installs the SDK) never runs."""
from plugins.memory.supermemory import SupermemoryMemoryProvider
import builtins
monkeypatch.setenv("SUPERMEMORY_API_KEY", "sk-test")
# Make any attempt to import the SDK fail, simulating the
# not-yet-installed sealed-venv state.
real_import = builtins.__import__
def _no_supermemory(name, *args, **kwargs):
if name == "supermemory" or name.startswith("supermemory."):
raise ImportError("No module named 'supermemory'")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _no_supermemory)
prov = SupermemoryMemoryProvider()
assert prov.is_available() is True
def test_unavailable_without_key(self, monkeypatch):
from plugins.memory.supermemory import SupermemoryMemoryProvider
monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False)
prov = SupermemoryMemoryProvider()
assert prov.is_available() is False
# ---------------------------------------------------------------------------
# 4. Real sealed-venv durable-target gate accepts the new features.
#
# This is the exact hosted-Fly condition: HERMES_DISABLE_LAZY_INSTALLS=1 seals
# the venv, but HERMES_LAZY_INSTALL_TARGET redirects installs to a writable
# durable dir, so installs are still ALLOWED. We exercise the real
# _allow_lazy_installs() + ensure() flow end-to-end with only the pip
# subprocess stubbed.
# ---------------------------------------------------------------------------
class TestSealedVenvDurableTarget:
@pytest.mark.parametrize("feature", MEMORY_FEATURES)
def test_ensure_installs_into_durable_target_on_sealed_venv(
self, feature, monkeypatch, tmp_path
):
# Sealed venv + durable target = the published Docker image config.
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
monkeypatch.setenv("HERMES_LAZY_INSTALL_TARGET", str(tmp_path / "lazy"))
# config.yaml kill-switch left at default (allow).
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"security": {"allow_lazy_installs": True}},
)
# Real gate must permit installs because a durable target is set.
assert ld._allow_lazy_installs() is True, (
"sealed venv WITH a durable target must allow installs — this is "
"the path honcho/hindsight use on hosted Fly instances"
)
# Drive ensure(): missing first, satisfied after the (stubbed) install.
states = iter([False, True])
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: next(states))
captured = {}
def fake_install(specs, **kw):
captured["specs"] = specs
captured["target_env"] = os.environ.get("HERMES_LAZY_INSTALL_TARGET")
return ld._InstallResult(True, "ok", "")
monkeypatch.setattr(ld, "_venv_pip_install", fake_install)
ld.ensure(feature, prompt=False) # must not raise
assert captured.get("specs") == ld.LAZY_DEPS[feature]
assert captured.get("target_env"), (
"install ran without the durable target env set"
)
@pytest.mark.parametrize("feature", MEMORY_FEATURES)
def test_sealed_venv_without_target_blocks(self, feature, monkeypatch):
# Sealed venv and NO durable target → installs blocked (can't mutate
# the sealed venv). Belt-and-suspenders: confirms the gate still
# protects the seal for these features.
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
monkeypatch.delenv("HERMES_LAZY_INSTALL_TARGET", raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"security": {"allow_lazy_installs": True}},
)
monkeypatch.setattr(ld, "_is_satisfied", lambda spec: False)
with pytest.raises(ld.FeatureUnavailable, match="lazy installs disabled"):
ld.ensure(feature, prompt=False)
@@ -0,0 +1,68 @@
"""OpenViking endpoint always-blocked floor."""
import pytest
from plugins.memory.openviking import (
_OpenVikingEndpointError,
_local_openviking_bind,
_normalize_openviking_url,
_openviking_endpoint_is_always_blocked,
)
def test_openviking_blocks_metadata_endpoint():
with pytest.raises(_OpenVikingEndpointError, match="blocked metadata address"):
_normalize_openviking_url("http://169.254.169.254/")
def test_openviking_keeps_default_loopback():
assert _normalize_openviking_url("http://127.0.0.1:1933") == "http://127.0.0.1:1933"
@pytest.mark.parametrize("host", ["localhost", "127.0.0.1"])
def test_openviking_bare_loopback_health_and_autostart_use_same_default_port(host):
endpoint = _normalize_openviking_url(host)
assert endpoint == f"http://{host}:1933"
assert _local_openviking_bind(endpoint) == (host, 1933)
def test_openviking_explicit_loopback_url_preserves_implicit_http_port():
assert _normalize_openviking_url("http://localhost") == "http://localhost"
def test_openviking_blocks_ecs_metadata_hostname():
with pytest.raises(_OpenVikingEndpointError, match="blocked metadata address"):
_normalize_openviking_url("http://metadata.google.internal/computeMetadata/v1/")
def test_openviking_rejects_endpoint_credentials_and_query():
with pytest.raises(_OpenVikingEndpointError, match="cannot contain user info"):
_normalize_openviking_url("https://user:secret@example.com?api_key=secret")
def test_openviking_validates_shorthand_ipv6_port():
assert _normalize_openviking_url("::1:1934") == "http://[::1]:1934"
with pytest.raises(_OpenVikingEndpointError, match="Port could not be cast"):
_normalize_openviking_url("::1:not-a-port")
def test_openviking_caches_safety_check_for_unchanged_endpoint(monkeypatch):
import tools.url_safety as url_safety
calls = []
_openviking_endpoint_is_always_blocked.cache_clear()
monkeypatch.setattr(
url_safety,
"is_always_blocked_url",
lambda value: calls.append(value) or False,
)
assert _normalize_openviking_url("https://openviking.example.test") == (
"https://openviking.example.test"
)
assert _normalize_openviking_url("https://openviking.example.test") == (
"https://openviking.example.test"
)
assert calls == ["https://openviking.example.test"]
_openviking_endpoint_is_always_blocked.cache_clear()
@@ -0,0 +1,355 @@
"""Optional peer identity must agree across setup, requests and memory writes."""
import json
import os
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
import pytest
import yaml
import plugins.memory.openviking as ov
@pytest.fixture(autouse=True)
def isolated_config(tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
for key in (*ov._OPENVIKING_ENV_KEYS, "OPENVIKING_CLI_CONFIG_FILE"):
# Track absent keys too, so setup's direct environment writes are undone.
monkeypatch.setenv(key, "")
monkeypatch.delenv(key, raising=False)
@pytest.mark.parametrize("source", ["env", "yaml", "actor_peer_id", "agent_id"])
@pytest.mark.parametrize("peer", ["", "hermes", "work-assistant"])
def test_configured_peer_routing_is_preserved(tmp_path, monkeypatch, source, peer):
config = {}
if source == "env":
monkeypatch.setenv("OPENVIKING_AGENT", peer)
elif source == "yaml":
config["agent"] = peer
else:
path = tmp_path / "ovcli.conf"
path.write_text(
json.dumps({"url": "http://localhost:1933", source: peer}), encoding="utf-8"
)
config = {"use_ovcli_config": True, "ovcli_config_path": str(path)}
settings = ov._resolve_connection_settings(config)
client = ov._VikingClient("http://localhost:1933", agent=settings["agent"])
monkeypatch.setattr(client, "get", lambda *a, **kw: {"result": {"user": "alice"}})
provider = ov.OpenVikingMemoryProvider()
uri = provider._build_memory_uri("preferences", client=client)
assert settings["agent"] == peer
assert client._headers().get("X-OpenViking-Actor-Peer", "") == peer
prefix = f"peers/{peer}/" if peer else ""
assert uri.startswith(f"viking://user/alice/{prefix}memories/preferences/mem_")
def test_unconfigured_client_and_schema_do_not_supply_a_peer():
settings = ov._resolve_connection_settings({})
client = ov._VikingClient("http://localhost:1933")
schema = {
field["key"]: field
for field in ov.OpenVikingMemoryProvider().get_config_schema()
}
assert settings["agent"] == ""
assert schema["agent"]["default"] == ""
assert "X-OpenViking-Actor-Peer" not in client._headers()
assert "X-OpenViking-Actor-Peer" not in client._multipart_headers()
@pytest.mark.parametrize("peer", ["", "hermes"])
def test_linked_profile_status_only_shows_a_configured_peer(tmp_path, peer):
path = tmp_path / "ovcli.conf"
path.write_text(
json.dumps({"url": "http://localhost:1933", "actor_peer_id": peer}),
encoding="utf-8",
)
display = ov.OpenVikingMemoryProvider().get_status_config({
"use_ovcli_config": True,
"ovcli_config_path": str(path),
})
if peer:
assert display["agent"] == peer
else:
assert "agent" not in display
@pytest.mark.parametrize("peer", ["", "hermes"])
def test_memory_uri_uses_captured_peer_even_when_empty(monkeypatch, peer):
client = ov._VikingClient("http://localhost:1933", agent=peer)
monkeypatch.setattr(client, "get", lambda *a, **kw: {"result": {"user": "alice"}})
provider = ov.OpenVikingMemoryProvider()
provider._agent = "later-peer"
uri = provider._build_memory_uri("preferences", client=client)
prefix = f"peers/{peer}/" if peer else ""
assert uri.startswith(f"viking://user/alice/{prefix}memories/preferences/mem_")
assert "later-peer" not in uri
@pytest.mark.parametrize("save_to_store", [False, True])
@pytest.mark.parametrize("credential", ["dev", "user", "root", "service"])
@pytest.mark.parametrize("stale_env", [False, True])
def test_new_setup_does_not_ask_for_or_save_peer(
tmp_path,
monkeypatch,
save_to_store,
credential,
stale_env,
):
from hermes_cli import memory_setup
home = tmp_path / "hermes"
home.mkdir()
(home / ".env").write_text(
"OPENVIKING_AGENT=old-peer\nOTHER_KEY=keep\n", encoding="utf-8"
)
config = {"memory": {"openviking": {"agent": "old-peer", "recall_limit": 9}}}
if stale_env:
for key in ov._OPENVIKING_ENV_KEYS:
monkeypatch.setenv(
key, "old-peer" if key == "OPENVIKING_AGENT" else "old-value"
)
monkeypatch.setenv("OTHER_KEY", "keep")
validations = []
def validate(values, **kwargs):
validations.append(dict(values))
role = (
"root"
if credential == "root"
else "user"
if values.get("api_key")
else None
)
return True, "", role
def prompt(label, default=None, secret=False):
values = {
"OpenViking server URL": "http://localhost:1933",
"OpenViking user API key": "test-user-key",
"OpenViking root API key": "test-root-key",
"OpenViking API key": "test-service-key",
"OpenViking account": "account",
"OpenViking user": "alice",
"OpenViking profile name": "personal",
}
assert label in values, f"Unexpected setup question: {label}"
return values[label]
def select(title, options, **kwargs):
choices = {
" OpenViking connection": 0 if credential == "service" else 1,
" OpenViking credential": {"dev": 2, "user": 0, "root": 1}.get(
credential, 0
),
" Save OpenViking config": int(save_to_store),
}
assert title in choices, f"Unexpected setup menu: {title}"
return choices[title]
monkeypatch.setattr(memory_setup, "_prompt", prompt)
monkeypatch.setattr(memory_setup, "_curses_select", select)
monkeypatch.setattr(ov, "_validate_openviking_reachability", lambda *a: (True, ""))
monkeypatch.setattr(ov, "_validate_openviking_setup_values", validate)
ov.OpenVikingMemoryProvider().post_setup(str(home), config)
assert validations
assert all(values["agent"] == "" for values in validations)
assert "OPENVIKING_AGENT" not in (home / ".env").read_text(encoding="utf-8")
assert "OTHER_KEY=keep" in (home / ".env").read_text(encoding="utf-8")
saved_config = ov._load_hermes_openviking_config()
assert saved_config["recall_limit"] == 9
settings = ov._resolve_connection_settings(saved_config)
assert settings["agent"] == ""
assert settings == {
key: validations[-1][key]
for key in ("endpoint", "api_key", "account", "user", "agent")
}
assert "OPENVIKING_AGENT" not in os.environ
assert os.environ["OTHER_KEY"] == "keep"
if save_to_store:
saved = json.loads(
Path(saved_config["ovcli_config_path"]).read_text(encoding="utf-8")
)
assert "actor_peer_id" not in saved
assert "agent_id" not in saved
@pytest.mark.parametrize("peer", ["", "work-assistant"])
def test_hermes_only_save_uses_the_same_clean_values_in_file_and_process(
tmp_path, peer
):
from dotenv import dotenv_values
env_path = tmp_path / ".env"
ov._save_hermes_only_config(
config={"memory": {}},
provider_config={},
env_path=env_path,
values={
"endpoint": "http://localhost:29333",
"api_key": "test\r\n-key\x00",
"agent": peer,
},
)
expected = {
"OPENVIKING_ENDPOINT": "http://localhost:29333",
"OPENVIKING_API_KEY": "test-key",
}
if peer:
expected["OPENVIKING_AGENT"] = peer
assert dict(dotenv_values(env_path)) == expected
assert {
key: os.environ[key] for key in ov._OPENVIKING_ENV_KEYS if key in os.environ
} == expected
def test_hermes_only_save_failure_leaves_process_environment_unchanged(
tmp_path, monkeypatch
):
for key in ov._OPENVIKING_ENV_KEYS:
monkeypatch.setenv(key, "old-value")
def fail_write(*args, **kwargs):
raise OSError("test write failure")
monkeypatch.setattr(ov, "_write_env_vars", fail_write)
with pytest.raises(OSError, match="test write failure"):
ov._save_hermes_only_config(
config={"memory": {}},
provider_config={},
env_path=tmp_path / ".env",
values={"endpoint": "http://localhost:29333", "api_key": "test-key"},
)
assert all(os.environ[key] == "old-value" for key in ov._OPENVIKING_ENV_KEYS)
@pytest.mark.parametrize("peer", ["", "hermes"])
def test_wire_requests_keep_writes_and_session_messages_in_the_selected_scope(
tmp_path,
monkeypatch,
peer,
):
records = []
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def respond(self, payload):
body = json.dumps(payload).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/health":
self.respond({"status": "ok", "healthy": True, "version": "test"})
elif self.path == "/api/v1/system/status":
self.respond({"result": {"user": "alice"}})
else:
self.send_error(404)
def do_POST(self):
payload = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
records.append((self.path, dict(self.headers), payload))
self.respond({"status": "ok", "result": {"written_bytes": 10}})
server = HTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
home = tmp_path / "hermes"
home.mkdir()
provider_config = {"endpoint": f"http://127.0.0.1:{server.server_port}"}
if peer:
provider_config["agent"] = peer
(home / "config.yaml").write_text(
yaml.safe_dump({
"memory": {"provider": "openviking", "openviking": provider_config}
}),
encoding="utf-8",
)
provider = ov.OpenVikingMemoryProvider()
try:
provider.initialize("peer-test", hermes_home=str(home))
assert provider._client is not None
result = json.loads(
provider.handle_tool_call("viking_remember", {"content": "I like tea"})
)
assert result["status"] == "submitted"
provider.on_memory_write("add", "user", "I like coffee")
provider.sync_turn("hello", "hi", session_id="peer-test")
assert provider._drain_writers("peer-test", timeout=5.0)
provider.sync_turn(
"next",
"reply",
session_id="peer-test",
messages=[
{"role": "user", "content": "next"},
{"role": "assistant", "content": "reply"},
],
)
assert provider._drain_writers("peer-test", timeout=5.0)
provider.on_session_end([])
finally:
provider.shutdown()
server.shutdown()
server.server_close()
thread.join(timeout=3.0)
assert records
for _path, headers, _payload in records:
if peer:
assert headers["X-OpenViking-Actor-Peer"] == peer
else:
assert "X-OpenViking-Actor-Peer" not in headers
writes = [
payload for path, _, payload in records if path == "/api/v1/content/write"
]
prefix = f"peers/{peer}/" if peer else ""
assert {write["content"] for write in writes} == {"I like coffee"}
assert all(
write["uri"].startswith(f"viking://user/alice/{prefix}memories/")
for write in writes
)
remember_messages = [
(path, payload)
for path, _, payload in records
if path.startswith("/api/v1/sessions/hermes-remember-")
and path.endswith("/messages")
]
assert len(remember_messages) == 1
remember_path, remember_message = remember_messages[0]
remember_session = remember_path.removesuffix("/messages")
assert remember_message == {
"role": "user",
"parts": [{"type": "text", "text": "I like tea"}],
}
assert any(path == f"{remember_session}/commit" for path, _, _ in records)
batches = [
payload["messages"]
for path, _, payload in records
if path.endswith("/messages/batch")
]
assert len(batches) == 2
for batch in batches:
assert "peer_id" not in batch[0]
if peer:
assert batch[1]["peer_id"] == peer
else:
assert "peer_id" not in batch[1]
assert any(path.endswith("/commit") for path, _, _ in records)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
"""Tests for OpenViking memory-provider shutdown teardown.
The runtime-autostart waiter is a tracked ``daemon=True`` thread that blocks
on network health probes. If ``shutdown()`` doesn't join it (and the waiter
doesn't bail on the shutdown flag), it can be left alive at interpreter exit,
which crashes CPython with SIGABRT at ``Py_FinalizeEx``. These tests assert
the waiter short-circuits on shutdown and that ``shutdown()`` waits for the
runtime-start thread.
"""
from __future__ import annotations
import threading
import time
from unittest.mock import patch
import plugins.memory.openviking as openviking_module
from plugins.memory.openviking import OpenVikingMemoryProvider
def test_wait_for_health_short_circuits_on_should_stop():
"""The health waiter returns False without probing when should_stop is set,
so the daemon thread running it can be join()ed promptly at shutdown."""
probes: list[str] = []
def _reach(endpoint):
probes.append(endpoint)
return (False, "down")
with patch.object(
openviking_module, "_validate_openviking_reachability", _reach
):
result = openviking_module._wait_for_openviking_health(
"http://example.invalid",
timeout_seconds=60.0,
should_stop=lambda: True,
)
assert result is False
assert probes == [] # bailed before the first network probe
def test_shutdown_waits_for_runtime_start_thread():
"""shutdown() must join the runtime-autostart waiter thread.
The fake waiter does post-stop work (a short sleep) once it observes the
shutdown flag. If shutdown() joins it, that work has completed by the time
shutdown() returns; without the join, shutdown() returns early and the
thread is still running (the SIGABRT-at-exit failure mode).
"""
provider = OpenVikingMemoryProvider()
started = threading.Event()
finished = threading.Event()
def _runtime():
started.set()
while not provider._shutting_down:
time.sleep(0.01)
time.sleep(0.2) # work that must finish during shutdown's join
finished.set()
t = threading.Thread(target=_runtime, daemon=True, name="openviking-runtime-start")
provider._runtime_start_thread = t
t.start()
assert started.wait(2.0)
provider.shutdown()
assert finished.is_set()
assert not t.is_alive()
@@ -0,0 +1,241 @@
from __future__ import annotations
import sqlite3
from unittest.mock import MagicMock
import agent.file_safety as fs
import pytest
import plugins.memory.retaindb as retaindb
from plugins.memory.retaindb import RetainDBMemoryProvider
def test_write_queue_closes_owner_connection(tmp_path):
queue = retaindb._WriteQueue(object(), tmp_path / "retaindb.db")
owner_conn = queue._local.conn
worker = retaindb.threading.Thread(target=queue._get_conn)
worker.start()
worker.join()
queue.shutdown()
assert not queue._connections
with pytest.raises(sqlite3.ProgrammingError):
owner_conn.execute("SELECT 1")
def test_write_queue_ignores_enqueue_after_shutdown(tmp_path):
queue = retaindb._WriteQueue(object(), tmp_path / "retaindb.db")
queue.shutdown()
queue.enqueue("user", "session", [])
assert not queue._connections
def test_prefetch_does_not_spawn_when_previous_batch_is_alive(monkeypatch):
provider = RetainDBMemoryProvider()
provider._client = object()
class _RunningThread:
def join(self, timeout):
pass
def is_alive(self):
return True
previous = _RunningThread()
provider._prefetch_threads = [previous]
created = []
class _Thread:
def __init__(self, *args, **kwargs):
created.append((args, kwargs))
def start(self):
pass
monkeypatch.setattr(retaindb.threading, "Thread", _Thread)
provider.queue_prefetch("query")
assert provider._prefetch_threads == [previous]
assert not created
def test_upload_file_rejects_hermes_credential_store(tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes_home"
hermes_home.mkdir()
auth_json = hermes_home / "auth.json"
auth_json.write_text('{"OPENAI_API_KEY":"sk-test-secret"}', encoding="utf-8")
monkeypatch.setattr(fs, "_hermes_home_path", lambda: hermes_home)
provider = RetainDBMemoryProvider()
provider._client = MagicMock()
result = provider._dispatch("retaindb_upload_file", {"local_path": str(auth_json)})
assert "error" in result
assert "credential store" in result["error"]
provider._client.upload_file.assert_not_called()
def test_upload_file_allows_regular_file(tmp_path):
note = tmp_path / "note.md"
note.write_text("# Note\n", encoding="utf-8")
provider = RetainDBMemoryProvider()
provider._client = MagicMock()
provider._client.upload_file.return_value = {
"file": {"id": "file-1", "name": "note.md"},
}
result = provider._dispatch("retaindb_upload_file", {"local_path": str(note)})
provider._client.upload_file.assert_called_once()
assert provider._client.upload_file.call_args.args[0] == note.read_bytes()
assert result["file"]["id"] == "file-1"
def _capture_initialized_client(monkeypatch, tmp_path):
"""Patch _Client/_WriteQueue/get_hermes_home; return a dict capturing args."""
import hermes_constants
import plugins.memory.retaindb as retaindb_module
captured: dict = {}
class _FakeClient:
def __init__(self, api_key, base_url, project):
captured["api_key"] = api_key
captured["base_url"] = base_url
captured["project"] = project
self.project = project
monkeypatch.setattr(retaindb_module, "_Client", _FakeClient)
monkeypatch.setattr(retaindb_module, "_WriteQueue", lambda *a, **k: MagicMock())
monkeypatch.setattr(hermes_constants, "get_hermes_home", lambda: tmp_path)
return retaindb_module, captured
def test_retaindb_config_loader_uses_readonly_config(monkeypatch):
import hermes_cli.config as config_mod
import plugins.memory.retaindb as retaindb_module
backing_config = {
"memory": {
"retaindb": {
"base_url": "https://saved.example",
"project": "saved-project",
}
}
}
monkeypatch.setattr(config_mod, "load_config_readonly", lambda: backing_config)
monkeypatch.setattr(
config_mod,
"load_config",
MagicMock(side_effect=AssertionError("read-only provider path must not load a mutable copy")),
)
config = retaindb_module._load_retaindb_config()
assert config == backing_config["memory"]["retaindb"]
assert config is not backing_config["memory"]["retaindb"]
def test_initialize_reads_real_dashboard_config_file(tmp_path, monkeypatch):
for var in ("RETAINDB_API_KEY", "RETAINDB_BASE_URL", "RETAINDB_PROJECT"):
monkeypatch.delenv(var, raising=False)
(tmp_path / "config.yaml").write_text(
"""\
memory:
provider: retaindb
retaindb:
base_url: https://retaindb.saved.example/
project: dashboard-project
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_retaindb_module, captured = _capture_initialized_client(monkeypatch, tmp_path)
RetainDBMemoryProvider().initialize("sess-1")
assert captured["base_url"] == "https://retaindb.saved.example"
assert captured["project"] == "dashboard-project"
def test_initialize_reads_base_url_and_project_from_config_yaml(tmp_path, monkeypatch):
"""#68209: non-secret base_url/project come from config.yaml when env is unset."""
for var in ("RETAINDB_API_KEY", "RETAINDB_BASE_URL", "RETAINDB_PROJECT"):
monkeypatch.delenv(var, raising=False)
retaindb_module, captured = _capture_initialized_client(monkeypatch, tmp_path)
monkeypatch.setattr(
retaindb_module,
"_load_retaindb_config",
lambda: {"base_url": "https://retaindb.example.com/", "project": "cfg-project"},
)
RetainDBMemoryProvider().initialize("sess-1")
assert captured["base_url"] == "https://retaindb.example.com" # trailing slash stripped
assert captured["project"] == "cfg-project"
def test_initialize_env_overrides_config_yaml(tmp_path, monkeypatch):
for var in ("RETAINDB_API_KEY", "RETAINDB_PROJECT"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("RETAINDB_BASE_URL", "https://env.example.com")
retaindb_module, captured = _capture_initialized_client(monkeypatch, tmp_path)
monkeypatch.setattr(
retaindb_module,
"_load_retaindb_config",
lambda: {"base_url": "https://cfg.example.com", "project": "cfg-project"},
)
RetainDBMemoryProvider().initialize("sess-1")
assert captured["base_url"] == "https://env.example.com"
def test_initialize_combines_scoped_secret_with_dashboard_config(tmp_path, monkeypatch):
"""Rebase regression: scoped secrets and non-secret config must coexist."""
from agent.secret_scope import (
is_multiplex_active,
reset_secret_scope,
set_multiplex_active,
set_secret_scope,
)
monkeypatch.setenv("RETAINDB_API_KEY", "env-other-profile")
monkeypatch.delenv("RETAINDB_BASE_URL", raising=False)
monkeypatch.delenv("RETAINDB_PROJECT", raising=False)
retaindb_module, captured = _capture_initialized_client(monkeypatch, tmp_path)
monkeypatch.setattr(
retaindb_module,
"_load_retaindb_config",
lambda: {"base_url": "https://dashboard.example.com/", "project": "dashboard-project"},
)
previous_multiplex_state = is_multiplex_active()
set_multiplex_active(True)
token = set_secret_scope({"RETAINDB_API_KEY": "scoped-key"})
try:
RetainDBMemoryProvider().initialize("sess-1")
finally:
reset_secret_scope(token)
set_multiplex_active(previous_multiplex_state)
assert captured == {
"api_key": "scoped-key",
"base_url": "https://dashboard.example.com",
"project": "dashboard-project",
}
def test_initialize_falls_back_to_default_base_url(tmp_path, monkeypatch):
for var in ("RETAINDB_API_KEY", "RETAINDB_BASE_URL", "RETAINDB_PROJECT"):
monkeypatch.delenv(var, raising=False)
retaindb_module, captured = _capture_initialized_client(monkeypatch, tmp_path)
monkeypatch.setattr(retaindb_module, "_load_retaindb_config", lambda: {})
RetainDBMemoryProvider().initialize("sess-1")
assert captured["base_url"] == retaindb_module._DEFAULT_BASE_URL
assert captured["project"] == "default"
@@ -0,0 +1,458 @@
import json
import os
import stat
import threading
import pytest
from plugins.memory.supermemory import (
SupermemoryMemoryProvider,
_clean_text_for_capture,
_format_connection_summary,
_format_prefetch_context,
_load_supermemory_config,
_probe_supermemory_connection,
_save_supermemory_config,
)
class FakeClient:
def __init__(self, api_key: str, timeout: float, container_tag: str, search_mode: str = "hybrid",
base_url: str = ""):
self.api_key = api_key
self.timeout = timeout
self.container_tag = container_tag
self.search_mode = search_mode
self.base_url = base_url
self.add_calls = []
self.search_results = []
self.profile_response = {"static": [], "dynamic": [], "search_results": []}
self.ingest_calls = []
self.forgotten_ids = []
self.forget_by_query_response = {"success": True, "message": "Forgot"}
def add_memory(self, content, metadata=None, *, entity_context="",
container_tag=None, custom_id=None):
self.add_calls.append({
"content": content,
"metadata": metadata,
"entity_context": entity_context,
"container_tag": container_tag,
"custom_id": custom_id,
})
return {"id": "mem_123"}
def search_memories(self, query, *, limit=5, container_tag=None, search_mode=None):
return self.search_results
def get_profile(self, query=None, *, container_tag=None):
return self.profile_response
def forget_memory(self, memory_id, *, container_tag=None):
self.forgotten_ids.append(memory_id)
def forget_by_query(self, query, *, container_tag=None):
return self.forget_by_query_response
def ingest_conversation(self, session_id, messages, metadata=None):
self.ingest_calls.append({"session_id": session_id, "messages": messages, "metadata": metadata})
@pytest.fixture
def provider(monkeypatch, tmp_path):
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
p = SupermemoryMemoryProvider()
p.initialize("session-1", hermes_home=str(tmp_path), platform="cli")
return p
def test_is_available_false_without_api_key(monkeypatch):
monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False)
p = SupermemoryMemoryProvider()
assert p.is_available() is False
def test_load_and_save_config_round_trip(tmp_path):
_save_supermemory_config({"container_tag": "demo-tag", "auto_capture": False}, str(tmp_path))
cfg = _load_supermemory_config(str(tmp_path))
# container_tag is kept raw — sanitization happens in initialize() after template resolution
assert cfg["container_tag"] == "demo-tag"
assert cfg["auto_capture"] is False
assert cfg["auto_recall"] is True
def test_clean_text_for_capture_strips_injected_context():
text = "hello\n<supermemory-context>ignore me</supermemory-context>\nworld"
assert _clean_text_for_capture(text) == "hello\nworld"
def test_format_prefetch_context_deduplicates_overlap():
result = _format_prefetch_context(
static_facts=["Jordan prefers short answers"],
dynamic_facts=["Jordan prefers short answers", "Uses Hermes"],
search_results=[{"memory": "Uses Hermes", "similarity": 0.9}],
max_results=10,
)
assert result.count("Jordan prefers short answers") == 1
assert result.count("Uses Hermes") == 1
assert "<supermemory-context>" in result
def test_prefetch_includes_profile_on_first_turn(provider):
provider._client.profile_response = {
"static": ["Jordan prefers short answers"],
"dynamic": ["Current project is Supermemory provider"],
"search_results": [{"memory": "Working on Hermes memory provider", "similarity": 0.88}],
}
provider.on_turn_start(1, "start")
result = provider.prefetch("what am I working on?")
assert "User Profile (Persistent)" in result
assert "Recent Context" in result
assert "Relevant Memories" in result
def test_sync_turn_buffers_short_messages(provider):
# Trivial filtering is no longer applied at sync time — every non-empty turn
# is buffered and only the full session is written at session boundaries.
provider.sync_turn("ok", "sure", session_id="session-1")
assert provider._session_turns == [{"user": "ok", "assistant": "sure"}]
assert provider._client.add_calls == []
def test_on_session_end_ingests_clean_messages(provider):
messages = [
{"role": "system", "content": "skip"},
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi there"},
]
provider.on_session_end(messages)
assert len(provider._client.ingest_calls) == 1
payload = provider._client.ingest_calls[0]
assert payload["session_id"] == "session-1"
assert payload["messages"] == [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi there"},
]
assert payload["metadata"]["type"] == "full_session"
assert payload["metadata"]["session_id"] == "session-1"
assert payload["metadata"]["message_count"] == 2
# Buffer is cleared after a normal session-end ingest.
assert provider._session_turns == []
def test_merge_metadata_stamps_sm_source():
# sm_source routes Hermes writes into the "Hermes" Space in the Supermemory
# app (functional routing, not telemetry) — must always be present.
from plugins.memory.supermemory import _SupermemoryClient
client = _SupermemoryClient.__new__(_SupermemoryClient)
merged = client._merge_metadata({"type": "explicit_memory"})
assert merged["sm_source"] == "hermes"
assert merged["type"] == "explicit_memory"
# Legacy "source" is migrated into "type" when type is absent.
merged2 = client._merge_metadata({"source": "conversation_turn"})
assert merged2["sm_source"] == "hermes"
assert merged2["type"] == "conversation_turn"
assert "source" not in merged2
def test_shutdown_joins_threads_and_flushes_buffer(provider, monkeypatch):
started = threading.Event()
release = threading.Event()
def slow_add_memory(content, metadata=None, *, entity_context="",
container_tag=None, custom_id=None):
started.set()
release.wait(timeout=1)
provider._client.add_calls.append({
"content": content,
"metadata": metadata,
"entity_context": entity_context,
})
return {"id": "mem_slow"}
monkeypatch.setattr(provider._client, "add_memory", slow_add_memory)
# sync_turn now only buffers — no thread is spawned.
provider.sync_turn(
"Please remember this request in long-term memory",
"Absolutely, I will keep that in long-term memory.",
session_id="session-1",
)
assert provider._sync_thread is None
assert len(provider._session_turns) == 1
# on_memory_write still runs on a background thread.
provider.on_memory_write("add", "memory", "Jordan likes concise docs")
assert started.wait(timeout=1)
assert provider._write_thread is not None
release.set()
provider.shutdown()
# All tracked threads joined and cleared.
assert provider._sync_thread is None
assert provider._write_thread is None
assert provider._prefetch_thread is None
# Explicit memory write went through.
assert len(provider._client.add_calls) == 1
# Buffered turn was flushed as a partial full-session ingest.
assert len(provider._client.ingest_calls) == 1
payload = provider._client.ingest_calls[0]
assert payload["session_id"] == "session-1"
assert payload["metadata"]["partial"] is True
assert payload["metadata"]["type"] == "full_session"
def test_store_tool_returns_saved_payload(provider):
result = json.loads(provider.handle_tool_call("supermemory_store", {"content": "Jordan likes concise docs"}))
assert result["saved"] is True
assert result["id"] == "mem_123"
def test_search_tool_formats_results(provider):
provider._client.search_results = [
{"id": "m1", "memory": "Jordan likes concise docs", "similarity": 0.92}
]
result = json.loads(provider.handle_tool_call("supermemory_search", {"query": "concise docs"}))
assert result["count"] == 1
assert result["results"][0]["similarity"] == 92
def test_forget_tool_by_id(provider):
result = json.loads(provider.handle_tool_call("supermemory_forget", {"id": "m1"}))
assert result == {"forgotten": True, "id": "m1"}
assert provider._client.forgotten_ids == ["m1"]
def test_profile_tool_formats_sections(provider):
provider._client.profile_response = {
"static": ["Jordan prefers concise docs"],
"dynamic": ["Working on Supermemory provider"],
"search_results": [],
}
result = json.loads(provider.handle_tool_call("supermemory_profile", {}))
assert result["static_count"] == 1
assert result["dynamic_count"] == 1
assert "User Profile (Persistent)" in result["profile"]
def test_handle_tool_call_returns_error_when_unconfigured(monkeypatch):
monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False)
p = SupermemoryMemoryProvider()
result = json.loads(p.handle_tool_call("supermemory_search", {"query": "x"}))
assert "error" in result
# -- Identity template tests --------------------------------------------------
def test_identity_template_resolved_in_container_tag(monkeypatch, tmp_path):
"""container_tag with {identity} resolves to profile-scoped tag."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
_save_supermemory_config({"container_tag": "hermes-{identity}"}, str(tmp_path))
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli", agent_identity="coder")
assert p._container_tag == "hermes_coder"
def test_container_tag_env_var_override(monkeypatch, tmp_path):
"""SUPERMEMORY_CONTAINER_TAG env var overrides config."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setenv("SUPERMEMORY_CONTAINER_TAG", "env-override")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
assert p._container_tag == "env_override"
# -- Search mode tests --------------------------------------------------------
def test_invalid_search_mode_falls_back_to_default(monkeypatch, tmp_path):
"""Invalid search_mode falls back to 'hybrid'."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
_save_supermemory_config({"search_mode": "invalid_mode"}, str(tmp_path))
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
assert p._search_mode == "hybrid"
# -- Base URL tests -------------------------------------------------------------
def test_base_url_defaults_to_cloud(monkeypatch, tmp_path):
"""Without config or env override, the client targets api.supermemory.ai."""
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
monkeypatch.delenv("SUPERMEMORY_BASE_URL", raising=False)
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
p = SupermemoryMemoryProvider()
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
assert p._base_url == "https://api.supermemory.ai"
assert p._client.base_url == "https://api.supermemory.ai"
def test_client_passes_custom_base_url_to_sdk(monkeypatch):
"""SDK operations and raw conversation ingest share one normalized base URL."""
import sys
import types
from plugins.memory.supermemory import _SupermemoryClient
captured = {}
class StubSupermemory:
def __init__(self, **kwargs):
captured.update(kwargs)
module = types.ModuleType("supermemory")
module.Supermemory = StubSupermemory
monkeypatch.setitem(sys.modules, "supermemory", module)
monkeypatch.setattr("tools.lazy_deps.ensure", lambda *args, **kwargs: None)
client = _SupermemoryClient(
api_key="test-key",
timeout=1.0,
container_tag="hermes",
base_url="http://localhost:6767/",
)
assert client._base_url == "http://localhost:6767"
assert captured["base_url"] == "http://localhost:6767"
@pytest.mark.parametrize(
("base_url", "expected_url"),
[
("https://api.supermemory.ai", "https://api.supermemory.ai/v4/conversations"),
("http://localhost:6767", "http://localhost:6767/v4/conversations"),
],
)
def test_ingest_conversation_uses_client_base_url(monkeypatch, base_url, expected_url):
"""Raw conversation ingest follows the same endpoint as SDK operations."""
from plugins.memory.supermemory import _SupermemoryClient
client = _SupermemoryClient.__new__(_SupermemoryClient)
client._api_key = "test-key"
client._container_tag = "hermes"
client._timeout = 1.0
client._base_url = base_url
captured = {}
class _FakeResponse:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def fake_urlopen(req, timeout=None):
captured["url"] = req.full_url
return _FakeResponse()
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
client.ingest_conversation("s1", [{"role": "user", "content": "hello there"}])
assert captured["url"] == expected_url
# -- Multi-container tests ----------------------------------------------------
def test_multi_container_disabled_by_default(provider):
"""Multi-container is off by default; schemas have no container_tag param."""
assert provider._enable_custom_containers is False
schemas = provider.get_tool_schemas()
for s in schemas:
assert "container_tag" not in s["parameters"]["properties"]
def test_get_config_schema_minimal():
"""get_config_schema only returns the API key field."""
p = SupermemoryMemoryProvider()
schema = p.get_config_schema()
assert len(schema) == 1
assert schema[0]["key"] == "api_key"
assert schema[0]["secret"] is True
def test_probe_supermemory_connection_missing_key(tmp_path):
status = _probe_supermemory_connection("", str(tmp_path))
assert status["ok"] is False
assert status["error"] == "SUPERMEMORY_API_KEY not set"
assert status["container_tag"] == "hermes"
def _stub_supermemory_importable(monkeypatch):
"""Make ``__import__("supermemory")`` succeed without the real package.
``_probe_supermemory_connection`` guards on ``__import__("supermemory")``
before using the (mocked) client, so tests that mock ``_SupermemoryClient``
must also satisfy that import guard — otherwise they only pass in an
environment where the optional ``supermemory`` package happens to be
installed (and fail on a clean checkout / CI). Mirrors the inverse stub in
``test_is_available_false_when_import_missing``.
"""
import builtins
import types
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "supermemory":
return types.ModuleType("supermemory")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
def test_post_setup_writes_config_and_prints_summary(monkeypatch, tmp_path, capsys):
config: dict = {"memory": {}}
monkeypatch.setenv("SUPERMEMORY_API_KEY", "")
monkeypatch.setattr(
"hermes_cli.memory_setup._prompt",
lambda label, secret=True, default=None: "new-api-key",
)
monkeypatch.setattr(
"plugins.memory.supermemory._probe_supermemory_connection",
lambda api_key, hermes_home, **kwargs: {
"ok": True,
"container_tag": "hermes",
"profile_facts": 3,
"auto_recall": True,
"auto_capture": True,
},
)
saved: dict = {}
def fake_save_config(cfg):
saved.update(cfg)
monkeypatch.setattr("hermes_cli.config.save_config", fake_save_config)
SupermemoryMemoryProvider().post_setup(str(tmp_path), config)
assert config["memory"]["provider"] == "supermemory"
assert saved["memory"]["provider"] == "supermemory"
env_text = (tmp_path / ".env").read_text(encoding="utf-8")
assert "SUPERMEMORY_API_KEY=new-api-key" in env_text
out = capsys.readouterr().out
assert "✓ Connected" in out
assert "3 profile facts" in out
assert "Memory provider: supermemory" in out
@pytest.mark.skipif(os.name == "nt", reason="POSIX mode bits not enforced on Windows")
def test_save_config_sets_owner_only_permissions(tmp_path):
"""supermemory.json must be written with 0o600 so API key is not world-readable."""
_save_supermemory_config({"api_key": "sm-test-key"}, str(tmp_path))
config_file = tmp_path / "supermemory.json"
assert config_file.exists()
mode = stat.S_IMODE(config_file.stat().st_mode)
assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"