feat(session): enforce replay/file-cap invariants for history lifecycle
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -15,7 +15,13 @@ from nanobot.command import CommandContext
|
||||
from nanobot.providers.base import LLMResponse
|
||||
|
||||
|
||||
def _make_loop(tmp_path: Path, session_ttl_minutes: int = 15) -> AgentLoop:
|
||||
def _make_loop(
|
||||
tmp_path: Path,
|
||||
session_ttl_minutes: int = 15,
|
||||
session_history_max_messages: int | None = None,
|
||||
session_history_max_tokens: int | None = None,
|
||||
session_file_max_messages: int | None = None,
|
||||
) -> AgentLoop:
|
||||
"""Create a minimal AgentLoop for testing."""
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
@@ -30,6 +36,9 @@ def _make_loop(tmp_path: Path, session_ttl_minutes: int = 15) -> AgentLoop:
|
||||
model="test-model",
|
||||
context_window_tokens=128_000,
|
||||
session_ttl_minutes=session_ttl_minutes,
|
||||
session_history_max_messages=session_history_max_messages,
|
||||
session_history_max_tokens=session_history_max_tokens,
|
||||
session_file_max_messages=session_file_max_messages,
|
||||
)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
return loop
|
||||
@@ -72,6 +81,34 @@ class TestSessionTTLConfig:
|
||||
assert data["idleCompactAfterMinutes"] == 30
|
||||
assert "sessionTtlMinutes" not in data
|
||||
|
||||
def test_default_session_history_window(self):
|
||||
"""Session history replay should be capped by default."""
|
||||
defaults = AgentDefaults()
|
||||
assert defaults.session_history_max_messages == 120
|
||||
|
||||
def test_default_session_history_token_budget_auto(self):
|
||||
defaults = AgentDefaults()
|
||||
assert defaults.session_history_max_tokens == 0
|
||||
|
||||
def test_default_session_file_cap(self):
|
||||
defaults = AgentDefaults()
|
||||
assert defaults.session_file_max_messages == 2000
|
||||
|
||||
def test_serializes_session_history_window(self):
|
||||
"""Config should expose sessionHistoryMaxMessages in JSON output."""
|
||||
defaults = AgentDefaults(session_history_max_messages=64)
|
||||
data = defaults.model_dump(mode="json", by_alias=True)
|
||||
assert data["sessionHistoryMaxMessages"] == 64
|
||||
|
||||
def test_serializes_history_token_budget_and_file_cap(self):
|
||||
defaults = AgentDefaults(
|
||||
session_history_max_tokens=2048,
|
||||
session_file_max_messages=1024,
|
||||
)
|
||||
data = defaults.model_dump(mode="json", by_alias=True)
|
||||
assert data["sessionHistoryMaxTokens"] == 2048
|
||||
assert data["sessionFileMaxMessages"] == 1024
|
||||
|
||||
|
||||
class TestAgentLoopTTLParam:
|
||||
"""Test that AutoCompact receives and stores session_ttl_minutes."""
|
||||
@@ -86,6 +123,109 @@ class TestAgentLoopTTLParam:
|
||||
loop = _make_loop(tmp_path, session_ttl_minutes=0)
|
||||
assert loop.auto_compact._ttl == 0
|
||||
|
||||
def test_loop_stores_history_window(self, tmp_path):
|
||||
"""AgentLoop should store configured session history max_messages."""
|
||||
loop = _make_loop(tmp_path, session_history_max_messages=42)
|
||||
assert loop.session_history_max_messages == 42
|
||||
|
||||
def test_loop_stores_history_token_budget(self, tmp_path):
|
||||
loop = _make_loop(tmp_path, session_history_max_tokens=2048)
|
||||
assert loop.session_history_max_tokens == 2048
|
||||
|
||||
def test_loop_stores_session_file_cap(self, tmp_path):
|
||||
loop = _make_loop(tmp_path, session_file_max_messages=512)
|
||||
assert loop.session_file_max_messages == 512
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_reads_history_with_configured_cap(self, tmp_path):
|
||||
"""_process_message should use session_history_max_messages, not unlimited history."""
|
||||
loop = _make_loop(tmp_path, session_history_max_messages=7)
|
||||
session = loop.sessions.get_or_create("cli:direct")
|
||||
session.get_history = MagicMock(return_value=[])
|
||||
loop.context.build_messages = MagicMock(return_value=[])
|
||||
loop._run_agent_loop = AsyncMock(return_value=("ok", [], [], "stop", False))
|
||||
loop._save_turn = MagicMock()
|
||||
|
||||
msg = InboundMessage(
|
||||
channel="cli",
|
||||
sender_id="u1",
|
||||
chat_id="direct",
|
||||
content="hello",
|
||||
)
|
||||
await loop._process_message(msg)
|
||||
session.get_history.assert_called_once()
|
||||
kwargs = session.get_history.call_args.kwargs
|
||||
assert kwargs["max_messages"] == 7
|
||||
assert isinstance(kwargs.get("max_tokens"), int)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_reads_history_with_token_budget(self, tmp_path):
|
||||
loop = _make_loop(
|
||||
tmp_path,
|
||||
session_history_max_messages=7,
|
||||
session_history_max_tokens=333,
|
||||
)
|
||||
session = loop.sessions.get_or_create("cli:direct")
|
||||
session.get_history = MagicMock(return_value=[])
|
||||
loop.context.build_messages = MagicMock(return_value=[])
|
||||
loop._run_agent_loop = AsyncMock(return_value=("ok", [], [], "stop", False))
|
||||
loop._save_turn = MagicMock()
|
||||
|
||||
msg = InboundMessage(
|
||||
channel="cli",
|
||||
sender_id="u1",
|
||||
chat_id="direct",
|
||||
content="hello",
|
||||
)
|
||||
await loop._process_message(msg)
|
||||
session.get_history.assert_called_once_with(max_messages=7, max_tokens=333)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_file_cap_archives_and_trims_old_messages(self, tmp_path):
|
||||
loop = _make_loop(tmp_path, session_file_max_messages=6)
|
||||
loop.context.memory.raw_archive = MagicMock()
|
||||
|
||||
for i in range(4):
|
||||
msg = InboundMessage(
|
||||
channel="cli",
|
||||
sender_id="u1",
|
||||
chat_id="direct",
|
||||
content=f"hello {i}",
|
||||
)
|
||||
await loop._process_message(msg)
|
||||
|
||||
session = loop.sessions.get_or_create("cli:direct")
|
||||
assert len(session.messages) <= 6
|
||||
assert loop.context.memory.raw_archive.called
|
||||
|
||||
def test_session_file_cap_skips_raw_archive_when_dropped_prefix_is_already_consolidated(self, tmp_path):
|
||||
loop = _make_loop(tmp_path, session_file_max_messages=4)
|
||||
loop.context.memory.raw_archive = MagicMock()
|
||||
session = loop.sessions.get_or_create("cli:direct")
|
||||
for i in range(8):
|
||||
session.add_message("user", f"u{i}")
|
||||
session.last_consolidated = 6
|
||||
|
||||
loop._enforce_session_file_cap(session)
|
||||
|
||||
assert len(session.messages) <= 4
|
||||
loop.context.memory.raw_archive.assert_not_called()
|
||||
|
||||
def test_session_file_cap_archives_only_unconsolidated_part_of_dropped_prefix(self, tmp_path):
|
||||
loop = _make_loop(tmp_path, session_file_max_messages=4)
|
||||
loop.context.memory.raw_archive = MagicMock()
|
||||
session = loop.sessions.get_or_create("cli:direct")
|
||||
for i in range(8):
|
||||
session.add_message("user", f"u{i}")
|
||||
session.last_consolidated = 2
|
||||
|
||||
loop._enforce_session_file_cap(session)
|
||||
|
||||
assert len(session.messages) <= 4
|
||||
loop.context.memory.raw_archive.assert_called_once()
|
||||
archived = loop.context.memory.raw_archive.call_args.args[0]
|
||||
assert [m["content"] for m in archived] == ["u2", "u3"]
|
||||
|
||||
|
||||
class TestAutoCompact:
|
||||
"""Test the _archive method."""
|
||||
|
||||
@@ -269,3 +269,66 @@ def test_get_history_ignores_media_kwarg_on_non_user_rows():
|
||||
# List content is passed through verbatim — the synthesizer only
|
||||
# rewrites plain-string content.
|
||||
assert history[0]["content"] == [{"type": "text", "text": "structured"}]
|
||||
|
||||
|
||||
def test_get_history_respects_max_tokens(monkeypatch):
|
||||
session = Session(key="test:token-cap")
|
||||
session.messages.extend(
|
||||
[
|
||||
{"role": "user", "content": "u1"},
|
||||
{"role": "assistant", "content": "a1"},
|
||||
{"role": "user", "content": "u2"},
|
||||
{"role": "assistant", "content": "a2"},
|
||||
{"role": "user", "content": "u3"},
|
||||
{"role": "assistant", "content": "a3"},
|
||||
]
|
||||
)
|
||||
|
||||
token_map = {"u1": 50, "a1": 50, "u2": 50, "a2": 50, "u3": 50, "a3": 50}
|
||||
monkeypatch.setattr(
|
||||
"nanobot.session.manager.estimate_message_tokens",
|
||||
lambda message: token_map.get(message.get("content"), 0),
|
||||
)
|
||||
|
||||
history = session.get_history(max_messages=500, max_tokens=120)
|
||||
assert [m["content"] for m in history] == ["u3", "a3"]
|
||||
|
||||
|
||||
def test_get_history_recovers_user_when_token_slice_would_be_assistant_only(monkeypatch):
|
||||
session = Session(key="test:assistant-only-slice")
|
||||
session.messages.extend(
|
||||
[
|
||||
{"role": "user", "content": "u1"},
|
||||
{"role": "assistant", "content": "a1"},
|
||||
{"role": "user", "content": "u2"},
|
||||
{"role": "assistant", "content": "a2"},
|
||||
]
|
||||
)
|
||||
token_map = {"u1": 100, "a1": 100, "u2": 100, "a2": 100}
|
||||
monkeypatch.setattr(
|
||||
"nanobot.session.manager.estimate_message_tokens",
|
||||
lambda message: token_map.get(message.get("content"), 0),
|
||||
)
|
||||
|
||||
history = session.get_history(max_messages=500, max_tokens=100)
|
||||
assert [m["content"] for m in history] == ["u2", "a2"]
|
||||
|
||||
|
||||
def test_retain_recent_legal_suffix_hard_cap_with_long_non_user_chain():
|
||||
session = Session(key="test:hard-cap-chain")
|
||||
session.messages.append({"role": "user", "content": "u0"})
|
||||
session.messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{"id": "c1", "type": "function", "function": {"name": "x", "arguments": "{}"}}
|
||||
],
|
||||
}
|
||||
)
|
||||
for i in range(12):
|
||||
session.messages.append({"role": "assistant", "content": f"a{i}"})
|
||||
|
||||
session.retain_recent_legal_suffix(6)
|
||||
|
||||
assert len(session.messages) <= 6
|
||||
|
||||
Reference in New Issue
Block a user