2026-04-27 15:29:52 +03:00
|
|
|
"""Tests for max_messages config wiring into session history replay."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from pathlib import Path
|
2026-04-28 06:39:59 +00:00
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
2026-04-27 15:29:52 +03:00
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from nanobot.agent.loop import AgentLoop
|
2026-04-28 06:39:59 +00:00
|
|
|
from nanobot.bus.events import InboundMessage
|
2026-04-27 15:29:52 +03:00
|
|
|
from nanobot.bus.queue import MessageBus
|
2026-04-28 06:39:59 +00:00
|
|
|
from nanobot.providers.base import LLMResponse
|
|
|
|
|
from nanobot.session.manager import Session
|
|
|
|
|
|
|
|
|
|
DEFAULT_MAX_MESSAGES = 120
|
2026-04-27 15:29:52 +03:00
|
|
|
|
|
|
|
|
|
2026-04-28 06:39:59 +00:00
|
|
|
def _make_loop(tmp_path: Path, max_messages: int = DEFAULT_MAX_MESSAGES) -> AgentLoop:
|
2026-04-27 15:29:52 +03:00
|
|
|
provider = MagicMock()
|
|
|
|
|
provider.get_default_model.return_value = "test-model"
|
|
|
|
|
return AgentLoop(
|
|
|
|
|
bus=MessageBus(),
|
|
|
|
|
provider=provider,
|
|
|
|
|
workspace=tmp_path,
|
|
|
|
|
model="test-model",
|
|
|
|
|
max_messages=max_messages,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-15 22:51:07 +08:00
|
|
|
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"},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-04-27 15:29:52 +03:00
|
|
|
class TestMaxMessagesInit:
|
|
|
|
|
"""Verify AgentLoop stores the config value correctly."""
|
|
|
|
|
|
2026-04-28 06:39:59 +00:00
|
|
|
def test_default_is_builtin_limit(self, tmp_path: Path) -> None:
|
2026-04-27 15:29:52 +03:00
|
|
|
loop = _make_loop(tmp_path)
|
2026-04-28 06:39:59 +00:00
|
|
|
assert loop._max_messages == DEFAULT_MAX_MESSAGES
|
2026-04-27 15:29:52 +03:00
|
|
|
|
|
|
|
|
def test_positive_value_stored(self, tmp_path: Path) -> None:
|
|
|
|
|
loop = _make_loop(tmp_path, max_messages=25)
|
|
|
|
|
assert loop._max_messages == 25
|
|
|
|
|
|
2026-04-28 06:39:59 +00:00
|
|
|
def test_zero_uses_builtin_limit(self, tmp_path: Path) -> None:
|
2026-04-27 15:29:52 +03:00
|
|
|
loop = _make_loop(tmp_path, max_messages=0)
|
2026-04-28 06:39:59 +00:00
|
|
|
assert loop._max_messages == DEFAULT_MAX_MESSAGES
|
2026-04-27 15:29:52 +03:00
|
|
|
|
2026-04-28 06:39:59 +00:00
|
|
|
def test_negative_treated_as_builtin_limit(self, tmp_path: Path) -> None:
|
2026-04-27 15:29:52 +03:00
|
|
|
"""Negative values should not produce negative slicing."""
|
|
|
|
|
loop = _make_loop(tmp_path, max_messages=-5)
|
2026-04-28 06:39:59 +00:00
|
|
|
assert loop._max_messages == DEFAULT_MAX_MESSAGES
|
2026-04-27 15:29:52 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestGetHistoryWithMaxMessages:
|
|
|
|
|
"""Verify get_history respects max_messages parameter."""
|
|
|
|
|
|
2026-04-28 06:39:59 +00:00
|
|
|
def test_default_uses_builtin_limit(self) -> None:
|
2026-04-27 15:29:52 +03:00
|
|
|
session = _populated_session(80)
|
|
|
|
|
history = session.get_history()
|
2026-04-28 06:39:59 +00:00
|
|
|
assert len(history) <= DEFAULT_MAX_MESSAGES
|
2026-04-27 15:29:52 +03:00
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
2026-04-28 06:39:59 +00:00
|
|
|
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) <= DEFAULT_MAX_MESSAGES
|
2026-04-27 15:29:52 +03:00
|
|
|
|
|
|
|
|
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 the config flows from AgentLoop into get_history calls."""
|
|
|
|
|
|
2026-04-28 06:39:59 +00:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_process_message_passes_config_to_history_call(self, tmp_path: Path) -> None:
|
|
|
|
|
"""The real message path should pass max_messages into session history replay."""
|
2026-04-27 15:29:52 +03:00
|
|
|
loop = _make_loop(tmp_path, max_messages=25)
|
2026-04-28 06:39:59 +00:00
|
|
|
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]
|
2026-04-27 15:29:52 +03:00
|
|
|
|
2026-04-28 06:39:59 +00:00
|
|
|
session = loop.sessions.get_or_create("cli:test")
|
2026-04-27 15:29:52 +03:00
|
|
|
with patch.object(session, "get_history", wraps=session.get_history) as mock_hist:
|
2026-04-28 06:39:59 +00:00
|
|
|
result = await loop._process_message(
|
|
|
|
|
InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
|
|
|
|
|
)
|
2026-04-27 15:29:52 +03:00
|
|
|
|
2026-04-28 06:39:59 +00:00
|
|
|
assert result is not None
|
|
|
|
|
assert mock_hist.call_count == 1
|
|
|
|
|
assert mock_hist.call_args.kwargs["max_messages"] == 25
|
2026-06-15 22:51:07 +08:00
|
|
|
assert mock_hist.call_args.kwargs["extend_to_user"] is False
|
2026-04-27 15:29:52 +03:00
|
|
|
|
2026-04-28 06:39:59 +00:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_zero_config_passes_builtin_limit_to_history_call(self, tmp_path: Path) -> None:
|
2026-04-27 15:29:52 +03:00
|
|
|
loop = _make_loop(tmp_path, max_messages=0)
|
2026-04-28 06:39:59 +00:00
|
|
|
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]
|
2026-04-27 15:29:52 +03:00
|
|
|
|
2026-04-28 06:39:59 +00:00
|
|
|
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")
|
|
|
|
|
)
|
2026-04-27 15:29:52 +03:00
|
|
|
|
2026-04-28 06:39:59 +00:00
|
|
|
assert result is not None
|
|
|
|
|
assert mock_hist.call_args.kwargs["max_messages"] == DEFAULT_MAX_MESSAGES
|
2026-06-15 22:51:07 +08:00
|
|
|
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, max_messages=6)
|
|
|
|
|
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(8):
|
|
|
|
|
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
|
2026-04-27 15:29:52 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSchemaConfig:
|
|
|
|
|
"""Verify the config schema accepts max_messages."""
|
|
|
|
|
|
|
|
|
|
def test_schema_default(self) -> None:
|
|
|
|
|
from nanobot.config.schema import AgentDefaults
|
|
|
|
|
|
|
|
|
|
defaults = AgentDefaults()
|
2026-04-28 06:39:59 +00:00
|
|
|
assert defaults.max_messages == DEFAULT_MAX_MESSAGES
|
|
|
|
|
|
|
|
|
|
def test_schema_accepts_zero_as_builtin_limit(self) -> None:
|
|
|
|
|
from nanobot.config.schema import AgentDefaults
|
|
|
|
|
|
|
|
|
|
defaults = AgentDefaults(max_messages=0)
|
2026-04-27 15:29:52 +03:00
|
|
|
assert defaults.max_messages == 0
|
|
|
|
|
|
|
|
|
|
def test_schema_accepts_positive(self) -> None:
|
|
|
|
|
from nanobot.config.schema import AgentDefaults
|
|
|
|
|
|
|
|
|
|
defaults = AgentDefaults(max_messages=25)
|
|
|
|
|
assert defaults.max_messages == 25
|
|
|
|
|
|
|
|
|
|
def test_schema_rejects_negative(self) -> None:
|
|
|
|
|
from nanobot.config.schema import AgentDefaults
|
|
|
|
|
|
|
|
|
|
with pytest.raises(Exception): # Pydantic validation error
|
|
|
|
|
AgentDefaults(max_messages=-1)
|