Newer
Older
navi-1 / tests / unit / core / test_context_builder.py
"""Unit tests for ContextBuilder."""

import asyncio

from navi.core.context_builder import ContextBuilder, _fact_value_is_abs_path_outside
from navi.llm.base import Message
from tests.conftest_factory import make_profile, make_profile_registry


class FakeMcpManager:
    def __init__(self):
        self.calls = []

    def get_instructions(self, server_names=None):
        self.calls.append(server_names)
        if not server_names:
            return {}
        return {name: f"{name} instructions" for name in server_names}


class TestBuildSystemPrompt:
    def test_includes_persona(self, monkeypatch):
        import navi.config as _config
        from navi.config import Settings

        monkeypatch.setattr(
            _config, "settings", Settings(navi_persona="You are Navi.", navi_persona_file="")
        )
        builder = ContextBuilder(profile_registry=make_profile_registry())
        profile = make_profile("test")
        prompt = builder.build_system_prompt(profile)
        assert "You are Navi." in prompt
        assert profile.system_prompt in prompt

    def test_includes_other_profiles(self):
        reg = make_profile_registry()
        builder = ContextBuilder(profile_registry=reg)
        profile = reg.get("secretary")
        prompt = builder.build_system_prompt(profile)
        assert "## Available profiles" in prompt
        assert "developer" in prompt

    def test_cache_returns_same_object(self):
        reg = make_profile_registry()
        builder = ContextBuilder(profile_registry=reg)
        profile = reg.get("secretary")
        p1 = builder.build_system_prompt(profile)
        p2 = builder.build_system_prompt(profile)
        assert p1 is p2  # cached

    def test_invalidate_cache(self):
        reg = make_profile_registry()
        builder = ContextBuilder(profile_registry=reg)
        profile = reg.get("secretary")
        p1 = builder.build_system_prompt(profile)
        builder.invalidate_system_prompt_cache(profile.id)
        p2 = builder.build_system_prompt(profile)
        assert p1 == p2
        assert p1 is not p2  # cache was invalidated


class TestBuildGoalAnchor:
    def test_includes_original_request(self):
        import asyncio
        builder = ContextBuilder(profile_registry=make_profile_registry())
        msg = asyncio.run(builder._build_goal_anchor("sess-1", "Write tests"))
        assert msg.role == "system"
        assert "Original request: Write tests" in msg.content
        assert "Stay on track" in msg.content


class TestBuild:
    def test_puts_system_first(self):
        builder = ContextBuilder(profile_registry=make_profile_registry())
        profile = make_profile("test")
        context = [Message(role="user", content="hi")]
        result = builder.build(context, profile, mem=None)
        assert result[0].role == "system"

    def test_scope_boundary_message_injected_when_enabled(self):
        builder = ContextBuilder(profile_registry=make_profile_registry())
        profile = make_profile("test", scope_boundary_enabled=True)
        context = [Message(role="user", content="hi")]
        result = builder.build(context, profile, mem=None)
        boundary = [m for m in result if m.role == "system" and "[Scope boundary]" in (m.content or "")]
        assert len(boundary) == 1
        assert "Do NOT expand to sibling or parent" in boundary[0].content

    def test_scope_boundary_absent_when_disabled(self):
        builder = ContextBuilder(profile_registry=make_profile_registry())
        profile = make_profile("test")  # default scope_boundary_enabled=False
        context = [Message(role="user", content="hi")]
        result = builder.build(context, profile, mem=None)
        assert not any("[Scope boundary]" in (m.content or "") for m in result)

    def test_injects_memory(self):
        builder = ContextBuilder(profile_registry=make_profile_registry())
        profile = make_profile("test")
        mem = Message(role="system", content="I remember you.")
        context = [Message(role="user", content="hi")]
        result = builder.build(context, profile, mem=mem)
        assert result[1] == mem

    def test_strips_existing_system(self):
        builder = ContextBuilder(profile_registry=make_profile_registry())
        profile = make_profile("test")
        context = [
            Message(role="system", content="old"),
            Message(role="user", content="hi"),
        ]
        result = builder.build(context, profile, mem=None)
        system_msgs = [m for m in result if m.role == "system"]
        assert len(system_msgs) == 1  # only the new system prompt

    def test_iteration_budget_injection(self):
        builder = ContextBuilder(profile_registry=make_profile_registry())
        profile = make_profile("test", iteration_budget_enabled=True)
        context = [Message(role="user", content="hi")]
        result = builder.build(context, profile, mem=None, iteration=7, max_iterations=10)
        last = result[-1]
        assert last.role == "system"
        assert "Iteration 8/10" in last.content
        assert "2 iteration(s) after this one" in last.content

    def test_critical_urgency(self):
        builder = ContextBuilder(profile_registry=make_profile_registry())
        profile = make_profile("test", iteration_budget_enabled=True)
        context = [Message(role="user", content="hi")]
        result = builder.build(context, profile, mem=None, iteration=9, max_iterations=10)
        assert "CRITICAL" in result[-1].content

    def test_does_not_inject_mcp_for_profiles_without_mcp_servers(self):
        mcp = FakeMcpManager()
        builder = ContextBuilder(profile_registry=make_profile_registry(), mcp_manager=mcp)
        profile = make_profile("test", mcp_servers={})
        context = [Message(role="user", content="hi")]

        result = builder.build(context, profile, mem=None)

        assert not any("MCP servers" in (m.content or "") for m in result)
        assert mcp.calls == []

    def test_injects_only_profile_mcp_server_instructions(self):
        mcp = FakeMcpManager()
        builder = ContextBuilder(profile_registry=make_profile_registry(), mcp_manager=mcp)
        profile = make_profile("test", mcp_servers={"gnexus-book": ["read"]})
        context = [Message(role="user", content="hi")]

        result = builder.build(context, profile, mem=None)

        assert any("gnexus-book instructions" in (m.content or "") for m in result)
        assert mcp.calls == [{"gnexus-book"}]


