Newer
Older
navi-1 / tests / unit / core / test_agent.py
"""Unit tests for navi.core.agent.Agent.

Uses InMemorySessionStore, FakeLLMBackend, and FakeTool so tests run
without a real database or LLM server.
"""

import asyncio
import copy
from datetime import datetime, timezone

import pytest
import pytest_asyncio

from navi.core.agent import Agent
from navi.core.events import (
    CompressionStarted,
    ContextCompressed,
    ModelInfo,
    StreamEnd,
    SubagentComplete,
)
from navi.core.registry import BackendRegistry, ProfileRegistry, ToolRegistry
from navi.core.session import InMemorySessionStore, Session
from navi.exceptions import MaxIterationsReached, NothingToCompactError, SessionNotFound
from navi.llm.base import LLMChunk, Message, ToolCallRequest
from tests.conftest_factory import (
    FakeLLMBackend,
    FakeTool,
    make_profile,
    make_registry_with_tools,
)


@pytest.fixture
def agent():
    sessions = InMemorySessionStore()
    profiles = ProfileRegistry()
    profile = make_profile("test")
    profiles.register(profile)
    tools = make_registry_with_tools()
    backends = BackendRegistry()
    backends.register("ollama", FakeLLMBackend(responses=["hello"]))
    return Agent(
        session_store=sessions,
        profile_registry=profiles,
        tool_registry=tools,
        backend_registry=backends,
    )


@pytest_asyncio.fixture
async def session(agent):
    return await agent._sessions.create(profile_id="test")


# ─── run() tests ───────────────────────────────────────────────────────────


class TestAgentRun:
    @pytest.mark.asyncio
    async def test_run_single_iteration(self, agent, session):
        backend = FakeLLMBackend(responses=["hello"])
        agent._backends.register("ollama", backend)

        result = await agent.run(session.id, "hi")
        assert result == "hello"
        saved = await agent._sessions.get(session.id)
        # user display + user context + assistant
        assert len(saved.messages) == 3
        assert saved.messages[0].role == "user"
        assert saved.messages[1].role == "user"
        assert saved.messages[2].role == "assistant"
        assert saved.messages[2].content == "hello"

        # Flags: display-only user message, context-only user message
        assert saved.messages[0].is_display is True
        assert saved.messages[0].is_context is False
        assert saved.messages[1].is_display is False
        assert saved.messages[1].is_context is True
        # Assistant message is both display and context
        assert saved.messages[2].is_display is True
        assert saved.messages[2].is_context is True

    @pytest.mark.asyncio
    async def test_run_session_not_found(self, agent):
        with pytest.raises(SessionNotFound):
            await agent.run("nonexistent-id", "hi")

    @pytest.mark.asyncio
    async def test_run_tool_calls_then_stop(self, agent, session):
        """Tool-calling turn followed by a final stop turn."""
        backend = FakeLLMBackend(
            responses=["", "done"],
            tool_calls=[
                [ToolCallRequest(id="1", name="test_tool", arguments={})],
                None,
            ],
        )
        agent._backends.register("ollama", backend)

        result = await agent.run(session.id, "do something")
        assert result == "done"
        saved = await agent._sessions.get(session.id)
        # user display + user context + assistant(tool) + tool_result + assistant(final)
        assert len(saved.messages) == 5
        assert saved.messages[3].role == "tool"
        assert saved.messages[4].content == "done"

    @pytest.mark.asyncio
    async def test_run_token_accumulation(self, agent, session):
        """_turn_tokens accumulates completion tokens across tool-calling iterations."""
        backend = FakeLLMBackend(
            responses=["", "done"],
            tool_calls=[
                [ToolCallRequest(id="1", name="test_tool", arguments={})],
                None,
            ],
            prompt_tokens=10,
            completion_tokens=5,
        )
        agent._backends.register("ollama", backend)

        await agent.run(session.id, "do something")
        saved = await agent._sessions.get(session.id)
        final_msg = saved.messages[-1]
        # Two iterations × 5 completion tokens = 10 tokens
        assert final_msg.token_count == 10

    @pytest.mark.asyncio
    async def test_run_max_iterations(self, agent, session):
        """After max_iterations tool turns, MaxIterationsReached is raised."""
        profile = agent._profiles.get("test")
        profile.max_iterations = 2

        backend = FakeLLMBackend(
            responses=["", ""],
            tool_calls=[
                [ToolCallRequest(id="1", name="test_tool", arguments={})],
                [ToolCallRequest(id="2", name="test_tool", arguments={})],
            ],
        )
        agent._backends.register("ollama", backend)

        with pytest.raises(MaxIterationsReached):
            await agent.run(session.id, "loop forever")


