Newer
Older
navi-1 / tests / clients / test_chat_panel.py
"""Tests for ChatPanel render caching and the visible-history window cap.

The panel rebuilds every renderable on each WS event (including per-token
stream_delta). Caching makes only the changed item miss, and the window cap
bounds how many items are laid out regardless of total history length.
"""

from __future__ import annotations

from pathlib import Path

import pytest

from clients.terminal.tui.tui_app import NaviCodeTui


@pytest.fixture(autouse=True)
def tmp_state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    from clients.terminal import config
    import clients.terminal.tui.settings as settings_module

    original = config.settings.state_dir
    config.settings.state_dir = tmp_path
    settings_module._tui_settings = None
    yield tmp_path
    config.settings.state_dir = original
    settings_module._tui_settings = None


@pytest.fixture(autouse=True)
def mock_tui_api(monkeypatch: pytest.MonkeyPatch) -> None:
    import clients.terminal.api as api_module

    monkeypatch.setattr(api_module, "create_session", lambda *a, **k: {"session_id": "s", "profile_id": "navi_code"})
    monkeypatch.setattr(api_module, "get_session", lambda sid: {"session_id": sid, "profile_id": "navi_code"})
    monkeypatch.setattr(api_module, "list_sessions", lambda: [])
    monkeypatch.setattr(api_module, "get_profile_model", lambda pid: "configured-model")


def _capture_groups(chat) -> list:
    """Record each Group passed to the items container's update()."""
    captured: list = []
    original_update = chat._items_container.update

    def _record(group, *args, **kwargs):
        captured.append(group)
        return original_update(group, *args, **kwargs)

    chat._items_container.update = _record  # type: ignore[method-assign]
    return captured


def _wrap_render(chat) -> list:
    """Wrap the panel's renderer registry.render to count calls."""
    calls: list[dict] = []
    original_render = chat._registry.render

    def _record(msg, *args, **kwargs):
        calls.append(msg)
        return original_render(msg, *args, **kwargs)

    chat._registry.render = _record  # type: ignore[method-assign]
    return calls


@pytest.mark.anyio
async def test_per_token_refresh_renders_only_the_streaming_item() -> None:
    """A stream_delta changes only the assistant bubble's signature, so the
    registry is called exactly once per token — every other visible item is
    reused from the cache instead of being rebuilt/re-parsed."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        calls = _wrap_render(chat)

        chat.add_user_message("hi")  # renders the user bubble once
        chat.handle_ws_event({"type": "stream_start"})
        chat.handle_ws_event({"type": "stream_delta", "delta": "a"})  # creates + renders assistant

        baseline = len(calls)
        # Two more tokens — only the streaming assistant item should be rebuilt.
        chat.handle_ws_event({"type": "stream_delta", "delta": "b"})
        after_one = len(calls)
        chat.handle_ws_event({"type": "stream_delta", "delta": "c"})
        after_two = len(calls)

        assert after_one - baseline == 1
        assert after_two - baseline == 2


@pytest.mark.anyio
async def test_window_cap_renders_only_last_n_with_hint(monkeypatch) -> None:
    """Beyond the cap, only the last N items are laid out and a hint is shown."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        monkeypatch.setattr(chat, "_max_visible_items", lambda: 3)
        captured = _capture_groups(chat)
        chat.clear()  # drop the startup cwd/connected status items

        for i in range(5):
            chat.add_user_message(f"msg {i}")

        group = captured[-1]
        # 3 visible items + 1 truncation hint.
        assert len(group.renderables) == 4
        hint = group.renderables[0]
        assert "2 earlier messages not shown" in str(hint)


@pytest.mark.anyio
async def test_no_hint_when_within_limit(monkeypatch) -> None:
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        monkeypatch.setattr(chat, "_max_visible_items", lambda: 10)
        captured = _capture_groups(chat)
        chat.clear()  # drop the startup cwd/connected status items

        for i in range(3):
            chat.add_user_message(f"msg {i}")

        group = captured[-1]
        assert len(group.renderables) == 3
        assert "not shown" not in str(group.renderables[0])


@pytest.mark.anyio
async def test_clear_empties_chat_render_cache() -> None:
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        chat.add_user_message("hi")
        chat.handle_ws_event({"type": "stream_start"})
        chat.handle_ws_event({"type": "stream_delta", "delta": "a"})
        assert chat._chat_render_cache  # populated during refresh

        chat.clear()
        assert chat._chat_render_cache == {}
        assert chat._model.items == []


