chore: import upstream snapshot with attribution
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
"""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
|
||||
|
||||
|
||||
def test_auto_extract_false_skips_memory_write(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.on_memory_write("add", "user", "User prefers concise responses")
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_auto_extract_false_skips_pre_compress(monkeypatch):
|
||||
calls = []
|
||||
provider = ByteRoverMemoryProvider({"auto_extract": "off"})
|
||||
provider.initialize("session-1")
|
||||
|
||||
monkeypatch.setattr("plugins.memory.byterover._run_brv", lambda *args, **kwargs: calls.append((args, kwargs)))
|
||||
|
||||
result = provider.on_pre_compress([
|
||||
{"role": "user", "content": "remember this"},
|
||||
{"role": "assistant", "content": "stored"},
|
||||
])
|
||||
|
||||
assert result == ""
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_auto_extract_false_keeps_explicit_curate_tool(monkeypatch):
|
||||
calls = []
|
||||
provider = ByteRoverMemoryProvider({"auto_extract": False})
|
||||
provider.initialize("session-1")
|
||||
|
||||
def fake_run(args, **kwargs):
|
||||
calls.append(args)
|
||||
return {"success": True, "output": "ok"}
|
||||
|
||||
monkeypatch.setattr("plugins.memory.byterover._run_brv", fake_run)
|
||||
|
||||
result = provider.handle_tool_call("brv_curate", {"content": "Important project fact"})
|
||||
|
||||
assert "Memory curated successfully" in result
|
||||
assert calls == [["curate", "--", "Important project fact"]]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,129 @@
|
||||
"""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}"
|
||||
|
||||
|
||||
def test_sanitize_fts_query_never_crashes_on_fts5_specials():
|
||||
"""Queries with FTS5 operator characters must not produce malformed SQL."""
|
||||
problematic = [
|
||||
'test " query',
|
||||
"test * query",
|
||||
"test (a OR b) query",
|
||||
"test^2 query",
|
||||
"test:colon query",
|
||||
"test-hyphen query",
|
||||
"a" * 1000, # long query
|
||||
]
|
||||
for q in problematic:
|
||||
result = FactRetriever._sanitize_fts_query(q)
|
||||
# We just need it to return a string without raising
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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()
|
||||
|
||||
|
||||
def test_prefetch_single_keyword_still_works(retriever_with_facts):
|
||||
"""Single-term queries (pre-fix working case) remain working."""
|
||||
results = retriever_with_facts.search("compaction")
|
||||
assert len(results) >= 1
|
||||
assert "Compaction" in results[0]["content"] or "compaction" in results[0]["content"].lower()
|
||||
|
||||
|
||||
def test_prefetch_stopword_only_query_empty(retriever_with_facts):
|
||||
"""Pure stopword queries return zero results but don't crash."""
|
||||
# Pass to _sanitize_fts_query directly first so we know what happens
|
||||
assert FactRetriever._sanitize_fts_query("the and of") == "the and of"
|
||||
# search() handles the likely-zero-hit case gracefully
|
||||
results = retriever_with_facts.search("the and of")
|
||||
# Either zero results or it errored-gracefully to [] — both are fine
|
||||
assert isinstance(results, list)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""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_shutdown_is_idempotent_and_safe_without_store(tmp_path):
|
||||
provider = _make_provider(tmp_path)
|
||||
provider.shutdown()
|
||||
# Second shutdown (store already None) must not raise.
|
||||
provider.shutdown()
|
||||
|
||||
# A provider that was never initialized must also shut down cleanly.
|
||||
bare = HolographicMemoryProvider(config={"db_path": str(tmp_path / "x.db")})
|
||||
bare.shutdown()
|
||||
@@ -0,0 +1,243 @@
|
||||
"""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 == {}
|
||||
|
||||
def test_shutdown_keeps_sibling_provider_alive(self, db_path):
|
||||
from plugins.memory.holographic import HolographicMemoryProvider
|
||||
|
||||
a = HolographicMemoryProvider(config={"db_path": str(db_path)})
|
||||
b = HolographicMemoryProvider(config={"db_path": str(db_path)})
|
||||
a.initialize("session-a")
|
||||
b.initialize("session-b")
|
||||
assert MemoryStore._shared[str(db_path)]["refs"] == 2
|
||||
|
||||
a.shutdown()
|
||||
# Sibling still holds a live, writable connection.
|
||||
assert MemoryStore._shared[str(db_path)]["refs"] == 1
|
||||
assert b._store is not None
|
||||
b._store.add_fact("write after sibling shutdown")
|
||||
b.shutdown()
|
||||
assert MemoryStore._shared == {}
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Tests for Mem0Backend abstraction — PlatformBackend, OSSBackend, SelfHostedBackend."""
|
||||
|
||||
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_search_forwards_rerank(self):
|
||||
backend, client = self._make()
|
||||
backend.search("q", filters={}, rerank=False)
|
||||
assert client.calls[0][2]["rerank"] is False
|
||||
|
||||
def test_search_rerank_default_false(self):
|
||||
backend, client = self._make()
|
||||
backend.search("q", filters={})
|
||||
assert client.calls[0][2]["rerank"] is False
|
||||
|
||||
def test_search_returns_list(self):
|
||||
backend, _ = self._make()
|
||||
result = backend.search("q", filters={})
|
||||
assert isinstance(result, list)
|
||||
assert result[0]["id"] == "m1"
|
||||
|
||||
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_add_forwards_metadata_when_present(self):
|
||||
backend, client = self._make()
|
||||
msgs = [{"role": "user", "content": "hi"}]
|
||||
backend.add(
|
||||
msgs,
|
||||
user_id="u1",
|
||||
agent_id="hermes",
|
||||
infer=False,
|
||||
metadata={"channel": "telegram"},
|
||||
)
|
||||
assert client.calls[0][2]["metadata"] == {"channel": "telegram"}
|
||||
|
||||
def test_add_omits_empty_metadata(self):
|
||||
backend, client = self._make()
|
||||
msgs = [{"role": "user", "content": "hi"}]
|
||||
backend.add(msgs, user_id="u1", agent_id="hermes", infer=False, metadata={})
|
||||
assert "metadata" not in client.calls[0][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!"}
|
||||
|
||||
|
||||
class TestOSSBackend:
|
||||
|
||||
def _make(self):
|
||||
memory = FakeOSSMemory()
|
||||
backend = OSSBackend.__new__(OSSBackend)
|
||||
backend._memory = memory
|
||||
return backend, memory
|
||||
|
||||
def test_search_returns_list(self):
|
||||
backend, _ = self._make()
|
||||
result = backend.search("test", filters={"user_id": "u1"})
|
||||
assert isinstance(result, list)
|
||||
assert result[0]["id"] == "m1"
|
||||
|
||||
def test_search_passes_filters(self):
|
||||
backend, memory = self._make()
|
||||
backend.search("q", filters={"user_id": "u1"}, top_k=3)
|
||||
assert memory.calls[0][2]["filters"] == {"user_id": "u1"}
|
||||
assert memory.calls[0][2]["top_k"] == 3
|
||||
|
||||
def test_search_ignores_rerank(self):
|
||||
"""OSS backend accepts rerank param but does not forward it to Memory."""
|
||||
backend, memory = self._make()
|
||||
backend.search("q", filters={}, rerank=True)
|
||||
assert "rerank" not in memory.calls[0][2]
|
||||
|
||||
def test_add_forwards_kwargs(self):
|
||||
backend, memory = self._make()
|
||||
msgs = [{"role": "user", "content": "hi"}]
|
||||
backend.add(msgs, user_id="u1", agent_id="hermes", infer=False)
|
||||
assert memory.calls[0][2]["user_id"] == "u1"
|
||||
assert memory.calls[0][2]["infer"] is False
|
||||
|
||||
def test_update_maps_text_to_data(self):
|
||||
"""OSS Memory.update uses `data=` param, not `text=`."""
|
||||
backend, memory = self._make()
|
||||
backend.update("m1", "new text")
|
||||
assert memory.calls[0][0] == "update"
|
||||
assert memory.calls[0][1] == "m1"
|
||||
assert memory.calls[0][2] == {"data": "new text"}
|
||||
|
||||
def test_delete_positional_arg(self):
|
||||
backend, memory = self._make()
|
||||
backend.delete("m1")
|
||||
assert memory.calls[0] == ("delete", "m1")
|
||||
|
||||
def test_update_normalizes_response(self):
|
||||
backend, _ = self._make()
|
||||
result = backend.update("m1", "text")
|
||||
assert result == {"result": "Memory updated.", "memory_id": "m1"}
|
||||
|
||||
def test_delete_normalizes_response(self):
|
||||
backend, _ = self._make()
|
||||
result = backend.delete("m1")
|
||||
assert result == {"result": "Memory deleted.", "memory_id": "m1"}
|
||||
|
||||
|
||||
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
|
||||
|
||||
def test_init_strips_trailing_slash(self):
|
||||
b = SelfHostedBackend("k", "http://sh:8888/")
|
||||
assert str(b._client.base_url) == "http://sh:8888"
|
||||
|
||||
def test_init_omits_api_key_header_when_blank(self):
|
||||
b = SelfHostedBackend("", "http://sh:8888") # AUTH_DISABLED server
|
||||
assert "x-api-key" not in b._client.headers
|
||||
|
||||
# --- search ----------------------------------------------------------
|
||||
|
||||
def test_search_posts_to_search_with_filters_in_body(self):
|
||||
s = _StubServer()
|
||||
results = _backend(s).search("drink", filters={"user_id": "u1"}, top_k=5)
|
||||
req = s.requests[-1]
|
||||
assert (req.method, req.url.path) == ("POST", "/search")
|
||||
import json
|
||||
body = json.loads(req.content)
|
||||
assert body == {"query": "drink", "top_k": 5, "filters": {"user_id": "u1"}}
|
||||
assert results == [{"id": "m1", "memory": "tea", "score": 0.9}]
|
||||
|
||||
def test_search_sends_x_api_key_header(self):
|
||||
s = _StubServer()
|
||||
_backend(s).search("q", filters={"user_id": "u1"})
|
||||
req = s.requests[-1]
|
||||
assert req.headers["x-api-key"] == "adminkey"
|
||||
assert "authorization" not in req.headers
|
||||
|
||||
# --- add / update / delete ------------------------------------------
|
||||
|
||||
def test_add_posts_messages_and_identity(self):
|
||||
s = _StubServer()
|
||||
msgs = [{"role": "user", "content": "likes tea"}]
|
||||
result = _backend(s).add(msgs, user_id="u1", agent_id="hermes", infer=False, metadata={"channel": "cli"})
|
||||
req = s.requests[-1]
|
||||
assert (req.method, req.url.path) == ("POST", "/memories")
|
||||
import json
|
||||
body = json.loads(req.content)
|
||||
assert body == {"messages": msgs, "user_id": "u1", "agent_id": "hermes",
|
||||
"infer": False, "metadata": {"channel": "cli"}}
|
||||
assert result["results"][0]["id"] == "new"
|
||||
|
||||
def test_update_puts_text_to_memory_id(self):
|
||||
s = _StubServer()
|
||||
result = _backend(s).update("abc", "new text")
|
||||
req = s.requests[-1]
|
||||
assert (req.method, req.url.path) == ("PUT", "/memories/abc")
|
||||
import json
|
||||
assert json.loads(req.content) == {"text": "new text"}
|
||||
assert result == {"result": "Memory updated.", "memory_id": "abc"}
|
||||
|
||||
def test_delete_calls_delete_endpoint(self):
|
||||
s = _StubServer()
|
||||
result = _backend(s).delete("abc")
|
||||
req = s.requests[-1]
|
||||
assert (req.method, req.url.path) == ("DELETE", "/memories/abc")
|
||||
assert result == {"result": "Memory deleted.", "memory_id": "abc"}
|
||||
|
||||
# --- 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,107 @@
|
||||
"""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_embedder_provider_ids(self):
|
||||
assert set(EMBEDDER_PROVIDERS.keys()) == {"openai", "ollama"}
|
||||
|
||||
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_vector_provider_ids(self):
|
||||
assert set(VECTOR_PROVIDERS.keys()) == {"qdrant", "pgvector"}
|
||||
|
||||
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_unknown_embedder_provider(self):
|
||||
cfg = {
|
||||
"llm": {"provider": "openai", "config": {}},
|
||||
"embedder": {"provider": "cohere", "config": {}},
|
||||
"vector_store": {"provider": "qdrant", "config": {}},
|
||||
}
|
||||
errors = validate_oss_config(cfg)
|
||||
assert any("embedder" in e.lower() for e in errors)
|
||||
|
||||
def test_unknown_vector_provider(self):
|
||||
cfg = {
|
||||
"llm": {"provider": "openai", "config": {}},
|
||||
"embedder": {"provider": "openai", "config": {}},
|
||||
"vector_store": {"provider": "redis", "config": {}},
|
||||
}
|
||||
errors = validate_oss_config(cfg)
|
||||
assert any("vector" 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)
|
||||
|
||||
def test_pgvector_with_user_valid(self):
|
||||
cfg = {
|
||||
"llm": {"provider": "openai", "config": {}},
|
||||
"embedder": {"provider": "openai", "config": {}},
|
||||
"vector_store": {"provider": "pgvector", "config": {"host": "localhost", "user": "pg"}},
|
||||
}
|
||||
errors = validate_oss_config(cfg)
|
||||
assert errors == []
|
||||
@@ -0,0 +1,313 @@
|
||||
"""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,
|
||||
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_mode_oss_defaults(self):
|
||||
flags = parse_flags(["--mode", "oss", "--oss-llm-key", "sk-oai"])
|
||||
assert flags["mode"] == "oss"
|
||||
assert flags["oss_llm"] == "openai"
|
||||
assert flags["oss_embedder"] == "openai"
|
||||
assert flags["oss_vector"] == "qdrant"
|
||||
|
||||
def test_mode_oss_all_flags(self):
|
||||
flags = parse_flags([
|
||||
"--mode", "oss",
|
||||
"--oss-llm", "ollama",
|
||||
"--oss-llm-model", "llama3:latest",
|
||||
"--oss-embedder", "ollama",
|
||||
"--oss-embedder-model", "nomic-embed-text",
|
||||
"--oss-vector", "pgvector",
|
||||
"--oss-vector-host", "db.local",
|
||||
"--oss-vector-port", "5433",
|
||||
"--oss-vector-user", "pguser",
|
||||
"--oss-vector-password", "secret",
|
||||
"--oss-vector-dbname", "memdb",
|
||||
"--user-id", "my-user",
|
||||
])
|
||||
assert flags["oss_llm"] == "ollama"
|
||||
assert flags["oss_llm_model"] == "llama3:latest"
|
||||
assert flags["oss_vector"] == "pgvector"
|
||||
assert flags["oss_vector_user"] == "pguser"
|
||||
assert flags["user_id"] == "my-user"
|
||||
|
||||
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["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_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 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
|
||||
|
||||
|
||||
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_platform_setup_clears_stale_host(self, tmp_path, monkeypatch):
|
||||
# A user who previously ran self-hosted has host in mem0.json. Switching
|
||||
# to platform must drop host — otherwise routing (host > platform) keeps
|
||||
# sending them to the self-hosted server despite --mode platform.
|
||||
(tmp_path / "mem0.json").write_text(
|
||||
json.dumps({"mode": "platform", "host": "http://old-selfhosted:8888"})
|
||||
)
|
||||
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)
|
||||
mem0_json = json.loads((tmp_path / "mem0.json").read_text())
|
||||
assert mem0_json["mode"] == "platform"
|
||||
assert not mem0_json.get("host") # cleared to falsy so routing → platform
|
||||
|
||||
def test_oss_flag_mode(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("sys.argv", [
|
||||
"hermes", "--mode", "oss", "--oss-llm-key", "sk-oai",
|
||||
])
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
|
||||
_inject_fake_hermes_cli(monkeypatch)
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup._install_provider_deps", lambda l, e, v: None)
|
||||
config = {"memory": {}}
|
||||
post_setup(str(tmp_path), config)
|
||||
assert config["memory"]["provider"] == "mem0"
|
||||
mem0_json = json.loads((tmp_path / "mem0.json").read_text())
|
||||
assert mem0_json["mode"] == "oss"
|
||||
assert mem0_json["oss"]["llm"]["provider"] == "openai"
|
||||
|
||||
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"
|
||||
|
||||
def test_selfhosted_no_api_key_auth_disabled(self, tmp_path, monkeypatch):
|
||||
# AUTH_DISABLED servers need no key — setup must not write one.
|
||||
monkeypatch.setattr("sys.argv", [
|
||||
"hermes", "--mode", "self-hosted", "--host", "http://mem0.lan:8888",
|
||||
])
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.delenv("MEM0_API_KEY", raising=False)
|
||||
_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 not (tmp_path / ".env").exists()
|
||||
mem0_json = json.loads((tmp_path / "mem0.json").read_text())
|
||||
assert mem0_json["host"] == "http://mem0.lan:8888"
|
||||
|
||||
def test_selfhosted_dry_run_no_files(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("sys.argv", [
|
||||
"hermes", "--mode", "selfhosted",
|
||||
"--host", "http://localhost:8888", "--api-key", "k", "--dry-run",
|
||||
])
|
||||
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 not (tmp_path / ".env").exists()
|
||||
assert not (tmp_path / "mem0.json").exists()
|
||||
assert "provider" not in config["memory"]
|
||||
|
||||
|
||||
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
|
||||
|
||||
def test_dry_run_not_set_by_default(self):
|
||||
flags = parse_flags(["--mode", "oss"])
|
||||
assert flags["dry_run"] is False
|
||||
|
||||
def test_dry_run_platform_no_files(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("sys.argv", ["hermes", "--mode", "platform", "--api-key", "sk-test", "--dry-run"])
|
||||
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 not (tmp_path / ".env").exists()
|
||||
assert not (tmp_path / "mem0.json").exists()
|
||||
assert "provider" not in config["memory"]
|
||||
|
||||
def test_dry_run_oss_no_files(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("sys.argv", [
|
||||
"hermes", "--mode", "oss", "--oss-llm-key", "sk-oai", "--dry-run",
|
||||
])
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup.get_hermes_home", lambda: tmp_path)
|
||||
_inject_fake_hermes_cli(monkeypatch)
|
||||
monkeypatch.setattr("plugins.memory.mem0._setup._install_provider_deps", lambda l, e, v: None)
|
||||
config = {"memory": {}}
|
||||
post_setup(str(tmp_path), config)
|
||||
assert not (tmp_path / ".env").exists()
|
||||
assert not (tmp_path / "mem0.json").exists()
|
||||
assert "provider" not in config["memory"]
|
||||
|
||||
|
||||
class TestConnectivityChecks:
|
||||
|
||||
def test_qdrant_path_writable(self, tmp_path):
|
||||
ok, msg = _check_qdrant_path(str(tmp_path / "qdrant"))
|
||||
assert ok is True
|
||||
|
||||
def test_qdrant_path_not_writable(self, tmp_path, monkeypatch):
|
||||
def _raise_oserror(*a, **kw):
|
||||
raise OSError("Permission denied")
|
||||
monkeypatch.setattr(Path, "mkdir", _raise_oserror)
|
||||
ok, msg = _check_qdrant_path(str(tmp_path / "qdrant"))
|
||||
assert ok is False
|
||||
assert "Permission denied" in msg
|
||||
|
||||
def test_ollama_unreachable(self):
|
||||
ok, msg = _check_ollama("http://localhost:1")
|
||||
assert ok is False
|
||||
|
||||
def test_pgvector_unreachable(self):
|
||||
ok, msg = _check_pgvector("localhost", 1)
|
||||
assert ok is False
|
||||
@@ -0,0 +1,621 @@
|
||||
"""Tests for Mem0 v3 API — new tool names, paginated responses, update/delete tools."""
|
||||
|
||||
import json
|
||||
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_search_uses_filters(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
provider.handle_tool_call("mem0_search", {"query": "hello", "top_k": 3})
|
||||
assert backend.captured[0][2]["filters"] == {"user_id": "u123"}
|
||||
assert backend.captured[0][2]["top_k"] == 3
|
||||
|
||||
def test_search_rerank_default_false(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
provider.handle_tool_call("mem0_search", {"query": "test"})
|
||||
assert backend.captured[0][2]["rerank"] is False
|
||||
|
||||
def test_search_rerank_override_false(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
provider.handle_tool_call("mem0_search", {"query": "test", "rerank": False})
|
||||
assert backend.captured[0][2]["rerank"] is False
|
||||
|
||||
def test_search_rerank_config_default_used_when_arg_absent(self, monkeypatch):
|
||||
"""The persisted mem0.json ``rerank`` preference is the tool default;
|
||||
a per-call arg still wins (see the explicit-False override above)."""
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
provider._rerank_default = True # as initialize() sets from config
|
||||
provider.handle_tool_call("mem0_search", {"query": "test"})
|
||||
assert backend.captured[0][2]["rerank"] is True
|
||||
|
||||
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_add_returns_event_id(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call("mem0_add", {"content": "test"}))
|
||||
assert result["event_id"] == "evt-test-123"
|
||||
|
||||
def test_add_missing_content(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call("mem0_add", {}))
|
||||
assert "error" 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_update_missing_memory_id(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call("mem0_update", {"text": "no id"}))
|
||||
assert "error" in result
|
||||
|
||||
def test_update_missing_text(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call("mem0_update", {"memory_id": "mem-1"}))
|
||||
assert "error" in result
|
||||
|
||||
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."
|
||||
|
||||
def test_delete_missing_memory_id(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call("mem0_delete", {}))
|
||||
assert "error" in result
|
||||
|
||||
|
||||
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
|
||||
|
||||
def test_update_404_no_circuit_breaker(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
backend.update = lambda mid, text: (_ for _ in ()).throw(Exception("404 Not Found"))
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"mem0_update", {"memory_id": "bad-id", "text": "x"}
|
||||
))
|
||||
assert "error" in result
|
||||
assert provider._consecutive_failures == 0
|
||||
|
||||
def test_delete_404_no_circuit_breaker(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
backend.delete = lambda mid: (_ for _ in ()).throw(Exception("404 not found"))
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"mem0_delete", {"memory_id": "bad-id"}
|
||||
))
|
||||
assert "error" in result
|
||||
assert provider._consecutive_failures == 0
|
||||
|
||||
def test_update_validation_error_no_circuit_breaker(self, monkeypatch):
|
||||
"""ValidationError (bad UUID format) should not trip circuit breaker."""
|
||||
class ValidationError(Exception):
|
||||
pass
|
||||
backend = FakeBackend()
|
||||
backend.update = lambda mid, text: (_ for _ in ()).throw(
|
||||
ValidationError('{"error":"memory_id should be a valid UUID"}')
|
||||
)
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"mem0_update", {"memory_id": "not-a-uuid", "text": "x"}
|
||||
))
|
||||
assert "error" in result
|
||||
assert provider._consecutive_failures == 0
|
||||
|
||||
def test_delete_validation_error_no_circuit_breaker(self, monkeypatch):
|
||||
class ValidationError(Exception):
|
||||
pass
|
||||
backend = FakeBackend()
|
||||
backend.delete = lambda mid: (_ for _ in ()).throw(
|
||||
ValidationError('{"error":"memory_id should be a valid UUID"}')
|
||||
)
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
result = json.loads(provider.handle_tool_call(
|
||||
"mem0_delete", {"memory_id": "not-a-uuid"}
|
||||
))
|
||||
assert "error" in result
|
||||
assert provider._consecutive_failures == 0
|
||||
|
||||
def test_update_5xx_trips_circuit_breaker(self, monkeypatch):
|
||||
backend = FakeBackend()
|
||||
backend.update = lambda mid, text: (_ for _ in ()).throw(Exception("500 Internal Server Error"))
|
||||
provider = self._make_provider(monkeypatch, backend)
|
||||
provider.handle_tool_call("mem0_update", {"memory_id": "mem-1", "text": "x"})
|
||||
assert provider._consecutive_failures == 1
|
||||
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
result = json.loads(provider.handle_tool_call("mem0_list", {}))
|
||||
assert "error" in result
|
||||
|
||||
|
||||
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_prefetch_returns_memories_on_first_call(self):
|
||||
# No prior queue_prefetch / warm — the very first call must still recall.
|
||||
backend = FakeBackend(search_results=[{"id": "m1", "memory": "lives in Berlin"}])
|
||||
provider = self._make_provider(backend)
|
||||
result = provider.prefetch("where do I live?")
|
||||
assert "lives in Berlin" 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):
|
||||
class SlowBackend(FakeBackend):
|
||||
def search(self, query, *, filters, top_k=10, rerank=True):
|
||||
time.sleep(0.2)
|
||||
return super().search(query, filters=filters, top_k=top_k, rerank=rerank)
|
||||
|
||||
monkeypatch.setattr(mem0_plugin, "_PREFETCH_WAIT_SECS", 0.01)
|
||||
provider = self._make_provider(
|
||||
SlowBackend(search_results=[{"id": "m1", "memory": "lives in Berlin"}])
|
||||
)
|
||||
started = time.monotonic()
|
||||
assert provider.prefetch("where do I live?") == ""
|
||||
assert time.monotonic() - started < 0.1
|
||||
provider._prefetch_thread.join(timeout=1)
|
||||
assert "lives in Berlin" in provider.prefetch("where do I live?")
|
||||
|
||||
def test_prefetch_empty_results_returns_empty(self):
|
||||
backend = FakeBackend(search_results=[])
|
||||
provider = self._make_provider(backend)
|
||||
assert provider.prefetch("anything") == ""
|
||||
|
||||
def test_prefetch_skips_when_breaker_open(self):
|
||||
backend = FakeBackend(search_results=[{"id": "m1", "memory": "x"}])
|
||||
provider = self._make_provider(backend)
|
||||
provider._consecutive_failures = 5
|
||||
provider._breaker_open_until = float("inf")
|
||||
assert provider.prefetch("q") == ""
|
||||
assert backend.captured == []
|
||||
|
||||
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
|
||||
|
||||
def test_system_prompt_shows_platform_mode(self):
|
||||
provider = Mem0MemoryProvider()
|
||||
provider._user_id = "test"
|
||||
provider._mode = "platform"
|
||||
block = provider.system_prompt_block()
|
||||
assert "platform" in block
|
||||
assert "Rerank" in block
|
||||
|
||||
def test_system_prompt_shows_oss_mode(self):
|
||||
provider = Mem0MemoryProvider()
|
||||
provider._user_id = "test"
|
||||
provider._mode = "oss"
|
||||
block = provider.system_prompt_block()
|
||||
assert "OSS" in block
|
||||
assert "Rerank" not in block
|
||||
|
||||
def test_search_schema_has_rerank(self):
|
||||
"""rerank property available in SEARCH_SCHEMA for platform mode."""
|
||||
provider = Mem0MemoryProvider()
|
||||
schemas = provider.get_tool_schemas()
|
||||
search = next(s for s in schemas if s["name"] == "mem0_search")
|
||||
assert "rerank" in search["parameters"]["properties"]
|
||||
assert search["parameters"]["properties"]["rerank"]["type"] == "boolean"
|
||||
|
||||
|
||||
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
|
||||
|
||||
def test_is_available_oss_needs_vector(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
config_path = tmp_path / "mem0.json"
|
||||
config_path.write_text('{"mode": "oss", "oss": {"vector_store": {"provider": "qdrant"}}}')
|
||||
provider = Mem0MemoryProvider()
|
||||
assert provider.is_available() is True
|
||||
|
||||
def test_is_available_oss_no_vector(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
config_path = tmp_path / "mem0.json"
|
||||
config_path.write_text('{"mode": "oss", "oss": {}}')
|
||||
provider = Mem0MemoryProvider()
|
||||
assert provider.is_available() is False
|
||||
|
||||
def test_tool_schemas_unchanged(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_includes_mode(self):
|
||||
provider = Mem0MemoryProvider()
|
||||
provider._user_id = "test"
|
||||
provider._mode = "oss"
|
||||
block = provider.system_prompt_block()
|
||||
assert "mem0_search" in block
|
||||
assert "OSS" in block
|
||||
|
||||
|
||||
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_unset_and_no_kwargs_falls_back_to_default(self, monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("MEM0_USER_ID", raising=False)
|
||||
provider = self._provider(monkeypatch, tmp_path)
|
||||
provider.initialize("test")
|
||||
assert provider._user_id == "hermes-user"
|
||||
|
||||
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
|
||||
|
||||
def test_add_tool_passes_channel_metadata(self):
|
||||
provider = self._make_provider("telegram")
|
||||
provider.handle_tool_call("mem0_add", {"content": "user likes dark mode"})
|
||||
call = provider._backend.captured[-1]
|
||||
assert call[2]["metadata"] == {"channel": "telegram"}
|
||||
|
||||
def test_sync_turn_passes_channel_metadata(self):
|
||||
provider = self._make_provider("discord")
|
||||
provider.sync_turn("hi", "hello", session_id="s")
|
||||
# sync_turn fires a daemon thread; wait for it.
|
||||
if provider._sync_thread:
|
||||
provider._sync_thread.join(timeout=5.0)
|
||||
adds = [c for c in provider._backend.captured if c[0] == "add"]
|
||||
assert adds, "expected an add call from sync_turn"
|
||||
assert adds[-1][2]["metadata"] == {"channel": "discord"}
|
||||
|
||||
|
||||
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_routes_to_platform_when_no_host(self, monkeypatch):
|
||||
class PB(_SentinelBackend):
|
||||
def __init__(self, api_key):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("plugins.memory.mem0._backend.PlatformBackend", PB)
|
||||
provider = self._provider(monkeypatch, host="")
|
||||
assert isinstance(provider._create_backend(), PB)
|
||||
|
||||
def test_routes_to_oss_when_mode_oss(self, monkeypatch):
|
||||
class OB(_SentinelBackend):
|
||||
def __init__(self, cfg):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("plugins.memory.mem0._backend.OSSBackend", OB)
|
||||
provider = self._provider(monkeypatch, mode="oss")
|
||||
assert isinstance(provider._create_backend(), OB)
|
||||
|
||||
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"
|
||||
|
||||
def test_is_available_true_with_host_only(self, monkeypatch):
|
||||
monkeypatch.delenv("MEM0_API_KEY", raising=False)
|
||||
monkeypatch.setenv("MEM0_MODE", "platform")
|
||||
monkeypatch.setenv("MEM0_HOST", "http://localhost:8888")
|
||||
assert Mem0MemoryProvider().is_available() is True
|
||||
|
||||
def test_is_available_false_without_key_or_host(self, monkeypatch):
|
||||
monkeypatch.delenv("MEM0_API_KEY", raising=False)
|
||||
monkeypatch.delenv("MEM0_HOST", raising=False)
|
||||
monkeypatch.setenv("MEM0_MODE", "platform")
|
||||
assert Mem0MemoryProvider().is_available() is False
|
||||
@@ -0,0 +1,254 @@
|
||||
"""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."
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("feature", MEMORY_FEATURES)
|
||||
def test_feature_specs_pass_safety(self, feature):
|
||||
for spec in ld.LAZY_DEPS[feature]:
|
||||
assert ld._spec_is_safe(spec), f"{feature}: {spec!r} fails safety"
|
||||
|
||||
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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import agent.file_safety as fs
|
||||
|
||||
from plugins.memory.retaindb import RetainDBMemoryProvider
|
||||
|
||||
|
||||
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"
|
||||
@@ -0,0 +1,625 @@
|
||||
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"):
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.container_tag = container_tag
|
||||
self.search_mode = search_mode
|
||||
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_is_available_true_when_import_missing_but_key_set(monkeypatch):
|
||||
# Regression: is_available() must NOT gate on the supermemory SDK being
|
||||
# importable. The SDK is lazy-installed at client construction (see
|
||||
# _SupermemoryClient.__init__ -> tools.lazy_deps.ensure). Gating here is a
|
||||
# chicken-and-egg trap: on a sealed Docker venv the package isn't present
|
||||
# until ensure() runs, but ensure() only runs once the provider loads —
|
||||
# which this gates. So with the key set and the SDK absent, the provider
|
||||
# must still report available. Mirrors honcho/mem0 (config-presence only).
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
|
||||
import builtins
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "supermemory" or name.startswith("supermemory."):
|
||||
raise ImportError("missing")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
p = SupermemoryMemoryProvider()
|
||||
assert p.is_available() is True
|
||||
|
||||
|
||||
def test_is_available_false_without_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_prefetch_skips_profile_between_frequency(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(2, "next")
|
||||
result = provider.prefetch("what am I working on?")
|
||||
assert "Relevant Memories" in result
|
||||
assert "User Profile (Persistent)" not 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_sync_turn_buffers_cleaned_exchange(provider):
|
||||
provider.sync_turn(
|
||||
"Please remember this\n<supermemory-context>ignore</supermemory-context>",
|
||||
"Got it, storing the context",
|
||||
session_id="session-1",
|
||||
)
|
||||
assert len(provider._session_turns) == 1
|
||||
turn = provider._session_turns[0]
|
||||
assert "ignore" not in turn["user"]
|
||||
assert turn["user"].startswith("Please remember this")
|
||||
assert turn["assistant"] == "Got it, storing the context"
|
||||
# Buffering only — no per-turn writes to the client
|
||||
assert provider._client.add_calls == []
|
||||
assert provider._client.ingest_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_on_memory_write_tracks_thread(provider):
|
||||
provider.on_memory_write("add", "memory", "Jordan likes concise docs")
|
||||
assert provider._write_thread is not None
|
||||
provider._write_thread.join(timeout=1)
|
||||
assert len(provider._client.add_calls) == 1
|
||||
assert provider._client.add_calls[0]["metadata"]["type"] == "explicit_memory"
|
||||
|
||||
|
||||
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_forget_tool_by_query(provider):
|
||||
provider._client.forget_by_query_response = {"success": True, "message": "Forgot one", "id": "m7"}
|
||||
result = json.loads(provider.handle_tool_call("supermemory_forget", {"query": "that thing"}))
|
||||
assert result["success"] is True
|
||||
assert result["id"] == "m7"
|
||||
|
||||
|
||||
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_identity_template_default_profile(monkeypatch, tmp_path):
|
||||
"""Without agent_identity kwarg, {identity} resolves to 'default'."""
|
||||
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")
|
||||
assert p._container_tag == "hermes_default"
|
||||
|
||||
|
||||
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_search_mode_config_passed_to_client(monkeypatch, tmp_path):
|
||||
"""search_mode from config is passed to _SupermemoryClient."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
_save_supermemory_config({"search_mode": "memories"}, str(tmp_path))
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
|
||||
assert p._search_mode == "memories"
|
||||
assert p._client.search_mode == "memories"
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
# -- 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_multi_container_enabled_adds_schema_param(monkeypatch, tmp_path):
|
||||
"""When enabled, tool schemas include container_tag parameter."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
_save_supermemory_config({
|
||||
"enable_custom_container_tags": True,
|
||||
"custom_containers": ["project-alpha", "shared"],
|
||||
}, str(tmp_path))
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
|
||||
assert p._enable_custom_containers is True
|
||||
assert p._allowed_containers == ["hermes", "project_alpha", "shared"]
|
||||
schemas = p.get_tool_schemas()
|
||||
for s in schemas:
|
||||
assert "container_tag" in s["parameters"]["properties"]
|
||||
|
||||
|
||||
def test_multi_container_tool_store_with_custom_tag(monkeypatch, tmp_path):
|
||||
"""supermemory_store uses the resolved container_tag when multi-container is enabled."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
_save_supermemory_config({
|
||||
"enable_custom_container_tags": True,
|
||||
"custom_containers": ["project-alpha"],
|
||||
}, str(tmp_path))
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
|
||||
result = json.loads(p.handle_tool_call("supermemory_store", {
|
||||
"content": "test memory",
|
||||
"container_tag": "project-alpha",
|
||||
}))
|
||||
assert result["saved"] is True
|
||||
assert result["container_tag"] == "project_alpha"
|
||||
assert p._client.add_calls[-1]["container_tag"] == "project_alpha"
|
||||
|
||||
|
||||
def test_multi_container_rejects_unlisted_tag(monkeypatch, tmp_path):
|
||||
"""Tool calls with a non-whitelisted container_tag return an error."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
_save_supermemory_config({
|
||||
"enable_custom_container_tags": True,
|
||||
"custom_containers": ["allowed-tag"],
|
||||
}, str(tmp_path))
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
|
||||
result = json.loads(p.handle_tool_call("supermemory_store", {
|
||||
"content": "test",
|
||||
"container_tag": "forbidden-tag",
|
||||
}))
|
||||
assert "error" in result
|
||||
assert "not allowed" in result["error"]
|
||||
|
||||
|
||||
def test_multi_container_system_prompt_includes_instructions(monkeypatch, tmp_path):
|
||||
"""system_prompt_block includes container list and instructions when multi-container is enabled."""
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
_save_supermemory_config({
|
||||
"enable_custom_container_tags": True,
|
||||
"custom_containers": ["docs"],
|
||||
"custom_container_instructions": "Use docs for documentation context.",
|
||||
}, str(tmp_path))
|
||||
p = SupermemoryMemoryProvider()
|
||||
p.initialize("s1", hermes_home=str(tmp_path), platform="cli")
|
||||
block = p.system_prompt_block()
|
||||
assert "Multi-container mode enabled" in block
|
||||
assert "docs" in block
|
||||
assert "Use docs for documentation context." in block
|
||||
|
||||
|
||||
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_format_connection_summary_ok():
|
||||
summary = _format_connection_summary({
|
||||
"ok": True,
|
||||
"container_tag": "hermes_coder",
|
||||
"profile_facts": 12,
|
||||
"auto_recall": True,
|
||||
"auto_capture": False,
|
||||
})
|
||||
assert "✓ Connected" in summary
|
||||
assert "container: hermes_coder" in summary
|
||||
assert "12 profile facts" in summary
|
||||
assert "auto_recall on" in summary
|
||||
assert "auto_capture off" in summary
|
||||
|
||||
|
||||
def test_format_connection_summary_single_fact_and_error():
|
||||
one = _format_connection_summary({
|
||||
"ok": True,
|
||||
"container_tag": "hermes",
|
||||
"profile_facts": 1,
|
||||
"auto_recall": True,
|
||||
"auto_capture": True,
|
||||
})
|
||||
assert "1 profile fact" in one
|
||||
assert "1 profile facts" not in one
|
||||
|
||||
err = _format_connection_summary({
|
||||
"ok": False,
|
||||
"error": "invalid API key",
|
||||
"container_tag": "hermes",
|
||||
"auto_recall": True,
|
||||
"auto_capture": True,
|
||||
})
|
||||
assert "✗ invalid API key" in err
|
||||
assert "container: hermes" in err
|
||||
|
||||
|
||||
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_probe_supermemory_connection_success(monkeypatch, tmp_path):
|
||||
_stub_supermemory_importable(monkeypatch)
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
|
||||
class CountingClient(FakeClient):
|
||||
def get_profile(self, query=None, *, container_tag=None):
|
||||
return {
|
||||
"static": ["Prefers TypeScript"],
|
||||
"dynamic": ["", "Working on Hermes"],
|
||||
"search_results": [],
|
||||
}
|
||||
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", CountingClient)
|
||||
status = _probe_supermemory_connection("test-key", str(tmp_path))
|
||||
assert status["ok"] is True
|
||||
assert status["profile_facts"] == 2
|
||||
assert status["auto_recall"] is True
|
||||
|
||||
|
||||
def test_probe_supermemory_connection_client_error(monkeypatch, tmp_path):
|
||||
_stub_supermemory_importable(monkeypatch)
|
||||
|
||||
class BrokenClient(FakeClient):
|
||||
def get_profile(self, query=None, *, container_tag=None):
|
||||
raise RuntimeError("API unavailable")
|
||||
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", BrokenClient)
|
||||
status = _probe_supermemory_connection("test-key", str(tmp_path))
|
||||
assert status["ok"] is False
|
||||
assert "API unavailable" in status["error"]
|
||||
|
||||
|
||||
def test_get_status_config_returns_summary(monkeypatch, tmp_path):
|
||||
_stub_supermemory_importable(monkeypatch)
|
||||
monkeypatch.setenv("SUPERMEMORY_API_KEY", "test-key")
|
||||
monkeypatch.setattr("plugins.memory.supermemory._SupermemoryClient", FakeClient)
|
||||
monkeypatch.setattr(
|
||||
"hermes_constants.get_hermes_home",
|
||||
lambda: tmp_path,
|
||||
)
|
||||
result = SupermemoryMemoryProvider().get_status_config({})
|
||||
assert "summary" in result
|
||||
assert "✓ Connected" in result["summary"]
|
||||
assert "container: hermes" in result["summary"]
|
||||
|
||||
|
||||
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)}"
|
||||
Reference in New Issue
Block a user