Merge PR #3459: feat(session): enforce replay/file-cap invariants for history lifecycle

feat(session): enforce replay/file-cap invariants for history lifecycle
This commit is contained in:
Xubin Ren
2026-04-27 16:17:23 +08:00
committed by GitHub
4 changed files with 262 additions and 13 deletions
+80 -2
View File
@@ -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,10 @@ 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,
) -> AgentLoop:
"""Create a minimal AgentLoop for testing."""
bus = MessageBus()
provider = MagicMock()
@@ -72,6 +75,12 @@ class TestSessionTTLConfig:
assert data["idleCompactAfterMinutes"] == 30
assert "sessionTtlMinutes" not in data
def test_session_history_and_file_cap_are_internal_constants(self):
"""Session history/file cap should be internal constants, not config fields."""
from nanobot.session.manager import HISTORY_MAX_MESSAGES, FILE_MAX_MESSAGES
assert HISTORY_MAX_MESSAGES == 120
assert FILE_MAX_MESSAGES == 2000
class TestAgentLoopTTLParam:
"""Test that AutoCompact receives and stores session_ttl_minutes."""
@@ -86,6 +95,75 @@ class TestAgentLoopTTLParam:
loop = _make_loop(tmp_path, session_ttl_minutes=0)
assert loop.auto_compact._ttl == 0
@pytest.mark.asyncio
async def test_process_message_reads_history_with_token_budget(self, tmp_path):
"""_process_message should pass an auto-derived token budget to get_history."""
loop = _make_loop(tmp_path)
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 isinstance(kwargs.get("max_tokens"), int)
assert kwargs["max_tokens"] > 0
assert kwargs["include_timestamps"] is True
@pytest.mark.asyncio
async def test_session_file_cap_archives_and_trims_old_messages(self, tmp_path):
loop = _make_loop(tmp_path)
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")
from nanobot.session.manager import FILE_MAX_MESSAGES
assert len(session.messages) <= FILE_MAX_MESSAGES
def test_session_enforce_file_cap_skips_archive_when_dropped_prefix_already_consolidated(self, tmp_path):
from nanobot.session.manager import Session
archive_fn = MagicMock()
session = Session(key="cli:direct")
for i in range(8):
session.add_message("user", f"u{i}")
session.last_consolidated = 6
session.enforce_file_cap(on_archive=archive_fn, limit=4)
assert len(session.messages) <= 4
archive_fn.assert_not_called()
def test_session_enforce_file_cap_archives_only_unconsolidated_dropped_prefix(self, tmp_path):
from nanobot.session.manager import Session
archive_fn = MagicMock()
session = Session(key="cli:direct")
for i in range(8):
session.add_message("user", f"u{i}")
session.last_consolidated = 2
session.enforce_file_cap(on_archive=archive_fn, limit=4)
assert len(session.messages) <= 4
archive_fn.assert_called_once()
archived = archive_fn.call_args.args[0]
assert [m["content"] for m in archived] == ["u2", "u3"]
class TestAutoCompact:
"""Test the _archive method."""
@@ -350,3 +350,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