Newer
Older
navi-1 / tests / unit / core / test_memory_recall.py
"""Automatic recall of raw excerpts — the read side of the compression index."""

from datetime import datetime, timezone
from types import SimpleNamespace

import navi.config as _config
import navi.core.context_builder.builder as _cb
from navi.config import Settings
from navi.core.context_builder import ContextBuilder, _recall_query
from navi.llm.base import Message
from navi.memory.search import MemoryHit
from tests.conftest_factory import make_profile_registry

NOW = datetime(2026, 11, 9, 12, 0, tzinfo=timezone.utc)


class FakeSearch:
    def __init__(self, hits=None):
        self.hits = hits or []
        self.calls = []

    async def search(self, query, **kwargs):
        self.calls.append((query, kwargs))
        return self.hits

    async def latest_summary_at(self, session_id):
        return NOW


def _session(*, with_summary: bool, context=None):
    messages = context or [
        Message(role="user", content="we discussed the deploy port"),
        Message(role="assistant", content="the deploy port is 8099"),
    ]
    if with_summary:
        messages = [Message(role="user", content="[Context Summary]", is_summary=True), *messages]
    return SimpleNamespace(id="s1", context=messages, session_metadata={})


def _hit(text="the deploy port is 8099", **kwargs):
    defaults = dict(
        source_type="raw", text=text, session_id="s1",
        start_seq=3, end_seq=4, timestamp=NOW, chunk_id="c1", score=0.9,
    )
    defaults.update(kwargs)
    return MemoryHit(**defaults)


class TestQueryWindow:
    def test_the_query_uses_the_window_not_only_the_last_message(self):
        context = [
            Message(role="user", content="we discussed the deploy port"),
            Message(role="assistant", content="the deploy port is 8099"),
        ]
        query = _recall_query(context, "and what about it?")
        assert "deploy port" in query
        assert "8099" in query
        assert "and what about it?" in query

    def test_summaries_are_kept_out_of_the_query(self):
        context = [
            Message(role="user", content="a giant old summary", is_summary=True),
            Message(role="user", content="fresh question"),
        ]
        assert "giant old summary" not in _recall_query(context, "next")

    def test_the_query_is_bounded(self):
        context = [Message(role="user", content="x" * 10_000)]
        assert len(_recall_query(context, "y" * 10_000)) <= 1200


class TestRecallsOnlyAfterACompression:
    async def test_no_summary_means_no_search(self):
        search = FakeSearch([_hit()])
        builder = ContextBuilder(make_profile_registry(), memory_search=search)
        msg = await builder._memory_recall_msg(_session(with_summary=False), "the port")
        assert msg is None
        assert search.calls == []

    async def test_with_a_summary_the_session_is_searched(self):
        search = FakeSearch([_hit()])
        builder = ContextBuilder(make_profile_registry(), memory_search=search)
        msg = await builder._memory_recall_msg(_session(with_summary=True), "the port", user_id="u1")
        assert msg is not None
        _, kwargs = search.calls[0]
        assert kwargs["scope"] == "raw"
        assert kwargs["session_id"] == "s1"
        assert kwargs["user_id"] == "u1"

    async def test_recall_can_be_switched_off(self, monkeypatch):
        new = Settings(_env_file=None, memory_recall_enabled=False)
        monkeypatch.setattr(_config, "settings", new)
        monkeypatch.setattr(_cb, "_config", SimpleNamespace(settings=new))
        search = FakeSearch([_hit()])
        builder = ContextBuilder(make_profile_registry(), memory_search=search)
        assert await builder._memory_recall_msg(_session(with_summary=True), "port") is None
        assert search.calls == []


class TestSelection:
    async def test_an_excerpt_is_rendered_with_its_source(self):
        builder = ContextBuilder(make_profile_registry(), memory_search=FakeSearch([_hit()]))
        msg = await builder._memory_recall_msg(_session(with_summary=True), "port")
        assert msg.role == "system"
        assert "session s1, turns 3–4" in msg.content
        assert "the deploy port is 8099" in msg.content
        assert "verbatim" in msg.content

    async def test_the_same_chunk_is_not_injected_twice(self):
        injected: list[str] = []
        builder = ContextBuilder(make_profile_registry(), memory_search=FakeSearch([_hit()]))
        first = await builder._memory_recall_msg(_session(with_summary=True), "port", injected_ids=injected)
        second = await builder._memory_recall_msg(_session(with_summary=True), "port", injected_ids=injected)
        assert first is not None
        assert second is None
        assert injected == ["c1"]

    async def test_a_weak_vector_match_is_dropped(self, monkeypatch):
        new = Settings(_env_file=None, memory_recall_min_similarity=0.6)
        monkeypatch.setattr(_cb, "_config", SimpleNamespace(settings=new))
        builder = ContextBuilder(
            make_profile_registry(), memory_search=FakeSearch([_hit(score=0.4)])
        )
        assert await builder._memory_recall_msg(_session(with_summary=True), "port") is None

    async def test_the_lexical_fallback_needs_two_terms(self):
        weak = _hit(score=None, terms_hit=1, chunk_id="c1")
        strong = _hit(score=None, terms_hit=2, chunk_id="c2", text="the port is in the deploy notes")
        builder = ContextBuilder(make_profile_registry(), memory_search=FakeSearch([weak, strong]))
        msg = await builder._memory_recall_msg(_session(with_summary=True), "port")
        assert "deploy notes" in msg.content
        assert msg.content.count("•") == 1

    async def test_an_outdated_excerpt_is_not_injected(self):
        builder = ContextBuilder(make_profile_registry(), memory_search=FakeSearch(
            [_hit(superseded_by="server_port (updated later)")]
        ))
        assert await builder._memory_recall_msg(_session(with_summary=True), "port") is None

    async def test_at_most_k_excerpts(self, monkeypatch):
        new = Settings(_env_file=None, memory_recall_k=1)
        monkeypatch.setattr(_cb, "_config", SimpleNamespace(settings=new))
        hits = [_hit(chunk_id="c1"), _hit("second", chunk_id="c2")]
        builder = ContextBuilder(make_profile_registry(), memory_search=FakeSearch(hits))
        msg = await builder._memory_recall_msg(_session(with_summary=True), "port")
        assert "second" not in msg.content

    async def test_a_search_failure_is_swallowed(self):
        class Broken(FakeSearch):
            async def search(self, query, **kwargs):
                raise RuntimeError("db down")

        builder = ContextBuilder(make_profile_registry(), memory_search=Broken())
        assert await builder._memory_recall_msg(_session(with_summary=True), "port") is None