"""Tests for the internal max_messages replay cap.""" from __future__ import annotations from dataclasses import replace from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from nanobot.agent.loop import AgentLoop from nanobot.bus.events import InboundMessage from nanobot.bus.queue import MessageBus from nanobot.providers.base import LLMResponse from nanobot.providers.factory import ProviderSnapshot from nanobot.session.manager import ( FILE_MAX_MESSAGES, Session, replay_max_messages_for_context, ) def _make_loop( tmp_path: Path, context_window_tokens: int = 200_000, ) -> AgentLoop: provider = MagicMock() provider.get_default_model.return_value = "test-model" provider.generation.max_tokens = 4096 return AgentLoop( bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model", context_window_tokens=context_window_tokens, ) def _populated_session(n: int) -> Session: """Create a session with *n* user/assistant turn pairs.""" session = Session(key="test:populated") for i in range(n): session.add_message("user", f"msg-{i}") session.add_message("assistant", f"reply-{i}") return session def _tool_round(call_id: str) -> list[dict]: return [ { "role": "assistant", "content": None, "tool_calls": [ {"id": call_id, "type": "function", "function": {"name": "x", "arguments": "{}"}} ], }, {"role": "tool", "tool_call_id": call_id, "name": "x", "content": "ok"}, ] class TestMaxMessagesInit: """Verify AgentLoop derives the internal replay cap correctly.""" def test_context_formula(self) -> None: assert replay_max_messages_for_context(8_000) == 120 assert replay_max_messages_for_context(32_768) == 327 assert replay_max_messages_for_context(200_000) == FILE_MAX_MESSAGES def test_default_for_200k_context_reaches_file_cap(self, tmp_path: Path) -> None: loop = _make_loop(tmp_path) runtime = loop.runtime_resolver.runtime assert replay_max_messages_for_context(runtime.context_window_tokens) == FILE_MAX_MESSAGES def test_default_scales_with_context_window(self, tmp_path: Path) -> None: loop = _make_loop(tmp_path, context_window_tokens=32_768) runtime = loop.runtime_resolver.runtime assert replay_max_messages_for_context(runtime.context_window_tokens) == 327 def test_provider_refresh_resyncs_context_derived_limit(self, tmp_path: Path) -> None: old_provider = MagicMock() old_provider.get_default_model.return_value = "old-model" old_provider.generation.max_tokens = 4096 new_provider = MagicMock() new_provider.generation.max_tokens = 4096 loop = AgentLoop( bus=MessageBus(), provider=old_provider, workspace=tmp_path, model="old-model", context_window_tokens=32_768, provider_snapshot_loader=lambda: ProviderSnapshot( provider=new_provider, model="new-model", context_window_tokens=200_000, signature=("new-model",), ), ) initial = loop.runtime_resolver.runtime assert replay_max_messages_for_context(initial.context_window_tokens) == 327 refreshed = loop.llm_runtime() assert replay_max_messages_for_context(refreshed.context_window_tokens) == FILE_MAX_MESSAGES class TestGetHistoryWithMaxMessages: """Verify get_history respects max_messages parameter.""" def test_default_uses_builtin_limit(self) -> None: session = _populated_session(80) history = session.get_history() assert len(history) <= FILE_MAX_MESSAGES def test_explicit_max_messages_limits_output(self) -> None: session = _populated_session(40) # 80 messages total history = session.get_history(max_messages=20) assert len(history) <= 20 def test_max_messages_starts_at_user_turn(self) -> None: """Sliced history should start with a user message, not mid-turn.""" session = _populated_session(30) # 60 messages history = session.get_history(max_messages=25) assert history[0]["role"] == "user" def test_max_messages_zero_uses_builtin_limit(self) -> None: session = _populated_session(80) # 160 messages total history = session.get_history(max_messages=0) assert len(history) <= FILE_MAX_MESSAGES def test_small_session_unaffected(self) -> None: """When session has fewer messages than max_messages, all are returned.""" session = _populated_session(5) # 10 messages history = session.get_history(max_messages=25) assert len(history) == 10 class TestMaxMessagesIntegration: """Verify AgentLoop passes the replay cap into get_history calls.""" @pytest.mark.asyncio async def test_process_message_passes_limit_to_history_call(self, tmp_path: Path) -> None: """The real message path should pass max_messages into session history replay.""" loop = _make_loop(tmp_path) runtime = replace(loop.llm_runtime(), context_window_tokens=32_768) loop.provider.chat_with_retry = AsyncMock( return_value=LLMResponse(content="ok", tool_calls=[], usage={}) ) loop.tools.get_definitions = MagicMock(return_value=[]) loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] session = loop.sessions.get_or_create("cli:test") with patch.object(session, "get_history", wraps=session.get_history) as mock_hist: result = await loop._process_message( InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello"), runtime=runtime, ) assert result is not None assert mock_hist.call_count == 1 assert mock_hist.call_args.kwargs["max_messages"] == 327 assert mock_hist.call_args.kwargs["extend_to_user"] is False @pytest.mark.asyncio async def test_default_limit_passes_context_derived_limit_to_history_call( self, tmp_path: Path, ) -> None: loop = _make_loop(tmp_path) loop.provider.chat_with_retry = AsyncMock( return_value=LLMResponse(content="ok", tool_calls=[], usage={}) ) loop.tools.get_definitions = MagicMock(return_value=[]) loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] session = loop.sessions.get_or_create("cli:test") with patch.object(session, "get_history", wraps=session.get_history) as mock_hist: result = await loop._process_message( InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello") ) assert result is not None assert mock_hist.call_args.kwargs["max_messages"] == FILE_MAX_MESSAGES assert mock_hist.call_args.kwargs["extend_to_user"] is False @pytest.mark.asyncio async def test_process_message_uses_current_user_as_replay_boundary( self, tmp_path: Path, ) -> None: """A live user turn should not extend history to an older long tool turn.""" loop = _make_loop(tmp_path, context_window_tokens=8_000) loop.provider.chat_with_retry = AsyncMock( return_value=LLMResponse(content="ok", tool_calls=[], usage={}) ) loop.tools.get_definitions = MagicMock(return_value=[]) loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] session = loop.sessions.get_or_create("cli:test") session.add_message("user", "old") session.add_message("assistant", "old answer") session.add_message("user", "long older turn") for i in range(70): session.messages.extend(_tool_round(f"older-{i}")) session.add_message("assistant", "older final") with patch.object(session, "get_history", wraps=session.get_history) as mock_hist: result = await loop._process_message( InboundMessage( channel="cli", sender_id="user", chat_id="test", content="new question", ) ) assert result is not None assert mock_hist.call_args.kwargs["extend_to_user"] is False sent_messages = loop.provider.chat_with_retry.await_args.kwargs["messages"] sent_text = "\n".join(str(message.get("content")) for message in sent_messages) assert "new question" in sent_text assert "long older turn" not in sent_text