# ─── run_stream() tests ──────────────────────────────────────────────────────


class TestAgentRunStream:
    @pytest.mark.asyncio
    async def test_run_stream_single_iteration(self, agent, session):
        backend = FakeLLMBackend(responses=["streamed hello"])
        agent._backends.register("ollama", backend)

        events = []
        async for ev in agent.run_stream(session.id, "hi"):
            events.append(type(ev).__name__)

        assert events[-1] == "StreamEnd"
        saved = await agent._sessions.get(session.id)
        assert saved.messages[-1].content == "streamed hello"

    @pytest.mark.asyncio
    async def test_first_message_does_not_force_planning(self, agent, session):
        """Regression: the pre-turn planning gate is gone. Even the FIRST message
        of a session must not run the planner — planning only happens when the
        agent calls the `plan` tool."""
        from navi.core.events import PlanReady, PlanningStatus

        backend = FakeLLMBackend(responses=["streamed hello"])
        agent._backends.register("ollama", backend)

        events = []
        async for ev in agent.run_stream(session.id, "please build the new feature"):
            events.append(ev)

        # No planning events, no plan messages.
        assert not any(isinstance(ev, (PlanningStatus, PlanReady)) for ev in events)
        saved = await agent._sessions.get(session.id)
        assert not any(getattr(m, "is_plan", False) for m in saved.messages)
        # The planner's analysis call never ran (complete() untouched) — only
        # the main loop's stream call hit the backend.
        assert backend._call_idx == 0
        assert backend._stream_idx == 1

    @pytest.mark.asyncio
    async def test_run_stream_emits_model_info(self, agent, session):
        """The agent emits a ModelInfo event carrying the resolved model."""
        from typing import AsyncGenerator

        from navi.llm.base import LLMBackend

        class ModelStampingBackend(LLMBackend):
            async def complete(self, messages, tools=None, temperature=0.7, model=None,
                               think=None, max_tokens=None, **kw):
                raise NotImplementedError

            async def stream_complete(self, messages, tools=None, temperature=0.7,
                                      model=None, think=None, **kw) -> AsyncGenerator[LLMChunk, None]:
                yield LLMChunk(delta="hello", model="resolved-model")
                yield LLMChunk(finish_reason="stop", prompt_tokens=5, completion_tokens=1)

            async def embed(self, texts, model=None):
                return [[0.1] * 768 for _ in texts]

        agent._backends.register("ollama", ModelStampingBackend())
        events = []
        async for ev in agent.run_stream(session.id, "hi"):
            events.append(ev)

        infos = [ev for ev in events if isinstance(ev, ModelInfo)]
        assert len(infos) == 1
        assert infos[0].model == "resolved-model"

    @pytest.mark.asyncio
    async def test_run_stream_emits_model_info_once_per_turn(self, agent, session):
        """ModelInfo is not re-emitted across iterations if the model stays the same."""
        from typing import AsyncGenerator

        from navi.llm.base import LLMBackend

        class ModelStampingBackend(LLMBackend):
            def __init__(self):
                self._call = 0

            async def complete(self, messages, tools=None, temperature=0.7, model=None,
                               think=None, max_tokens=None, **kw):
                raise NotImplementedError

            async def stream_complete(self, messages, tools=None, temperature=0.7,
                                      model=None, think=None, **kw) -> AsyncGenerator[LLMChunk, None]:
                self._call += 1
                if self._call == 1:
                    yield LLMChunk(
                        delta="", model="same-model",
                        finish_reason="tool_calls",
                        tool_calls=[ToolCallRequest(id="1", name="test_tool", arguments={})],
                    )
                else:
                    yield LLMChunk(delta="final answer", model="same-model")
                    yield LLMChunk(finish_reason="stop", prompt_tokens=5, completion_tokens=1)

            async def embed(self, texts, model=None):
                return [[0.1] * 768 for _ in texts]

        agent._backends.register("ollama", ModelStampingBackend())
        events = []
        async for ev in agent.run_stream(session.id, "do something"):
            events.append(ev)

        # Two iterations happened (tool call then final), but ModelInfo fires once.
        infos = [ev for ev in events if isinstance(ev, ModelInfo)]
        assert len(infos) == 1
        assert infos[0].model == "same-model"

    @pytest.mark.asyncio
    async def test_run_stream_tool_calls(self, agent, session):
        backend = FakeLLMBackend(
            responses=["", "final"],
            tool_calls=[
                [ToolCallRequest(id="1", name="test_tool", arguments={})],
                None,
            ],
        )
        agent._backends.register("ollama", backend)

        events = []
        async for ev in agent.run_stream(session.id, "do something"):
            events.append(type(ev).__name__)

        assert "ToolStarted" in events
        assert "ToolEvent" in events
        assert events[-1] == "StreamEnd"

    @pytest.mark.asyncio
    async def test_run_stream_emits_todo_updated_after_tool_turn(self, agent, session):
        """A TodoUpdated event is emitted after each tool-execution turn so the
        UI side panel can reflect todo changes from the todo tool."""
        from navi.core.events import TodoUpdated

        backend = FakeLLMBackend(
            responses=["", "final"],
            tool_calls=[
                [ToolCallRequest(id="1", name="test_tool", arguments={})],
                None,
            ],
        )
        agent._backends.register("ollama", backend)

        events = []
        async for ev in agent.run_stream(session.id, "do something"):
            events.append(ev)

        todo_events = [ev for ev in events if isinstance(ev, TodoUpdated)]
        assert len(todo_events) >= 1
        # The event carries the session id and a (possibly empty) tasks list.
        assert todo_events[0].session_id == session.id
        assert isinstance(todo_events[0].tasks, list)

    @pytest.mark.asyncio
    async def test_run_stream_stop_event(self, agent, session):
        """Cooperative stop mid-stream yields StreamStopped."""
        from navi.tools._internal.base import current_stop_event

        stop = asyncio.Event()
        token = current_stop_event.set(stop)
        try:
            async def _slow_stream(self, **kwargs):
                yield LLMChunk(delta="a")
                await asyncio.sleep(10)
                yield LLMChunk(delta="b")

            backend = FakeLLMBackend()
            # Monkey-patch stream_complete to be slow
            backend.stream_complete = _slow_stream
            agent._backends.register("ollama", backend)

            stop.set()
            events = []
            async for ev in agent.run_stream(session.id, "hi"):
                events.append(type(ev).__name__)

            assert "StreamStopped" in events
        finally:
            current_stop_event.reset(token)

    @pytest.mark.asyncio
    async def test_run_stream_token_count(self, agent, session):
        backend = FakeLLMBackend(
            responses=["final"],
            prompt_tokens=100,
            completion_tokens=50,
        )
        agent._backends.register("ollama", backend)

        events = []
        async for ev in agent.run_stream(session.id, "hi"):
            if isinstance(ev, StreamEnd):
                events.append(ev)

        assert events[0].token_count == 50
        saved = await agent._sessions.get(session.id)
        assert saved.messages[-1].token_count == 50


