mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 05:18:49 +03:00
fix(session): preserve history during idle compaction (#5167)
This commit is contained in:
@@ -586,7 +586,7 @@ class TestConsolidatorTokenBudget:
|
||||
|
||||
|
||||
class TestCompactIdleSession:
|
||||
"""Tests for Consolidator.compact_idle_session — lock-protected idle truncation."""
|
||||
"""Idle compaction tests."""
|
||||
|
||||
@pytest.fixture
|
||||
def real_consolidator(self, store, mock_provider):
|
||||
@@ -602,11 +602,9 @@ class TestCompactIdleSession:
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archives_prefix_keeps_suffix(
|
||||
async def test_archives_prefix_preserves_messages_and_hides_prefix(
|
||||
self, real_consolidator, mock_provider, runtime
|
||||
):
|
||||
"""20 user/assistant turns → compact with max_suffix=8 → messages ≤ 8,
|
||||
last_consolidated=0, _last_summary stored."""
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||
content="Summary of old conversation.", finish_reason="stop"
|
||||
)
|
||||
@@ -624,9 +622,15 @@ class TestCompactIdleSession:
|
||||
)
|
||||
assert result == "Summary of old conversation."
|
||||
|
||||
sessions.invalidate("cli:test")
|
||||
reloaded = sessions.get_or_create("cli:test")
|
||||
assert len(reloaded.messages) <= 8
|
||||
assert reloaded.last_consolidated == 0
|
||||
assert len(reloaded.messages) == 40
|
||||
assert reloaded.messages[0]["content"] == "user msg 0"
|
||||
assert reloaded.last_consolidated == 32
|
||||
visible = reloaded.get_history(max_messages=40)
|
||||
assert len(visible) == 8
|
||||
assert visible[0]["content"] == "user msg 16"
|
||||
assert visible[-1]["content"] == "assistant msg 19"
|
||||
meta = reloaded.metadata.get("_last_summary")
|
||||
assert meta is not None
|
||||
assert meta["text"] == "Summary of old conversation."
|
||||
@@ -665,9 +669,7 @@ class TestCompactIdleSession:
|
||||
async def test_raw_dumps_only_dropped_messages_on_llm_failure(
|
||||
self, real_consolidator, mock_provider, store, runtime
|
||||
):
|
||||
"""Summarizing over the full tail must not widen what gets raw-dumped on
|
||||
LLM failure: the breadcrumb should contain only the removed prefix, not
|
||||
the retained suffix that stays live in the session. Regression for #4264."""
|
||||
"""Extra summary context must not enter raw fallback. Regression for #4264."""
|
||||
mock_provider.chat_with_retry.side_effect = RuntimeError("LLM unavailable")
|
||||
sessions = real_consolidator.sessions
|
||||
session = sessions.get_or_create("cli:rawdrop")
|
||||
@@ -684,8 +686,11 @@ class TestCompactIdleSession:
|
||||
|
||||
raw = "\n".join(e["content"] for e in store.read_unprocessed_history(since_cursor=0))
|
||||
assert "[RAW]" in raw
|
||||
assert "user msg 0" in raw # removed prefix is the breadcrumb
|
||||
assert "RETAINED_SUFFIX_marker" not in raw # retained suffix not dumped
|
||||
assert "user msg 0" in raw
|
||||
assert "RETAINED_SUFFIX_marker" not in raw
|
||||
reloaded = sessions.get_or_create("cli:rawdrop")
|
||||
assert len(reloaded.messages) == 38
|
||||
assert reloaded.messages[-1]["content"] == "RETAINED_SUFFIX_marker"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_compact_writes_session_key_to_history(
|
||||
@@ -757,10 +762,9 @@ class TestCompactIdleSession:
|
||||
assert "_last_summary" not in reloaded.metadata
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_failure_still_truncates(
|
||||
async def test_llm_failure_preserves_history_but_advances_replay_boundary(
|
||||
self, real_consolidator, mock_provider, store, runtime
|
||||
):
|
||||
"""LLM raises RuntimeError → raw_archive fires, session still truncated, returns None."""
|
||||
mock_provider.chat_with_retry.side_effect = RuntimeError("LLM unavailable")
|
||||
sessions = real_consolidator.sessions
|
||||
session = sessions.get_or_create("cli:fail")
|
||||
@@ -778,9 +782,16 @@ class TestCompactIdleSession:
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert any("[RAW]" in e["content"] for e in entries)
|
||||
|
||||
# Session should still be truncated
|
||||
reloaded = sessions.get_or_create("cli:fail")
|
||||
assert len(reloaded.messages) <= 4
|
||||
assert len(reloaded.messages) == 20
|
||||
assert reloaded.messages[0]["content"] == "u0"
|
||||
assert reloaded.last_consolidated == 16
|
||||
assert [m["content"] for m in reloaded.get_history(max_messages=20)] == [
|
||||
"u8",
|
||||
"a8",
|
||||
"u9",
|
||||
"a9",
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respects_last_consolidated(
|
||||
@@ -802,6 +813,9 @@ class TestCompactIdleSession:
|
||||
"cli:offset", runtime=runtime, max_suffix=4
|
||||
)
|
||||
assert result == "Tail summary."
|
||||
reloaded = sessions.get_or_create("cli:offset")
|
||||
assert len(reloaded.messages) == 60
|
||||
assert reloaded.last_consolidated == 56
|
||||
|
||||
# Verify only the unconsolidated tail was processed:
|
||||
# 10 unconsolidated messages (50-59), keep suffix of 4 → archive 6
|
||||
@@ -812,14 +826,12 @@ class TestCompactIdleSession:
|
||||
assert "u25" in user_content or "a25" in user_content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_contiguous_suffix_archives_actual_dropped_messages(
|
||||
async def test_extended_suffix_archives_only_hidden_prefix(
|
||||
self,
|
||||
real_consolidator,
|
||||
mock_provider,
|
||||
runtime,
|
||||
):
|
||||
"""Assistant-only tails extend back to the latest user turn, so archive
|
||||
the actual dropped messages rather than a computed prefix."""
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||
content="Tail summary.", finish_reason="stop"
|
||||
)
|
||||
@@ -837,7 +849,9 @@ class TestCompactIdleSession:
|
||||
assert result == "Tail summary."
|
||||
|
||||
reloaded = sessions.get_or_create("cli:noncontiguous")
|
||||
assert [m["content"] for m in reloaded.messages] == [
|
||||
assert len(reloaded.messages) == 25
|
||||
assert reloaded.last_consolidated == 14
|
||||
assert [m["content"] for m in reloaded.get_history(max_messages=25)] == [
|
||||
"user-14",
|
||||
"assistant-00",
|
||||
"assistant-01",
|
||||
@@ -987,23 +1001,21 @@ class TestConsolidatorSessionRefresh:
|
||||
# Simulate: background consolidation captures old reference
|
||||
old_ref = session
|
||||
|
||||
# AutoCompact runs first and truncates to 8
|
||||
await consolidator.compact_idle_session(
|
||||
"cli:test",
|
||||
runtime=runtime,
|
||||
max_suffix=8,
|
||||
)
|
||||
|
||||
# Background consolidation runs with stale reference —
|
||||
# should detect the session was replaced and not undo the compact.
|
||||
await consolidator.maybe_consolidate_by_tokens(
|
||||
old_ref,
|
||||
runtime=runtime,
|
||||
)
|
||||
|
||||
session_after = sessions.get_or_create("cli:test")
|
||||
# Messages should still be truncated (not restored to 40)
|
||||
assert len(session_after.messages) <= 8
|
||||
assert len(session_after.messages) == 40
|
||||
assert session_after.last_consolidated == 32
|
||||
assert len(session_after.get_history(max_messages=40)) == 8
|
||||
|
||||
|
||||
class TestRawArchiveTruncation:
|
||||
|
||||
Reference in New Issue
Block a user