mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-28 05:12:26 +00:00
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>
209 lines
6.4 KiB
Python
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)
|