# ─── switch_profile reload tests ─────────────────────────────────────────────


class SwitchProfileTool(FakeTool):
    """The real switch_profile's shape: repoint the store, touch nothing else."""

    def __init__(self, store, target):
        super().__init__("switch_profile")
        self._store = store
        self._target = target

    async def execute(self, arguments, ctx=None):
        from navi.tools._internal.base import ToolResult, current_session_id

        sid = (ctx.session_id if ctx else None) or current_session_id.get()
        await self._store.set_profile(sid, self._target)
        return ToolResult(success=True, output=f"Switched to '{self._target}'.")


class ToolRecordingBackend(FakeLLMBackend):
    """Records which tools each LLM call was offered."""

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.offered: list[set[str]] = []

    async def stream_complete(self, messages, tools=None, **kwargs):
        names = set()
        for tool in tools or []:
            names.add(tool["function"]["name"] if isinstance(tool, dict) else tool.name)
        self.offered.append(names)
        async for chunk in super().stream_complete(messages, tools, **kwargs):
            yield chunk


class TestProfileSwitchReload:
    """A mid-turn switch_profile must rebind the run's tools for the next LLM call.

    Both store paths leave session.profile_id equal to the stored row right after
    a switch: set_profile() rewrites the row (and the run's own save() then put
    its stale value back over it), or it mutates the very Session object the run
    holds, as InMemorySessionStore does. The old guard compared those two after
    the tool batch, so it was never true — the turn kept offering the old
    profile's tools while switch_profile's own answer advertised the new ones.
    """

    def make_agent(self):
        store = InMemorySessionStore()
        profiles = ProfileRegistry()
        profiles.register(
            make_profile("test", enabled_tools=["test_tool", "switch_profile"])
        )
        profiles.register(make_profile("other", enabled_tools=["another_tool"]))
        tools = ToolRegistry()
        tools.register(FakeTool("test_tool"), builtin=True)
        tools.register(FakeTool("another_tool"), builtin=True)
        tools.register(SwitchProfileTool(store, "other"), builtin=True)
        backends = BackendRegistry()
        return Agent(
            session_store=store,
            profile_registry=profiles,
            tool_registry=tools,
            backend_registry=backends,
        )

    @pytest.mark.asyncio
    async def test_the_next_llm_call_is_offered_the_new_profiles_tools(self):
        agent = self.make_agent()
        backend = ToolRecordingBackend(
            responses=["", "final"],
            tool_calls=[
                [
                    ToolCallRequest(
                        id="1",
                        name="switch_profile",
                        arguments={"profile_id": "other"},
                    )
                ],
                None,
            ],
        )
        agent._backends.register("ollama", backend)
        session = await agent._sessions.create(profile_id="test")

        async for _ in agent.run_stream(session.id, "switch please"):
            pass

        assert "test_tool" in backend.offered[0]
        assert backend.offered[1] == {"another_tool"}
        # The switch is what the session ends on, not just what the tool said.
        assert session.profile_id == "other"


