Files
OpenJarvis/tests/memory/test_memory_service.py
T
Elliot SluskyandGitHub 433d10db5e feat(memory): native persistent memory service integrated into core (#579)
Adds the openjarvis.memory package (LocalFactStore, FactExtractor, background MemoryService), starts/stops it in the jarvis serve and jarvis chat lifecycle, feeds completed non-streaming exchanges to it, adds [memory] config support, and adds jarvis memory list/clear CLI commands. Extraction runs on a background thread and degrades to a no-op on any failure (BrokenPipe, timeouts, unparseable output) so it can never block a reply or crash the host. Disabled by default. Closes #393, #571, #572, #573.
2026-06-22 13:59:02 -07:00

172 lines
5.3 KiB
Python

"""Tests for the background memory service (openjarvis.memory.service)."""
from __future__ import annotations
import threading
import time
from types import SimpleNamespace
from openjarvis.core.config import StorageConfig
from openjarvis.memory.service import MemoryService, build_memory_service
from openjarvis.memory.store import LocalFactStore
def _wait_until(predicate, timeout=2.0, interval=0.01):
deadline = time.time() + timeout
while time.time() < deadline:
if predicate():
return True
time.sleep(interval)
return predicate()
class FakeExtractor:
"""Extractor stub with controllable output, blocking and failures."""
def __init__(self, facts=None, *, raises=None, gate=None):
self._facts = facts or []
self._raises = raises
self._gate = gate # optional threading.Event to block on
self.calls = []
def extract(self, user_text, assistant_text=""):
self.calls.append((user_text, assistant_text))
if self._gate is not None:
self._gate.wait(timeout=2.0)
if self._raises is not None:
raise self._raises
return list(self._facts)
def _service(tmp_path, extractor, **kwargs):
store = LocalFactStore(tmp_path / "facts.jsonl")
return MemoryService(store, extractor, **kwargs)
def test_start_stop_lifecycle(tmp_path):
svc = _service(tmp_path, FakeExtractor())
assert svc.is_running is False
svc.start()
assert svc.is_running is True
svc.start() # idempotent
assert svc.is_running is True
svc.stop()
assert svc.is_running is False
svc.stop() # idempotent
def test_submit_extracts_and_stores(tmp_path):
extractor = FakeExtractor(["User likes hiking"])
svc = _service(tmp_path, extractor)
svc.start()
try:
assert svc.submit("I love hiking", "Nice!") is True
assert _wait_until(lambda: svc.fact_count() == 1)
assert [f.text for f in svc.list_facts()] == ["User likes hiking"]
finally:
svc.stop()
def test_submit_when_not_running_is_dropped(tmp_path):
extractor = FakeExtractor(["x"])
svc = _service(tmp_path, extractor)
assert svc.submit("hi", "there") is False
assert extractor.calls == []
def test_submit_empty_user_text_dropped(tmp_path):
extractor = FakeExtractor(["x"])
svc = _service(tmp_path, extractor)
svc.start()
try:
assert svc.submit(" ", "y") is False
finally:
svc.stop()
def test_worker_survives_extractor_broken_pipe(tmp_path):
"""A BrokenPipeError in one job must not kill the worker."""
extractor = FakeExtractor(raises=BrokenPipeError("client gone"))
svc = _service(tmp_path, extractor)
svc.start()
try:
svc.submit("first", "a")
assert _wait_until(lambda: len(extractor.calls) == 1)
# Service is still alive and accepting work.
assert svc.is_running is True
assert svc.submit("second", "b") is True
assert _wait_until(lambda: len(extractor.calls) == 2)
finally:
svc.stop()
def test_worker_survives_generic_exception(tmp_path):
extractor = FakeExtractor(raises=RuntimeError("boom"))
svc = _service(tmp_path, extractor)
svc.start()
try:
svc.submit("x", "y")
assert _wait_until(lambda: len(extractor.calls) == 1)
assert svc.is_running is True
finally:
svc.stop()
def test_submit_returns_false_when_queue_full(tmp_path):
"""Backpressure: a full queue drops work instead of blocking the caller."""
gate = threading.Event()
extractor = FakeExtractor(["fact"], gate=gate)
svc = _service(tmp_path, extractor, max_queue=1)
svc.start()
try:
# First submit is pulled by the worker and blocks on the gate.
assert svc.submit("job1", "a") is True
assert _wait_until(lambda: len(extractor.calls) == 1)
# Fill the (size-1) queue, then the next submit must be dropped.
assert svc.submit("job2", "b") is True
dropped = svc.submit("job3", "c")
assert dropped is False
finally:
gate.set()
svc.stop()
def test_build_memory_service_disabled_returns_none(tmp_path):
cfg = SimpleNamespace(memory=StorageConfig(enabled=False))
assert build_memory_service(cfg, object(), "model") is None
def test_build_memory_service_no_engine_returns_none(tmp_path):
cfg = SimpleNamespace(memory=StorageConfig(enabled=True))
assert build_memory_service(cfg, None, "model") is None
def test_build_memory_service_no_model_returns_none(tmp_path):
cfg = SimpleNamespace(memory=StorageConfig(enabled=True, extraction_model=""))
assert build_memory_service(cfg, object(), "") is None
def test_build_memory_service_enabled(tmp_path):
cfg = SimpleNamespace(
memory=StorageConfig(
enabled=True,
extraction_model="qwen3:14b",
facts_path=str(tmp_path / "facts.jsonl"),
max_facts=10,
)
)
svc = build_memory_service(cfg, object(), "fallback-model")
assert isinstance(svc, MemoryService)
def test_build_memory_service_falls_back_to_default_model(tmp_path):
cfg = SimpleNamespace(
memory=StorageConfig(
enabled=True,
extraction_model="",
facts_path=str(tmp_path / "facts.jsonl"),
)
)
svc = build_memory_service(cfg, object(), "active-model")
assert isinstance(svc, MemoryService)