Files
OpenJarvis/tests/memory/test_memory_service.py
T
eb2b612c7c fix(memory): address service follow-ups (#591)
Closes #582. Route fact-store construction through a new FactStoreRegistry (local backend registered by default); align the default facts path with get_config_dir(); wire completed chat exchanges (streamed and non-streamed) through the EventBus so the memory service captures them consistently; reload the local fact store from disk before operations so external clears don't resurrect stale facts; make the affected config/persona/memory/CLI/route tests hermetic; and refresh uv.lock with the current resolver (locks pytest-xdist + transitive deps, drops py3.14 artifacts since the project constrains Python <3.14).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:58:49 -07:00

209 lines
6.4 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.core.events import EventBus
from openjarvis.memory.service import (
MemoryService,
build_memory_service,
publish_completed_exchange,
)
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_completed_exchange_event_extracts_and_stores(tmp_path):
bus = EventBus(record_history=True)
extractor = FakeExtractor(["User likes jazz"])
store = LocalFactStore(tmp_path / "facts.jsonl")
svc = MemoryService(store, extractor, event_bus=bus)
svc.start()
try:
assert publish_completed_exchange(
bus,
"I like jazz",
"Noted.",
source="test",
)
assert _wait_until(lambda: svc.fact_count() == 1)
assert extractor.calls == [("I like jazz", "Noted.")]
finally:
svc.stop()
def test_completed_exchange_event_unsubscribes_on_stop(tmp_path):
bus = EventBus(record_history=True)
extractor = FakeExtractor(["User likes jazz"])
store = LocalFactStore(tmp_path / "facts.jsonl")
svc = MemoryService(store, extractor, event_bus=bus)
svc.start()
svc.stop()
publish_completed_exchange(bus, "I like jazz", "Noted.", source="test")
assert extractor.calls == []
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)