# ─── final-turn intercept tests ──────────────────────────────────────────────


class TestFinalIntercept:
    """The final-turn intercept: a bare-text turn (no tool call) with open todo
    steps is continued with a recovery nudge instead of closing the run, up to
    final_intercept_limit. This is the structural patch for the "stopped to
    narrate" failure mode that bypasses the tool-loop nudges (anti-stall,
    adaptive re-plan) because those fire in the *next* iteration's pre_turn,
    which never comes when the turn ends on bare text.
    """

    @pytest_asyncio.fixture
    async def kv(self):
        """In-memory KV so set_tasks persists a plan the agent's has_open_steps
        can read. Mirrors the _fake_kv fixture in tests/unit/tools/test_todo.py."""
        from navi.store import KvStore
        from tests.conftest_factory import FakePool

        class _MemKv(KvStore):
            def __init__(self):
                self._data: dict[tuple, str] = {}

            async def _get_pool(self):
                return FakePool()

            async def get(self, user_id, session_id, scope, key):
                return self._data.get((user_id or "", session_id, scope, key))

            async def set(self, user_id, session_id, scope, key, value):
                self._data[(user_id or "", session_id, scope, key)] = value

            async def get_all(self, user_id, session_id, scope):
                return {
                    k[3]: v
                    for k, v in self._data.items()
                    if k[:3] == (user_id or "", session_id, scope)
                }

            async def delete(self, user_id, session_id, scope, key):
                self._data.pop((user_id or "", session_id, scope, key), None)

            async def clear_scope(self, user_id, session_id, scope):
                keys = [k for k in self._data if k[:3] == (user_id or "", session_id, scope)]
                for k in keys:
                    del self._data[k]

        from navi.tools import todo as _mod

        store = _MemKv()
        _mod._kv_store = store
        yield store
        _mod._kv_store = None

    def _configure(self, agent, *, limit):
        profile = agent._profiles.get("test")
        profile.max_iterations = 20
        profile.final_intercept_enabled = True
        profile.final_intercept_limit = limit

    @pytest.mark.asyncio
    async def test_bare_text_with_open_todo_intercepts_and_continues(self, agent, session, kv):
        """A bare-text turn while the todo has open steps is not closed: the loop
        continues (n-1 intercepts) until the limit is hit, then one final StreamEnd."""
        from navi.tools.todo import set_tasks

        self._configure(agent, limit=2)
        await set_tasks(session.id, ["step a", "step b"])  # open steps -> has_open_steps True
        backend = FakeLLMBackend(
            responses=["I'll try another approach", "I'll try again", "done"],
            tool_calls=[None, None, None],
        )
        agent._backends.register("ollama", backend)

        events = []
        async for ev in agent.run_stream(session.id, "do the task"):
            events.append(ev)

        saved = await agent._sessions.get(session.id)
        assistant = [m for m in saved.messages if m.role == "assistant"]
        # Two intercepts (continue) + one genuine final = three bare-text turns.
        assert len(assistant) == 3
        # Exactly one StreamEnd, at the very end — the loop never closed early.
        stream_ends = [ev for ev in events if isinstance(ev, StreamEnd)]
        assert len(stream_ends) == 1

    @pytest.mark.asyncio
    async def test_intercept_disabled_when_flag_off(self, agent, session, kv):
        """final_intercept_enabled=False: a bare-text turn with open steps
        finalises immediately, no continuation."""
        from navi.tools.todo import set_tasks

        profile = agent._profiles.get("test")
        profile.max_iterations = 20
        profile.final_intercept_enabled = False
        profile.final_intercept_limit = 2
        await set_tasks(session.id, ["step a", "step b"])
        backend = FakeLLMBackend(responses=["I'll try another approach"], tool_calls=[None])
        agent._backends.register("ollama", backend)

        events = []
        async for ev in agent.run_stream(session.id, "do the task"):
            events.append(ev)

        saved = await agent._sessions.get(session.id)
        assistant = [m for m in saved.messages if m.role == "assistant"]
        assert len(assistant) == 1
        assert len([ev for ev in events if isinstance(ev, StreamEnd)]) == 1

    @pytest.mark.asyncio
    async def test_empty_todo_no_intercept(self, agent, session, kv):
        """No plan (empty todo) -> has_open_steps False -> no intercept; the turn
        closes normally. Protects casual messages and observe-only runs."""
        self._configure(agent, limit=2)
        # No set_tasks: the plan is empty.
        backend = FakeLLMBackend(responses=["all done here"], tool_calls=[None])
        agent._backends.register("ollama", backend)

        events = []
        async for ev in agent.run_stream(session.id, "hi"):
            events.append(ev)

        saved = await agent._sessions.get(session.id)
        assistant = [m for m in saved.messages if m.role == "assistant"]
        assert len(assistant) == 1
        assert len([ev for ev in events if isinstance(ev, StreamEnd)]) == 1

    @pytest.mark.asyncio
    async def test_limit_one_intercepts_once(self, agent, session, kv):
        """final_intercept_limit=1: one intercept, then the second bare-text turn
        finalises (limit reached)."""
        from navi.tools.todo import set_tasks

        self._configure(agent, limit=1)
        await set_tasks(session.id, ["step a", "step b"])
        backend = FakeLLMBackend(
            responses=["I'll try again", "done"],
            tool_calls=[None, None],
        )
        agent._backends.register("ollama", backend)

        events = []
        async for ev in agent.run_stream(session.id, "do the task"):
            events.append(ev)

        saved = await agent._sessions.get(session.id)
        assistant = [m for m in saved.messages if m.role == "assistant"]
        assert len(assistant) == 2  # one intercept + one final
        assert len([ev for ev in events if isinstance(ev, StreamEnd)]) == 1

    @pytest.mark.asyncio
    async def test_nudge_text_escalates_and_is_injected_next_iteration(self, agent, session, kv):
        """The 1st-intercept nudge is soft, the 2nd is hard ("second stop"); each is
        injected as a system message into the NEXT iteration's context (not the
        closed turn's). Verified by capturing the messages each LLM call sees."""
        from navi.tools.todo import set_tasks

        self._configure(agent, limit=2)
        await set_tasks(session.id, ["step a", "step b"])

        captured: list[list[Message]] = []

        class CapturingBackend(FakeLLMBackend):
            async def stream_complete(self, messages, **kwargs):
                captured.append(list(messages))
                async for chunk in super().stream_complete(messages, **kwargs):
                    yield chunk

        backend = CapturingBackend(
            responses=["I'll try another approach", "I'll try again", "done"],
            tool_calls=[None, None, None],
        )
        agent._backends.register("ollama", backend)

        events = []
        async for ev in agent.run_stream(session.id, "do the task"):
            events.append(ev)

        # Three LLM calls (2 intercepts + 1 final). captured[0] has no nudge yet;
        # captured[1] carries the soft (1st) nudge; captured[2] the hard (2nd).
        assert len(captured) == 3

        def _sys_content(idx, needle):
            return any(
                m.role == "system" and needle in (m.content or "")
                for m in captured[idx]
            )

        assert not _sys_content(0, "Final-turn check")  # first turn: no nudge yet
        assert _sys_content(1, "Final-turn check")
        assert "second stop" not in "".join(
            (m.content or "") for m in captured[1] if m.role == "system"
        )
        assert _sys_content(2, "second stop")
        # The run still closed exactly once.
        assert len([ev for ev in events if isinstance(ev, StreamEnd)]) == 1


