fix(session): preserve history during idle compaction (#5167)

This commit is contained in:
chengyongru
2026-07-30 10:45:45 +08:00
committed by GitHub
parent 11fcd9cc5f
commit c33c188afb
3 changed files with 126 additions and 110 deletions
+36 -24
View File
@@ -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: