Files
mem0/integrations/hermes-plugin/tests/test_provider.py
T

226 lines
9.0 KiB
Python

"""Offline contracts for the native Hermes provider and legacy configuration."""
import contextvars
import importlib.util
import json
import sys
import threading
import types
from pathlib import Path
from unittest.mock import Mock
import pytest
ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture
def plugin(monkeypatch, tmp_path):
def spawn(target, *, name):
context = contextvars.copy_context()
return threading.Thread(target=context.run, args=(target,), name=name)
modules = {
"agent": {},
"agent.memory_provider": {"MemoryProvider": object, "spawn_context_thread": spawn},
"agent.secret_scope": {"get_secret": lambda key, default="": default},
"tools": {},
"tools.registry": {"tool_error": lambda text: json.dumps({"error": text})},
"utils": {
"read_json_or_empty": lambda p: json.loads(p.read_text()) if p.exists() else {},
"atomic_json_write": lambda p, value, **kw: p.write_text(json.dumps(value)),
},
"hermes_constants": {"get_hermes_home": lambda: tmp_path},
}
for name, values in modules.items():
module = types.ModuleType(name)
module.__dict__.update(values)
monkeypatch.setitem(sys.modules, name, module)
name = "hermes_test_mem0"
spec = importlib.util.spec_from_file_location(name, ROOT / "__init__.py", submodule_search_locations=[str(ROOT)])
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, name, module)
spec.loader.exec_module(module)
monkeypatch.setattr(module.atexit, "register", lambda *args: None)
return module
def provider(plugin, monkeypatch, config=None, **identity):
backend = Mock()
backend.add.return_value = {"event_id": "event-1"}
backend.search.return_value = []
monkeypatch.setattr(plugin, "_load_config", lambda: config or {})
monkeypatch.setattr(plugin.Mem0MemoryProvider, "_create_backend", lambda self: backend)
instance = plugin.Mem0MemoryProvider()
instance.initialize("session-1", **identity)
return instance, backend
def test_long_turns_preserve_tail_and_redact_before_chunking(plugin, monkeypatch):
instance, backend = provider(plugin, monkeypatch, {"sync_max_chars": 450})
text = "Opening. " + "x" * 1100 + " api_key=secret-value lasting preference at the end."
instance.sync_turn(text, "Noted.", session_id="turn-session")
instance.shutdown()
messages = [m for call in backend.add.call_args_list for m in call.args[0]]
assert "".join(m["content"] for m in messages if m["role"] == "user") == plugin.redact(text)
assert all(len(m["content"]) <= 450 for m in messages)
assert "secret-value" not in repr(backend.add.call_args_list)
assert all(call.kwargs["run_id"] == "turn-session" for call in backend.add.call_args_list)
def test_busy_capture_queues_each_turn_with_its_profile_context(plugin, monkeypatch):
instance, backend = provider(plugin, monkeypatch)
started, release = threading.Event(), threading.Event()
profile = contextvars.ContextVar("profile", default="first")
seen = []
def add(messages, **kwargs):
if not seen:
started.set()
assert release.wait(3)
seen.append((messages[0]["content"], profile.get()))
return {}
backend.add.side_effect = add
instance.sync_turn("first turn", "reply")
assert started.wait(3)
token = profile.set("second")
try:
instance.sync_turn("second turn", "reply")
finally:
profile.reset(token)
release.set()
instance.shutdown()
assert seen == [("first turn", "first"), ("second turn", "second")]
backend.close.assert_called_once()
@pytest.mark.parametrize(
"configured,gateway,expected",
[
(None, "telegram-42", "telegram-42"),
("hermes-user", "telegram-42", "telegram-42"),
("existing-account", "telegram-42", "existing-account"),
(None, None, "hermes-user"),
],
)
def test_legacy_identity_and_tool_contract(plugin, monkeypatch, configured, gateway, expected):
instance, backend = provider(plugin, monkeypatch, {"user_id": configured}, user_id=gateway)
assert instance.name == "mem0"
assert {s["name"] for s in instance.get_tool_schemas()} == {"mem0_search", "mem0_add", "mem0_update", "mem0_delete"}
instance.handle_tool_call("mem0_search", {"query": "api_key=secret-value"})
assert backend.search.call_args.kwargs["filters"] == {"user_id": expected}
assert backend.search.call_args.args[0] == "api_key=[REDACTED]"
instance.handle_tool_call("mem0_add", {"content": "password=secret-value"})
assert backend.add.call_args.kwargs["infer"] is False
assert backend.add.call_args.args[0][0]["content"] == "password=[REDACTED]"
instance.handle_tool_call("mem0_update", {"memory_id": "old-id", "text": "password=secret-value"})
backend.update.assert_called_once_with("old-id", "password=[REDACTED]")
instance.shutdown()
def test_config_keeps_legacy_file_over_env_precedence(plugin, monkeypatch, tmp_path):
env = {"MEM0_API_KEY": "env-key", "MEM0_USER_ID": "env-user", "MEM0_HOST": "http://localhost:8888"}
monkeypatch.setattr(plugin, "get_secret", lambda key, default="": env.get(key, default))
(tmp_path / "mem0.json").write_text(json.dumps({"user_id": "existing-user", "rerank": True}))
cfg = plugin._load_config()
assert (cfg["api_key"], cfg["user_id"], cfg["host"], cfg["rerank"]) == (
"env-key",
"existing-user",
"http://localhost:8888",
True,
)
def test_prefetch_respects_rerank_and_redacts_recalled_context(plugin, monkeypatch):
instance, backend = provider(plugin, monkeypatch, {"rerank": True})
backend.search.return_value = [{"memory": "password=old-secret"}]
assert "old-secret" not in instance.prefetch("preference")
assert backend.search.call_args.kwargs["rerank"] is True
instance.shutdown()
def test_session_switch_updates_writes_without_narrowing_recall(plugin, monkeypatch):
instance, backend = provider(plugin, monkeypatch)
instance.on_session_switch("resumed-session")
instance.handle_tool_call("mem0_add", {"content": "fact"})
assert backend.add.call_args.kwargs["run_id"] == "resumed-session"
instance.handle_tool_call("mem0_search", {"query": "fact"})
assert backend.search.call_args.kwargs["filters"] == {"user_id": "hermes-user"}
instance.shutdown()
def test_invalid_tool_input_never_reaches_backend_or_trips_breaker(plugin, monkeypatch):
instance, backend = provider(plugin, monkeypatch)
for args in ({"query": []}, {"query": "fact", "top_k": "not-a-number"}, None):
assert "error" in json.loads(instance.handle_tool_call("mem0_search", args))
backend.search.assert_not_called()
assert instance._consecutive_failures == 0
instance.shutdown()
def test_setup_writes_private_env_and_preserves_existing_values(plugin, tmp_path):
import importlib
setup = importlib.import_module(f"{plugin.__name__}._setup")
path = tmp_path / ".env"
path.write_text("EXISTING=value\nMEM0_API_KEY=old\n")
setup._write_env(path, {"MEM0_API_KEY": "new"})
assert path.read_text() == "EXISTING=value\nMEM0_API_KEY=new\n"
assert path.stat().st_mode & 0o777 == 0o600
def test_redaction_marker_split_at_chunk_boundary_is_lossless(plugin, monkeypatch):
instance, backend = provider(plugin, monkeypatch, {"sync_max_chars": 450})
text = "x" * 438 + " password=secret-value tail"
instance.sync_turn(text, "")
instance.shutdown()
stored = "".join(m["content"] for call in backend.add.call_args_list for m in call.args[0])
assert stored == plugin.redact(text)
def test_sync_queue_stops_network_calls_when_breaker_opens(plugin, monkeypatch, caplog):
instance, backend = provider(plugin, monkeypatch)
started, release = threading.Event(), threading.Event()
def unavailable(messages, **kwargs):
started.set()
assert release.wait(3)
raise RuntimeError("server unavailable")
backend.add.side_effect = unavailable
instance.sync_turn("first turn", "reply")
assert started.wait(3)
try:
for number in range(9):
instance.sync_turn(f"queued turn {number}", "reply")
finally:
release.set()
instance.shutdown()
assert backend.add.call_count == plugin._BREAKER_THRESHOLD
assert any("not synced" in record.message.lower() for record in caplog.records)
def test_shutdown_is_bounded_and_defers_close_until_sync_finishes(plugin, monkeypatch, caplog):
instance, backend = provider(plugin, monkeypatch)
started, release = threading.Event(), threading.Event()
monkeypatch.setattr(plugin, "_SHUTDOWN_WAIT_SECS", 0.01)
def add(*args, **kwargs):
started.set()
assert release.wait(3)
return {}
backend.add.side_effect = add
instance.sync_turn("fact", "reply")
assert started.wait(3)
worker = instance._sync_thread
try:
instance.shutdown()
backend.close.assert_not_called()
assert "shutdown timed out" in caplog.text
finally:
release.set()
worker.join(timeout=3)
backend.close.assert_called_once()