# ─── compact_stream() tests ──────────────────────────────────────────────────


class TestAgentCompactStream:
    """Forced /compact: bypasses the token threshold and runs the real compressor."""

    @pytest_asyncio.fixture
    async def session_with_history(self, agent, session):
        """A session with enough turns that there is something to summarize."""
        # context_keep_recent defaults to 8 turns; add 10 so the oldest turns
        # fall into to_summarize (>= 2 messages) and compress_context does work.
        for i in range(10):
            session.context.append(Message(role="user", content=f"user {i}"))
            session.context.append(Message(role="assistant", content=f"assistant {i}"))
            session.messages.append(Message(role="user", content=f"user {i}"))
            session.messages.append(Message(role="assistant", content=f"assistant {i}"))
        await agent._sessions.save(session)
        return session

    @pytest.mark.asyncio
    async def test_compact_emits_started_then_compressed(self, agent, session_with_history):
        # The agent fixture's FakeLLMBackend returns "hello" — a valid summary.
        events = [ev async for ev in agent.compact_stream(session_with_history.id)]

        assert isinstance(events[0], CompressionStarted)
        assert isinstance(events[-1], ContextCompressed)
        # Compression actually shrank the LLM context (10 turns -> summary + kept recent).
        assert events[-1].messages_after < events[-1].messages_before

    @pytest_asyncio.fixture
    async def session_one_long_turn(self, agent, session):
        """The navi_code shape: a single long autonomous turn — one user message
        followed by many tool/assistant iterations. This is ONE turn, so the
        turn-based partition (len(turns) <= keep_recent) finds nothing to
        summarize. Forced compact must still compress via the intra-turn split
        (keep_recent_messages), otherwise the user always sees
        "Nothing to compact yet — the context is still small" regardless of how
        long the turn grew. See keep_recent_messages in compact_stream."""
        session.context.append(Message(role="user", content="do the task"))
        session.messages.append(Message(role="user", content="do the task"))
        for i in range(20):
            session.context.append(Message(role="assistant", content=f"step {i}"))
            session.messages.append(Message(role="assistant", content=f"step {i}"))
        await agent._sessions.save(session)
        return session

    @pytest.mark.asyncio
    async def test_compact_compresses_single_long_turn(self, agent, session_one_long_turn):
        """Regression: a single long turn (navi_code shape) must compress, not
        report 'nothing to compact'. The intra-turn fallback in compact_stream
        (keep_recent_messages=max(12, context_keep_recent*2)) is what makes this
        work — without it forced compact always returns NothingToCompactError
        once the conversation is a single turn."""
        events = [ev async for ev in agent.compact_stream(session_one_long_turn.id)]

        assert isinstance(events[0], CompressionStarted)
        assert isinstance(events[-1], ContextCompressed)
        assert events[-1].messages_after < events[-1].messages_before

    @pytest.mark.asyncio
    async def test_compact_bypasses_threshold_even_when_context_small(self, agent, session):
        """Forced compact is NOT gated by should_compress — but still needs >= 2
        summarizable messages. A near-empty session raises NothingToCompactError
        (not silently does nothing), which the orchestrator surfaces as feedback."""
        session.context.append(Message(role="user", content="hi"))
        session.context.append(Message(role="assistant", content="hello"))
        await agent._sessions.save(session)

        with pytest.raises(NothingToCompactError):
            async for _ in agent.compact_stream(session.id):
                pass

    @pytest.mark.asyncio
    async def test_compact_raises_when_compression_disabled(self, agent, session_with_history, monkeypatch):
        import navi.core.agent as agent_mod
        import navi.config as config

        # Settings is frozen — swap the whole object the agent module sees.
        disabled = config.Settings(
            database_url=config.settings.database_url,
            context_compression_enabled=False,
        )
        monkeypatch.setattr(agent_mod, "settings", disabled)
        with pytest.raises(NothingToCompactError):
            async for _ in agent.compact_stream(session_with_history.id):
                pass

    @pytest.mark.asyncio
    async def test_compact_unknown_session_raises(self, agent):
        with pytest.raises(SessionNotFound):
            async for _ in agent.compact_stream("no-such-session"):
                pass

    @pytest.mark.asyncio
    async def test_midturn_no_compression_started_when_nothing_to_compress(self, agent, session, monkeypatch):
        """C: even when the token gate fires, CompressionStarted is NOT emitted
        if would_compress says the partition cannot shrink the context — no
        misleading "compression" status with no work done. The gate is forced
        True here so we exercise the would_compress guard directly."""
        import navi.core.agent as agent_mod
        from navi.core.events import CompressionStarted

        # Small context that partition cannot shrink (would_compress False).
        session.context.append(Message(role="user", content="hi"))
        session.context.append(Message(role="assistant", content="hello"))
        await agent._sessions.save(session)

        # Force the token gate open; without this the small context would not
        # reach the would_compress check at all.
        monkeypatch.setattr(agent_mod, "should_compress", lambda *a, **k: True)

        events = [
            ev
            async for ev in agent._compression_events_midturn(
                session,
                llm=agent._get_backend("ollama"),
                profile=agent._profiles.get("test"),
                session_id=session.id,
                iteration=1,
                ctx_injections=[],
                mem=None,
            )
        ]
        assert not any(isinstance(ev, CompressionStarted) for ev in events)