class FakeMemoryStore:
    """Minimal MemoryStore stub returning canned facts from search_facts."""

    def __init__(self, facts):
        self._facts = facts

    async def search_facts(self, query, user_id=None, limit=15):
        return self._facts[:limit]


class TestFactPathScope:
    def test_abs_path_outside_cwd(self):
        assert _fact_value_is_abs_path_outside("/home/u/navi-1", "/home/u/other-proj") is True

    def test_abs_path_inside_cwd(self):
        assert _fact_value_is_abs_path_outside("/home/u/other-proj/sub", "/home/u/other-proj") is False

    def test_cwd_itself(self):
        assert _fact_value_is_abs_path_outside("/home/u/other-proj", "/home/u/other-proj") is False

    def test_non_path_value_kept(self):
        assert _fact_value_is_abs_path_outside("Linux", "/home/u/other-proj") is False

    def test_relative_path_kept(self):
        assert _fact_value_is_abs_path_outside("workspace/secrets/x.yaml", "/home/u/other-proj") is False

    def test_empty_value_kept(self):
        assert _fact_value_is_abs_path_outside("", "/home/u/other-proj") is False


def _fact(key, value, fid="1"):
    return {"id": fid, "category": "technical", "key": key, "value": value, "confidence": 80}


class TestMemoryFactsScopeFilter:
    def _builder(self, facts):
        return ContextBuilder(profile_registry=make_profile_registry(), memory_store=FakeMemoryStore(facts))

    def test_filter_drops_abs_path_outside_cwd(self):
        facts = [_fact("project_root", "/home/u/navi-1")]
        builder = self._builder(facts)
        msg = asyncio.run(
            builder._memory_facts_msg(
                "please look at the contents of this directory and report back what you find there",
                scope_boundary_enabled=True,
                session_cwd="/home/u/other-proj",
            )
        )
        assert msg is None  # the only fact was filtered out → nothing to inject

    def test_filter_keeps_abs_path_inside_cwd(self):
        facts = [_fact("project_root", "/home/u/other-proj")]
        builder = self._builder(facts)
        msg = asyncio.run(
            builder._memory_facts_msg(
                "please look at the contents of this directory and report back what you find there",
                scope_boundary_enabled=True,
                session_cwd="/home/u/other-proj",
            )
        )
        assert msg is not None
        assert "project_root" in msg.content
        assert "/home/u/other-proj" in msg.content

    def test_filter_keeps_non_path_facts(self):
        facts = [_fact("os", "Linux"), _fact("project_root", "/home/u/navi-1")]
        builder = self._builder(facts)
        msg = asyncio.run(
            builder._memory_facts_msg(
                "please look at the contents of this directory and report back what you find there",
                scope_boundary_enabled=True,
                session_cwd="/home/u/other-proj",
            )
        )
        assert msg is not None
        assert "os" in msg.content and "Linux" in msg.content
        assert "navi-1" not in msg.content  # path fact dropped

    def test_filter_disabled_injects_path_outside_cwd(self):
        facts = [_fact("project_root", "/home/u/navi-1")]
        builder = self._builder(facts)
        msg = asyncio.run(
            builder._memory_facts_msg(
                "please look at the contents of this directory and report back what you find there",
                scope_boundary_enabled=False,
                session_cwd="/home/u/other-proj",
            )
        )
        assert msg is not None
        assert "navi-1" in msg.content  # free-flight: nothing filtered

    def test_no_cwd_no_filtering(self):
        facts = [_fact("project_root", "/home/u/navi-1")]
        builder = self._builder(facts)
        msg = asyncio.run(
            builder._memory_facts_msg(
                "please look at the contents of this directory and report back what you find there",
                scope_boundary_enabled=True,
                session_cwd=None,
            )
        )
        assert msg is not None
        assert "navi-1" in msg.content  # without cwd we cannot judge scope