ReMe/tests/unit/test_hermes_agent_integration.py
jinliyl 1be61b1e4c
feat(integrations): add dual-mode Hermes memory provider (#533)
* feat(integrations): add dual-mode Hermes memory provider

* style(integrations): apply repository formatting

* fix(integrations): address Hermes provider review

* fix(integrations): bound embedded recall startup cleanup

* fix(integrations): finish embedded application cleanup

* docs(integrations): expand Hermes verification guide
2026-09-11 12:25:18 +08:00

569 lines
18 KiB
Python

"""Focused tests for the external Hermes memory-provider plugin."""
# pylint: disable=missing-class-docstring,missing-function-docstring
# pylint: disable=protected-access,wrong-import-position,unused-import
from __future__ import annotations
import sys
import types
import asyncio
import importlib
import threading
import time
from dataclasses import dataclass
from pathlib import Path
import pytest
_PLUGIN_PARENT = Path(__file__).resolve().parents[2] / "integrations"
if str(_PLUGIN_PARENT) not in sys.path:
sys.path.insert(0, str(_PLUGIN_PARENT))
try:
import agent.memory_provider # type: ignore[import-not-found] # noqa: F401
except ImportError:
agent_module = types.ModuleType("agent")
memory_provider_module = types.ModuleType("agent.memory_provider")
class MemoryProvider:
"""Minimal Hermes contract used when hermes-agent is not installed."""
@dataclass(frozen=True)
class RecallStatus:
provider_label: str
count: int
glyph: str = "🧠"
memory_provider_module.MemoryProvider = MemoryProvider
memory_provider_module.RecallStatus = RecallStatus
agent_module.memory_provider = memory_provider_module
sys.modules["agent"] = agent_module
sys.modules["agent.memory_provider"] = memory_provider_module
PLUGIN_MODULE = importlib.import_module("hermes_agent")
BACKEND_MODULE = importlib.import_module("hermes_agent.backend")
CONFIG_MODULE = importlib.import_module("hermes_agent.config")
EMBEDDED_MODULE = importlib.import_module("hermes_agent.embedded_backend")
CLIENT_MODULE = importlib.import_module("hermes_agent.client")
ReMeMemoryProvider = PLUGIN_MODULE.ReMeMemoryProvider
ReMeBackendError = BACKEND_MODULE.ReMeBackendError
ReMeConfig = CONFIG_MODULE.ReMeConfig
ReMeConfigError = CONFIG_MODULE.ReMeConfigError
config_path = CONFIG_MODULE.config_path
load_config = CONFIG_MODULE.load_config
parse_config = CONFIG_MODULE.parse_config
save_config = CONFIG_MODULE.save_config
EmbeddedReMeBackend = EMBEDDED_MODULE.EmbeddedReMeBackend
ReMeHttpClient = CLIENT_MODULE.ReMeHttpClient
scoped_session_id = PLUGIN_MODULE._scoped_session_id
def test_config_defaults_to_http(tmp_path):
config = load_config(tmp_path)
assert config.mode == "http"
assert config.endpoint == "http://127.0.0.1:2333"
def test_provider_schema_exposes_mode_specific_and_advanced_fields():
fields = {field["key"]: field for field in ReMeMemoryProvider().get_config_schema()}
assert set(fields) == {
"mode",
"endpoint",
"workspace_dir",
"reme_config",
"recall_limit",
"recall_timeout",
"request_timeout",
"health_timeout",
"health_retry_seconds",
"shutdown_timeout",
}
assert fields["endpoint"]["when"] == {"mode": "http"}
assert fields["workspace_dir"]["when"] == {"mode": "embedded"}
def test_current_config_precedes_legacy(tmp_path):
(tmp_path / "reme.json").write_text(
'{"endpoint": "http://legacy:1"}',
encoding="utf-8",
)
current = config_path(tmp_path)
current.parent.mkdir()
current.write_text('{"endpoint": "http://current:2"}', encoding="utf-8")
assert load_config(tmp_path).endpoint == "http://current:2"
def test_sparse_dashboard_config_inherits_legacy_values(tmp_path):
(tmp_path / "reme.json").write_text(
'{"endpoint": "http://legacy:2444", "recall_limit": 3}',
encoding="utf-8",
)
current = config_path(tmp_path)
current.parent.mkdir()
current.write_text('{"recall_limit": 7}', encoding="utf-8")
config = load_config(tmp_path)
assert config.endpoint == "http://legacy:2444"
assert config.recall_limit == 7
def test_embedded_config_normalizes_workspace(tmp_path):
config = parse_config(
{"mode": " EMBEDDED ", "workspace_dir": str(tmp_path / "workspace")},
hermes_home=tmp_path,
)
assert config.mode == "embedded"
assert config.workspace_dir == str((tmp_path / "workspace").absolute())
def test_embedded_config_requires_workspace(tmp_path):
with pytest.raises(ReMeConfigError, match="workspace_dir"):
parse_config({"mode": "embedded"}, hermes_home=tmp_path)
@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")])
def test_config_rejects_non_finite_timeouts(tmp_path, value):
with pytest.raises(ReMeConfigError, match="positive number"):
parse_config({"request_timeout": value}, hermes_home=tmp_path)
@pytest.mark.parametrize(
"endpoint",
[
"http://user:secret@127.0.0.1:2333",
"http://127.0.0.1:2333?token=secret",
"http://127.0.0.1:2333#fragment",
"http://127.0.0.1:invalid",
],
)
def test_config_and_client_reject_unsafe_endpoint_shapes(tmp_path, endpoint):
with pytest.raises(ReMeConfigError, match="absolute http"):
parse_config({"endpoint": endpoint}, hermes_home=tmp_path)
with pytest.raises(ValueError, match="absolute http"):
ReMeHttpClient(endpoint, timeout=1)
def test_save_config_uses_dashboard_layout_and_private_permissions(tmp_path):
saved = save_config({"mode": "http", "recall_limit": 7}, tmp_path)
path = config_path(tmp_path)
assert saved.recall_limit == 7
assert path.is_file()
assert path.stat().st_mode & 0o777 == 0o600
class _FakeResponse:
def __init__(self, answer="", metadata=None, success=True):
self.answer = answer
self.metadata = metadata or {}
self.success = success
def model_dump(self):
return {
"answer": self.answer,
"metadata": self.metadata,
"success": self.success,
}
class _FakeApplication:
instances = []
def __init__(self, **config):
self.config = config
self.started = False
self.closed = False
self.calls = []
self.__class__.instances.append(self)
async def start(self):
self.started = True
async def close(self):
self.closed = True
async def run_job(self, name, **kwargs):
self.calls.append((name, kwargs))
if name == "health_check":
return _FakeResponse(metadata={"health": {"healthy": True}})
return _FakeResponse(answer=name, metadata={"counts": {"returned": 1}})
def test_embedded_backend_owns_application_lifecycle(monkeypatch, tmp_path):
import reme
import reme.config
_FakeApplication.instances.clear()
monkeypatch.setattr(reme, "Application", _FakeApplication)
monkeypatch.setattr(
reme.config,
"resolve_app_config",
lambda **kwargs: {
"workspace_dir": kwargs["workspace_dir"],
"marker": kwargs["config"],
},
)
backend = EmbeddedReMeBackend(str(tmp_path), start_timeout=2)
backend.start()
assert backend.health(timeout=2)["success"] is True
assert backend.search("needle", limit=3, timeout=2)["answer"] == "search"
backend.auto_memory("session", [{"role": "user", "content": "hello"}], timeout=2)
backend.close(timeout=2)
app = _FakeApplication.instances[0]
assert app.started is True
assert app.closed is True
assert app.config == {
"workspace_dir": str(tmp_path),
"marker": "default",
}
assert [name for name, _ in app.calls] == ["health_check", "search", "auto_memory"]
assert backend.state == "closed"
class _FakeBackend:
label = "fake"
def __init__(self):
self.writes = []
self.closed = False
def start(self):
return None
def health(self, *, timeout):
del timeout
return {"success": True, "metadata": {"health": {"healthy": True}}}
def search(self, query, *, limit, timeout):
del query, limit, timeout
return {
"success": True,
"answer": " remembered ",
"metadata": {"counts": {"returned": 2}},
}
def auto_memory(self, session_id, messages, *, timeout):
del timeout
self.writes.append((session_id, messages))
return {"success": True}
def close(self, *, timeout):
del timeout
self.closed = True
def test_provider_selects_backend_recalls_and_writes(monkeypatch, tmp_path):
backend = _FakeBackend()
monkeypatch.setattr(PLUGIN_MODULE, "load_config", lambda home=None: ReMeConfig())
monkeypatch.setattr(PLUGIN_MODULE, "_backend_for", lambda config: backend)
provider = ReMeMemoryProvider()
provider.initialize("session/one", hermes_home=tmp_path, agent_identity="work")
assert provider.prefetch("project decision") == "remembered"
assert provider.recall_status().count == 2
provider.sync_turn("hello", "hi")
provider.shutdown()
assert len(backend.writes) == 1
assert backend.writes[0][0].startswith("hermes-work-session-one-")
assert backend.closed is True
def test_session_scope_distinguishes_profiles_and_ambiguous_names():
assert scoped_session_id("profile/a", "session") != scoped_session_id(
"profile-a",
"session",
)
assert scoped_session_id("profile", "session/a") != scoped_session_id(
"profile",
"session-a",
)
def test_reinitialize_closes_previous_backend(monkeypatch, tmp_path):
backends = [_FakeBackend(), _FakeBackend()]
monkeypatch.setattr(PLUGIN_MODULE, "load_config", lambda home=None: ReMeConfig())
monkeypatch.setattr(PLUGIN_MODULE, "_backend_for", lambda config: backends.pop(0))
provider = ReMeMemoryProvider()
provider.initialize("first", hermes_home=tmp_path)
first = provider._backend
provider.initialize("second", hermes_home=tmp_path)
assert first is not None and first.closed is True
assert provider._backend is not first
provider.shutdown()
def test_provider_failure_does_not_escape_model_path(monkeypatch, tmp_path):
backend = _FakeBackend()
backend.search = lambda *args, **kwargs: (_ for _ in ()).throw(
ReMeBackendError("offline"),
)
monkeypatch.setattr(PLUGIN_MODULE, "load_config", lambda home=None: ReMeConfig())
monkeypatch.setattr(PLUGIN_MODULE, "_backend_for", lambda config: backend)
provider = ReMeMemoryProvider()
provider.initialize("session", hermes_home=tmp_path)
assert provider.prefetch("query") == ""
assert provider.recall_status() is None
provider.shutdown()
def test_provider_reports_embedded_workspace_for_hermes_backup(monkeypatch, tmp_path):
workspace = tmp_path / "workspace"
monkeypatch.setattr(
PLUGIN_MODULE,
"load_config",
lambda home=None: ReMeConfig(mode="embedded", workspace_dir=str(workspace)),
)
assert ReMeMemoryProvider().backup_paths() == [str(workspace)]
def test_backend_creation_is_serialized(monkeypatch):
provider = ReMeMemoryProvider()
provider._config = ReMeConfig()
created = []
def factory(config):
del config
time.sleep(0.05)
backend = _FakeBackend()
created.append(backend)
return backend
monkeypatch.setattr(PLUGIN_MODULE, "_backend_for", factory)
gate = threading.Barrier(3)
results = []
def ensure():
gate.wait()
results.append(provider._ensure_backend())
threads = [threading.Thread(target=ensure) for _ in range(2)]
for thread in threads:
thread.start()
gate.wait()
for thread in threads:
thread.join(timeout=2)
assert results == [True, True]
assert len(created) == 1
provider.shutdown()
def test_shutdown_defers_close_until_inflight_recall_finishes():
entered = threading.Event()
release = threading.Event()
class BlockingBackend(_FakeBackend):
def search(self, query, *, limit, timeout):
del query, limit, timeout
entered.set()
release.wait(timeout=2)
return {"success": True, "answer": "remembered", "metadata": {}}
backend = BlockingBackend()
provider = ReMeMemoryProvider()
provider._config = ReMeConfig()
provider._backend = backend
provider._backend_available = True
provider._shutdown_timeout = 0.05
recall = threading.Thread(target=provider.prefetch, args=("query",))
recall.start()
assert entered.wait(timeout=1)
provider.shutdown()
assert backend.closed is False
release.set()
recall.join(timeout=2)
assert recall.is_alive() is False
assert backend.closed is True
def test_shutdown_drains_all_accepted_writes_before_closing_backend():
entered = threading.Event()
release = threading.Event()
class BlockingBackend(_FakeBackend):
def auto_memory(self, session_id, messages, *, timeout):
del timeout
self.writes.append((session_id, messages))
if len(self.writes) == 1:
entered.set()
release.wait(timeout=2)
backend = BlockingBackend()
provider = ReMeMemoryProvider()
provider._config = ReMeConfig()
provider._backend = backend
provider._backend_available = True
provider._shutdown_timeout = 1
for index in range(3):
provider.sync_turn(f"user {index}", f"assistant {index}", session_id=f"session-{index}")
assert entered.wait(timeout=1)
shutdown = threading.Thread(target=provider.shutdown)
shutdown.start()
deadline = time.monotonic() + 1
while not provider._shutdown_started and time.monotonic() < deadline:
time.sleep(0.001)
assert provider._shutdown_started is True
release.set()
shutdown.join(timeout=2)
assert shutdown.is_alive() is False
assert len(backend.writes) == 3
assert backend.closed is True
def test_recall_timeout_includes_waiting_for_background_write():
write_entered = threading.Event()
write_release = threading.Event()
search_entered = threading.Event()
class BlockingBackend(_FakeBackend):
def auto_memory(self, session_id, messages, *, timeout):
del session_id, messages, timeout
write_entered.set()
write_release.wait(timeout=2)
def search(self, query, *, limit, timeout):
del query, limit, timeout
search_entered.set()
return {"success": True, "answer": "remembered", "metadata": {}}
backend = BlockingBackend()
provider = ReMeMemoryProvider()
provider._config = ReMeConfig()
provider._backend = backend
provider._backend_available = True
provider._recall_timeout = 0.05
provider.sync_turn("user", "assistant", session_id="session")
assert write_entered.wait(timeout=1)
started = time.monotonic()
assert provider.prefetch("query") == ""
elapsed = time.monotonic() - started
assert elapsed < 0.2
assert search_entered.is_set() is False
write_release.set()
provider.shutdown()
def test_recall_timeout_includes_embedded_startup_failure_cleanup(monkeypatch, tmp_path):
import reme
import reme.config
class SlowApplication:
instances = []
def __init__(self, **config):
del config
self.closed = threading.Event()
self.thread_pool = object()
self.__class__.instances.append(self)
async def start(self):
time.sleep(0.25)
async def close(self):
await asyncio.sleep(0.4)
self.thread_pool = None
self.closed.set()
monkeypatch.setattr(reme, "Application", SlowApplication)
monkeypatch.setattr(reme.config, "resolve_app_config", lambda **kwargs: kwargs)
backend = EmbeddedReMeBackend(str(tmp_path), start_timeout=1)
monkeypatch.setattr(PLUGIN_MODULE, "_backend_for", lambda config: backend)
provider = ReMeMemoryProvider()
provider._config = ReMeConfig(
mode="embedded",
workspace_dir=str(tmp_path),
request_timeout=1,
recall_timeout=0.15,
shutdown_timeout=0.6,
)
provider._recall_timeout = 0.15
provider._shutdown_timeout = 0.6
started = time.monotonic()
assert provider.prefetch("query") == ""
elapsed = time.monotonic() - started
assert elapsed < 0.25
assert backend._thread is not None
backend._thread.join(timeout=1)
assert backend._thread.is_alive() is False
app = SlowApplication.instances[0]
assert app.closed.is_set()
assert app.thread_pool is None
def test_embedded_start_timeout_closes_real_application_resources(monkeypatch, tmp_path):
import reme
import reme.config
from reme.application import Application
from reme.components import BaseComponent
component_closed = threading.Event()
class SlowComponent(BaseComponent):
component_type = "slow_test"
async def _start(self):
time.sleep(0.25)
async def _close(self):
component_closed.set()
app = Application(
workspace_dir=str(tmp_path),
service={"backend": "cli"},
thread_pool_max_workers=1,
enable_logo=False,
log_to_console=False,
log_to_file=False,
)
component = SlowComponent(name="slow", app_context=app.context)
app.context.components["slow_test"] = {"slow": component}
monkeypatch.setattr(reme, "Application", lambda **config: app)
monkeypatch.setattr(reme.config, "resolve_app_config", lambda **kwargs: kwargs)
backend = EmbeddedReMeBackend(str(tmp_path), start_timeout=1)
started = time.monotonic()
with pytest.raises(ReMeBackendError, match="timed out"):
backend.start(deadline=started + 0.15)
elapsed = time.monotonic() - started
assert elapsed < 0.25
assert backend._thread is not None
backend._thread.join(timeout=1)
assert backend._thread.is_alive() is False
assert component_closed.is_set()
assert component.is_started is False
assert app.context.thread_pool is None
def test_shutdown_discard_keeps_sentinel_for_inflight_writer():
provider = ReMeMemoryProvider()
provider._write_queue.put({"session_id": "queued"})
provider._write_queue.put(None)
assert provider._discard_queued_writes() == 1
assert provider._write_queue.get_nowait() is None