# ─── run_ephemeral() tests ───────────────────────────────────────────────────


class TestAgentRunEphemeral:
    @pytest.mark.asyncio
    async def test_run_ephemeral_complete(self, agent):
        backend = FakeLLMBackend(responses=["subagent result"])
        agent._backends.register("ollama", backend)

        result, ok = await agent.run_ephemeral("task", profile_id="test")
        assert "subagent result" in result
        assert "[Sub-agent stopped: completed]" in result
        assert ok is True

    @pytest.mark.asyncio
    async def test_run_ephemeral_max_iterations(self, agent):
        backend = FakeLLMBackend(
            responses=[""],
            tool_calls=[
                [ToolCallRequest(id="1", name="test_tool", arguments={})],
            ],
        )
        agent._backends.register("ollama", backend)

        result, ok = await agent.run_ephemeral(
            "task", profile_id="test", max_iterations=1
        )
        assert ok is False
        assert "iteration limit" in result.lower()

    @pytest.mark.skip(reason="run_ephemeral uses 'import time as _time' inside the function; CPython LOAD_GLOBAL caching makes module-level mock replacement unreliable in pytest-asyncio.")
    @pytest.mark.asyncio
    async def test_run_ephemeral_timeout(self, agent):
        pass

    @pytest.mark.asyncio
    async def test_run_ephemeral_planning_tokens_accumulated(self, agent):
        """Planning phase AIHelperTokensUsed contributes to SubagentComplete."""
        from navi.core.events import AIHelperTokensUsed
        from navi.tools._internal.base import current_event_sink

        backend = FakeLLMBackend(responses=["final"])
        agent._backends.register("ollama", backend)

        # Force planning by setting subagent_planning_enabled on profile
        profile = agent._profiles.get("test")
        profile.subagent_planning_enabled = True

        sink = asyncio.Queue()
        token = current_event_sink.set(sink)
        try:
            # Mock planning to emit AIHelperTokensUsed
            original_planning_run = agent._planning.run

            async def _mock_planning(*args, **kwargs):
                yield AIHelperTokensUsed(prompt_tokens=5, completion_tokens=10)
                yield AIHelperTokensUsed(prompt_tokens=3, completion_tokens=7)

            agent._planning.run = _mock_planning

            result, ok = await agent.run_ephemeral("task", profile_id="test")
            assert ok is True

            # Drain sink for SubagentComplete
            subagent_complete = None
            while not sink.empty():
                item = await sink.get()
                if isinstance(item, SubagentComplete):
                    subagent_complete = item

            # Planning completion tokens: 10 + 7 = 17
            # Final LLM call: 0 (no tokens in FakeLLMBackend default)
            assert subagent_complete is not None
            assert subagent_complete.token_count == 17
        finally:
            current_event_sink.reset(token)
            agent._planning.run = original_planning_run

    @pytest.mark.asyncio
    async def test_run_ephemeral_thinking_stall(self, agent):
        """Subagent that produces only thinking for too long is aborted."""
        async def _thinking_only(self, **kwargs):
            for _ in range(200):
                yield LLMChunk(thinking="thinking " * 100)
            yield LLMChunk(delta="done", finish_reason="stop")

        backend = FakeLLMBackend()
        backend.stream_complete = _thinking_only
        agent._backends.register("ollama", backend)

        result, ok = await agent.run_ephemeral("task", profile_id="test")
        assert ok is False
        assert "thinking" in result.lower() or "stall" in result.lower()