@pytest.mark.anyio
async def test_removed_item_is_pruned_from_cache() -> None:
    """stream_end purges empty assistant bubbles; their cache entries must go
    too so the cache stays bounded by live items, not by everything shown."""
    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")

        chat.handle_ws_event({"type": "stream_start"})
        chat.handle_ws_event({"type": "stream_delta", "delta": ""})  # empty bubble
        empty_ids = {id(it) for it in chat._model.items if it.kind == "assistant_message"}
        assert empty_ids  # the empty bubble exists before stream_end

        chat.handle_ws_event({"type": "stream_end", "content": ""})
        cached_ids = set(chat._chat_render_cache.keys())
        assert not (cached_ids & empty_ids)
        # And the empty bubble is no longer in the model.
        assert not any(it.kind == "assistant_message" for it in chat._model.items)

# ─── load_history (session resume) ─────────────────────────────────────────


def _hist_msg(**kw) -> dict:
    """A persisted Message dict as GET /sessions/{id} returns it."""
    base = {"is_display": True}
    base.update(kw)
    return base


def test_chat_model_load_history_maps_roles() -> None:
    from clients.terminal.tui.chat_model import ChatModel

    messages = [
        _hist_msg(role="user", content="hello"),
        _hist_msg(role="assistant", thinking="hmm", content=""),
        _hist_msg(
            role="assistant",
            content="",
            tool_calls=[{"id": "1", "name": "filesystem", "arguments": {"path": "/x"}}],
        ),
        _hist_msg(role="tool", name="filesystem", content="listed: a, b"),
        _hist_msg(role="assistant", content="X has a and b."),
    ]
    model = ChatModel()
    model.load_history(messages)

    kinds = [it.kind for it in model.items]
    assert kinds == [
        "user_message",
        "thinking_block",
        "tool_started",
        "tool_call",
        "assistant_message",
    ]
    assert model.items[0].content == "hello"
    assert model.items[1].content == "hmm"
    assert model.items[2].meta["tool"] == "filesystem"
    assert model.items[2].meta["args"] == {"path": "/x"}
    assert model.items[3].meta["result"] == "listed: a, b"
    assert model.items[4].content == "X has a and b."


def test_chat_model_load_history_skips_non_display() -> None:
    """Context-only user message, summaries, compression events are not shown."""
    from clients.terminal.tui.chat_model import ChatModel

    messages = [
        _hist_msg(role="user", content="display me", is_display=True),
        _hist_msg(role="user", content="context only", is_display=False),
        _hist_msg(role="assistant", content="summary", is_display=False, is_summary=True),
        _hist_msg(role="assistant", content="real answer"),
    ]
    model = ChatModel()
    model.load_history(messages)
    contents = [(it.kind, it.content) for it in model.items]
    assert contents == [("user_message", "display me"), ("assistant_message", "real answer")]


def test_chat_model_load_history_plan_block() -> None:
    from clients.terminal.tui.chat_model import ChatModel

    messages = [
        _hist_msg(role="assistant", content="1. step\n2. step", is_plan=True),
    ]
    model = ChatModel()
    model.load_history(messages)
    assert model.items[0].kind == "plan_ready"
    assert "step" in model.items[0].content


def test_chat_model_load_history_clears_existing() -> None:
    """Loading history replaces any items already in the model (session switch)."""
    from clients.terminal.tui.chat_model import ChatModel

    model = ChatModel()
    model.add_user_message("old")
    assert len(model.items) == 1

    model.load_history([_hist_msg(role="user", content="new")])
    assert len(model.items) == 1
    assert model.items[0].content == "new"


@pytest.mark.anyio
async def test_chat_panel_load_history_renders(monkeypatch) -> None:
    from rich.console import Console

    async with NaviCodeTui(new_session=True).run_test() as pilot:
        await pilot.pause()
        chat = pilot.app.query_one("ChatPanel")
        captured = _capture_groups(chat)
        chat.clear()

        chat.load_history(
            [
                _hist_msg(role="user", content="hi there"),
                _hist_msg(role="assistant", content="hello back"),
            ]
        )
        await pilot.pause()
        group = captured[-1]
        # Two rendered bubbles, no truncation hint.
        assert len(group.renderables) == 2

        console = Console(record=True, width=80, force_terminal=True, color_system=None)

        def _text(r) -> str:
            console.print(r)
            return console.export_text(clear=True)

        rendered = _text(group.renderables[0]) + _text(group.renderables[1])
        assert "hi there" in rendered
        assert "hello back" in rendered