test: tighten consolidation ratio coverage

Made-with: Cursor
This commit is contained in:
Xubin Ren 2026-04-26 12:20:07 +00:00 committed by Xubin Ren
parent fca56d324a
commit 727086ddac

View File

@ -1,12 +1,14 @@
"""Tests for the configurable consolidation_ratio feature."""
"""Tests for configurable consolidation_ratio."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from pydantic import ValidationError
from nanobot.agent.loop import AgentLoop
import nanobot.agent.memory as memory_module
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import GenerationSettings, LLMResponse
@ -38,140 +40,67 @@ def _make_loop(
return loop
@pytest.mark.asyncio
async def test_default_ratio_uses_half_budget(tmp_path, monkeypatch) -> None:
"""With ratio=0.5 (default), target should be half of budget."""
loop = _make_loop(tmp_path, context_window_tokens=200, consolidation_ratio=0.5)
loop.consolidator.archive = AsyncMock(return_value=True) # type: ignore[method-assign]
def _session_with_turns(loop: AgentLoop, *, turns: int):
session = loop.sessions.get_or_create("cli:test")
session.messages = [
{"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"},
{"role": "assistant", "content": "a1", "timestamp": "2026-01-01T00:00:01"},
{"role": "user", "content": "u2", "timestamp": "2026-01-01T00:00:02"},
{"role": "assistant", "content": "a2", "timestamp": "2026-01-01T00:00:03"},
{"role": "user", "content": "u3", "timestamp": "2026-01-01T00:00:04"},
{"role": "assistant", "content": "a3", "timestamp": "2026-01-01T00:00:05"},
{"role": "user", "content": "u4", "timestamp": "2026-01-01T00:00:06"},
]
loop.sessions.save(session)
# budget = 200 - 0 (max_tokens) - 0 (safety_buffer) = 200
# target = int(200 * 0.5) = 100
# estimated must be >= budget to trigger consolidation
call_count = [0]
def mock_estimate(_session):
call_count[0] += 1
if call_count[0] == 1:
return (250, "test")
return (90, "test")
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 100)
await loop.consolidator.maybe_consolidate_by_tokens(session)
# 250 >= 200 (budget, triggers) → 250 > 100 (target) → archive → 90 < 100, stops.
assert loop.consolidator.archive.await_count == 1
@pytest.mark.asyncio
async def test_low_ratio_aggressively_consolidates(tmp_path, monkeypatch) -> None:
"""With ratio=0.1, target is only 10% of budget — more rounds of archiving."""
loop = _make_loop(tmp_path, context_window_tokens=1000, consolidation_ratio=0.1)
loop.consolidator.archive = AsyncMock(return_value=True) # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test")
# Interleave user/assistant so pick_consolidation_boundary can find boundaries
session.messages = []
for i in range(10):
for i in range(turns):
session.messages.append({"role": "user", "content": f"u{i}", "timestamp": f"2026-01-01T00:00:{i:02d}"})
session.messages.append({"role": "assistant", "content": f"a{i}", "timestamp": f"2026-01-01T00:00:{i:02d}"})
session.messages.append({"role": "assistant", "content": f"a{i}", "timestamp": f"2026-01-01T00:01:{i:02d}"})
loop.sessions.save(session)
# budget = 1000, target = int(1000 * 0.1) = 100
call_count = [0]
def mock_estimate(_session):
call_count[0] += 1
if call_count[0] == 1:
return (1200, "test")
if call_count[0] == 2:
return (800, "test")
if call_count[0] == 3:
return (400, "test")
return (50, "test")
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 100)
await loop.consolidator.maybe_consolidate_by_tokens(session)
# With low ratio, more rounds needed to reach target; at least 2 rounds
assert loop.consolidator.archive.await_count >= 2
return session
@pytest.mark.asyncio
async def test_high_ratio_preserves_more_history(tmp_path, monkeypatch) -> None:
"""With ratio=0.9, target is 90% of budget — consolidation stops sooner."""
loop = _make_loop(tmp_path, context_window_tokens=200, consolidation_ratio=0.9)
@pytest.mark.parametrize(
("ratio", "context_window_tokens", "estimates", "expected_archives"),
[
(0.5, 200, [250, 90], 1),
(0.1, 1000, [1200, 800, 400, 50], 2),
(0.9, 200, [300, 175], 1),
],
)
async def test_consolidation_ratio_controls_target(
tmp_path,
monkeypatch,
ratio: float,
context_window_tokens: int,
estimates: list[int],
expected_archives: int,
) -> None:
loop = _make_loop(
tmp_path,
context_window_tokens=context_window_tokens,
consolidation_ratio=ratio,
)
loop.consolidator.archive = AsyncMock(return_value=True) # type: ignore[method-assign]
session = _session_with_turns(loop, turns=10)
session = loop.sessions.get_or_create("cli:test")
session.messages = [
{"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"},
{"role": "assistant", "content": "a1", "timestamp": "2026-01-01T00:00:01"},
{"role": "user", "content": "u2", "timestamp": "2026-01-01T00:00:02"},
{"role": "assistant", "content": "a2", "timestamp": "2026-01-01T00:00:03"},
{"role": "user", "content": "u3", "timestamp": "2026-01-01T00:00:04"},
{"role": "assistant", "content": "a3", "timestamp": "2026-01-01T00:00:05"},
{"role": "user", "content": "u4", "timestamp": "2026-01-01T00:00:06"},
]
loop.sessions.save(session)
remaining_estimates = list(estimates)
# budget = 200, target = int(200 * 0.9) = 180
call_count = [0]
def mock_estimate(_session):
call_count[0] += 1
if call_count[0] == 1:
return (300, "test")
return (175, "test")
def mock_estimate(_session, *, session_summary=None):
assert session_summary is None
return (remaining_estimates.pop(0), "test")
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 100)
await loop.consolidator.maybe_consolidate_by_tokens(session)
# 300 >= 200 (triggers) → 300 > 180 → archive → 175 < 180 → stop
assert loop.consolidator.archive.await_count == 1
assert loop.consolidator.archive.await_count == expected_archives
@pytest.mark.asyncio
async def test_ratio_propagated_from_config_schema() -> None:
"""Verify consolidation_ratio is parsed from config with camelCase alias."""
from nanobot.config.schema import AgentDefaults
# Default
def test_ratio_propagated_from_config_schema() -> None:
defaults = AgentDefaults()
assert defaults.consolidation_ratio == 0.5
# camelCase alias
defaults = AgentDefaults.model_validate({"consolidationRatio": 0.3})
assert defaults.consolidation_ratio == 0.3
# Serialization uses alias
dumped = defaults.model_dump(by_alias=True)
assert dumped["consolidationRatio"] == 0.3
@pytest.mark.asyncio
async def test_ratio_validation_rejects_out_of_range() -> None:
"""Invalid ratio values should be rejected by validation."""
from pydantic import ValidationError
from nanobot.config.schema import AgentDefaults
def test_ratio_validation_rejects_out_of_range() -> None:
with pytest.raises(ValidationError):
AgentDefaults(consolidation_ratio=0.05)