class _SnapshotSessionStore(InMemorySessionStore):
    """Session store that mimics a real DB boundary.

    ``save()`` deep-copies the session into a snapshot; ``get()`` returns the last
    saved snapshot (a fresh copy), not the live object the agent is mutating. So
    mutations that were never ``save()``d are invisible to ``get()`` — exactly like
    ``PgSessionStore`` where messages with ``sequence_number < 0`` only persist on
    ``save()``. This lets us assert that a crash mid-turn does not lose work that
    was incrementally flushed (B1).
    """

    def __init__(self) -> None:
        super().__init__()
        self._snapshots: dict[str, Session] = {}

    async def save(self, session: Session) -> None:
        session.last_active = datetime.now(timezone.utc)
        # Mirror PgSessionStore: assign sequence numbers to new messages on save.
        for m in session.messages:
            if m.sequence_number < 0:
                m.sequence_number = session.db_next_sequence
                session.db_next_sequence += 1
        self._snapshots[session.id] = copy.deepcopy(session)
        self._sessions[session.id] = session

    async def get(self, session_id: str) -> Session | None:
        snap = self._snapshots.get(session_id)
        if snap is not None:
            return copy.deepcopy(snap)
        return self._sessions.get(session_id)


class _CrashBackend(FakeLLMBackend):
    """FakeLLMBackend whose ``stream_complete`` raises on the Nth call.

    Simulates a server crash / CancelledError arriving mid-turn, so we can assert
    that tool work completed before the crash was already persisted (B1).
    """

    def __init__(
        self,
        responses: list[str],
        tool_calls: list[list[ToolCallRequest] | None],
        raise_on_call: int,
        exc: BaseException = RuntimeError("simulated crash"),
    ) -> None:
        super().__init__(responses=responses, tool_calls=tool_calls)
        self._raise_on_call = raise_on_call
        self._exc = exc
        self._sc_idx = 0

    async def stream_complete(self, messages, tools=None, temperature=0.7, model=None,
                              think=None, **kwargs):
        idx = self._sc_idx
        self._sc_idx += 1
        if idx == self._raise_on_call:
            raise self._exc
        content = self._responses[idx] if idx < len(self._responses) else ""
        tcalls = self._tool_calls[idx] if idx < len(self._tool_calls) else None
        if content:
            yield LLMChunk(delta=content)
        yield LLMChunk(
            finish_reason="tool_calls" if tcalls else "stop",
            tool_calls=tcalls,
        )


