mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-28 14:07:55 +00:00
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.
172 lines
5.3 KiB
Python
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)
|