def _build_persistence_agent(store: InMemorySessionStore, backend) -> Agent:
    profiles = ProfileRegistry()
    profile = make_profile("test")
    profiles.register(profile)
    tools = make_registry_with_tools()
    backends = BackendRegistry()
    backends.register("ollama", backend)
    return Agent(
        session_store=store,
        profile_registry=profiles,
        tool_registry=tools,
        backend_registry=backends,
    )


class TestAgentPersistenceOnCrash:
    @pytest.mark.asyncio
    async def test_tool_results_persisted_on_crash_midturn(self):
        """A crash on a later LLM call must not lose tool results already completed
        in earlier iterations — B1 persists each tool result as it completes."""
        store = _SnapshotSessionStore()
        backend = _CrashBackend(
            responses=["", "unused"],
            tool_calls=[
                [ToolCallRequest(id="1", name="test_tool", arguments={})],
                None,
            ],
            raise_on_call=1,
        )
        agent = _build_persistence_agent(store, backend)
        sess = await agent._sessions.create(profile_id="test")

        with pytest.raises(RuntimeError, match="simulated crash"):
            async for _ in agent.run_stream(sess.id, "do work"):
                pass

        saved = await agent._sessions.get(sess.id)
        roles = [m.role for m in saved.messages]
        # Iteration 0's assistant tool-call decision + tool result were flushed by
        # B1 before the crash on iteration 1. Without B1 only the user message
        # (saved at turn start) would survive.
        assert "tool" in roles, f"tool result missing from saved state: {roles}"
        assert any(
            m.role == "assistant" and m.tool_calls for m in saved.messages
        ), f"assistant tool-call msg missing: {roles}"

    @pytest.mark.asyncio
    async def test_cancel_flushes_completed_tool_work(self):
        """CancelledError mid-turn (server restart/shutdown) must not lose tool
        results already completed — B1 flushed them incrementally before the cancel."""
        store = _SnapshotSessionStore()
        backend = _CrashBackend(
            responses=["", "unused"],
            tool_calls=[
                [ToolCallRequest(id="1", name="test_tool", arguments={})],
                None,
            ],
            raise_on_call=1,
            exc=asyncio.CancelledError(),
        )
        agent = _build_persistence_agent(store, backend)
        sess = await agent._sessions.create(profile_id="test")

        with pytest.raises(asyncio.CancelledError):
            async for _ in agent.run_stream(sess.id, "do work"):
                pass

        saved = await agent._sessions.get(sess.id)
        roles = [m.role for m in saved.messages]
        assert "tool" in roles, f"tool result missing after cancel: {roles}"