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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 126 additions and 110 deletions

View File

@ -807,7 +807,7 @@ _HISTORY_ENTRY_HARD_CAP = 64_000 # emergency cap in append_history
class Consolidator: class Consolidator:
"""Lightweight consolidation: summarizes evicted messages into history.jsonl.""" """Summarize compacted messages into history.jsonl."""
_MAX_CONSOLIDATION_ROUNDS = 5 _MAX_CONSOLIDATION_ROUNDS = 5
@ -998,14 +998,9 @@ class Consolidator:
session_key: str | None = None, session_key: str | None = None,
summary_messages: list[dict[str, Any]] | None = None, summary_messages: list[dict[str, Any]] | None = None,
) -> str | None: ) -> str | None:
"""Summarize messages via LLM and append to history.jsonl. """Summarize messages and append the result to history.jsonl.
``messages`` are the messages being archived (removed from the live ``summary_messages`` adds context but is excluded from raw fallback.
session); they are what gets raw-dumped if the LLM call fails.
``summary_messages``, when given, lets callers include retained
messages in the summary without archiving them.
Returns the summary text on success, None if nothing to archive.
""" """
if not messages: if not messages:
return None return None
@ -1166,13 +1161,7 @@ class Consolidator:
runtime: LLMRuntime, runtime: LLMRuntime,
max_suffix: int = 8, max_suffix: int = 8,
) -> str | None: ) -> str | None:
"""Hard-truncate an idle session under the consolidation lock. """Archive an idle prefix and hide it from replay without deleting it."""
Used by AutoCompact so all session mutation goes through a single
lock-protected path. Returns the summary text on success, ``None``
if the LLM failed (raw_archive fallback), or ``""`` if there was
nothing to archive.
"""
lock = self.get_lock(session_key) lock = self.get_lock(session_key)
async with lock: async with lock:
self.sessions.invalidate(session_key) self.sessions.invalidate(session_key)
@ -1192,24 +1181,21 @@ class Consolidator:
last_consolidated=0, last_consolidated=0,
) )
result = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True) result = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True)
messages_to_keep = probe.messages visible_suffix = probe.messages
messages_to_remove = result.dropped[result.already_consolidated_count:] messages_to_remove = result.dropped
if not messages_to_remove and not messages_to_keep: if not messages_to_remove:
self.sessions.save(session) self.sessions.save(session)
return "" return ""
last_active = session.updated_at last_active = session.updated_at
summary: str | None = "" # The visible suffix informs the summary but stays out of raw fallback.
if messages_to_remove: summary = await self.archive(
# Summarize the retained suffix too, but only remove/raw-dump messages_to_remove,
# the messages that are no longer kept in the live session. runtime=runtime,
summary = await self.archive( session_key=session_key,
messages_to_remove, summary_messages=messages_to_summarize,
runtime=runtime, )
session_key=session_key,
summary_messages=messages_to_summarize,
)
if summary and summary != "(nothing)": if summary and summary != "(nothing)":
session.metadata["_last_summary"] = { session.metadata["_last_summary"] = {
@ -1217,17 +1203,17 @@ class Consolidator:
"last_active": last_active.isoformat(), "last_active": last_active.isoformat(),
} }
session.messages = messages_to_keep # Preserve history and advance only the replay boundary.
session.last_consolidated = 0 session.last_consolidated = len(session.messages) - len(visible_suffix)
self.sessions.save(session) self.sessions.save(session)
if messages_to_remove: logger.info(
logger.info( "Idle-session compact for {}: archived={}, visible={}, retained={}, summary={}",
"Idle-session compact for {}: archived={}, kept={}, summary={}", session_key,
session_key, len(messages_to_remove),
len(messages_to_remove), len(visible_suffix),
len(messages_to_keep), len(session.messages),
bool(summary), bool(summary),
) )
return summary return summary

View File

@ -80,7 +80,6 @@ def _make_fake_compact(
track_archived: list | None = None, track_archived: list | None = None,
track_count: bool = False, track_count: bool = False,
): ):
"""Return a fake compact_idle_session that mirrors the real method's session mutation."""
from nanobot.session.manager import Session as _Session from nanobot.session.manager import Session as _Session
state = {"count": 0} state = {"count": 0}
@ -106,21 +105,20 @@ def _make_fake_compact(
max_suffix, max_suffix,
extend_to_user=True, extend_to_user=True,
) )
kept = probe.messages visible_suffix = probe.messages
archive_msgs = result.dropped[result.already_consolidated_count:] archive_msgs = result.dropped
if not archive_msgs and not kept: if not archive_msgs:
loop.sessions.save(session) loop.sessions.save(session)
return "" return ""
last_active = session.updated_at last_active = session.updated_at
s = summary s = summary
if archive_msgs: if on_archive:
if on_archive: result = on_archive(archive_msgs)
result = on_archive(archive_msgs) s = result if isinstance(result, str) else summary
s = result if isinstance(result, str) else summary if track_archived is not None:
if track_archived is not None: track_archived.extend(archive_msgs)
track_archived.extend(archive_msgs)
if s and s != "(nothing)": if s and s != "(nothing)":
session.metadata["_last_summary"] = { session.metadata["_last_summary"] = {
@ -128,8 +126,7 @@ def _make_fake_compact(
"last_active": last_active.isoformat(), "last_active": last_active.isoformat(),
} }
session.messages = kept session.last_consolidated = len(session.messages) - len(visible_suffix)
session.last_consolidated = 0
loop.sessions.save(session) loop.sessions.save(session)
return s return s
@ -368,8 +365,7 @@ class TestAutoCompact:
await loop.close_mcp() await loop.close_mcp()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_archives_prefix_and_keeps_recent_suffix(self, tmp_path): async def test_auto_compact_archives_prefix_without_deleting_history(self, tmp_path):
"""_archive should summarize the old prefix and keep a recent legal suffix."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6) _add_turns(session, 6)
@ -384,9 +380,12 @@ class TestAutoCompact:
assert len(archived_messages) == 4 assert len(archived_messages) == 4
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES assert len(session_after.messages) == 12
assert session_after.messages[0]["content"] == "msg user 2" assert session_after.messages[0]["content"] == "msg user 0"
assert session_after.messages[-1]["content"] == "msg assistant 5" visible = session_after.get_history(max_messages=12)
assert len(visible) == loop.auto_compact._RECENT_SUFFIX_MESSAGES
assert visible[0]["content"] == "msg user 2"
assert visible[-1]["content"] == "msg assistant 5"
await loop.close_mcp() await loop.close_mcp()
@pytest.mark.asyncio @pytest.mark.asyncio
@ -403,17 +402,19 @@ class TestAutoCompact:
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime()) await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) > loop.auto_compact._RECENT_SUFFIX_MESSAGES assert session_after.messages[0]["content"] == "old user 0"
assert session_after.messages[0]["content"] == "record this" visible = session_after.get_history(max_messages=len(session_after.messages))
assert session_after.messages[-1]["content"] == "done" assert len(visible) > loop.auto_compact._RECENT_SUFFIX_MESSAGES
assert visible[0]["content"] == "record this"
assert visible[-1]["content"] == "done"
tool_results = { tool_results = {
m.get("tool_call_id") m.get("tool_call_id")
for m in session_after.messages for m in visible
if m.get("role") == "tool" if m.get("role") == "tool"
} }
assert all( assert all(
tc["id"] in tool_results tc["id"] in tool_results
for m in session_after.messages for m in visible
for tc in (m.get("tool_calls") or []) for tc in (m.get("tool_calls") or [])
) )
await loop.close_mcp() await loop.close_mcp()
@ -436,7 +437,10 @@ class TestAutoCompact:
assert entry is not None assert entry is not None
assert entry[0] == "User said hello." assert entry[0] == "User said hello."
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES assert len(session_after.messages) == 12
assert len(session_after.get_history(max_messages=12)) == (
loop.auto_compact._RECENT_SUFFIX_MESSAGES
)
await loop.close_mcp() await loop.close_mcp()
@pytest.mark.asyncio @pytest.mark.asyncio
@ -474,11 +478,10 @@ class TestAutoCompact:
class TestAutoCompactIdleDetection: class TestAutoCompactIdleDetection:
"""Test idle detection triggers auto-new in _process_message.""" """Idle detection tests."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_auto_compact_when_ttl_disabled(self, tmp_path): async def test_no_auto_compact_when_ttl_disabled(self, tmp_path):
"""No auto-new should happen when TTL is 0 (disabled)."""
loop = _make_loop(tmp_path, session_ttl_minutes=0) loop = _make_loop(tmp_path, session_ttl_minutes=0)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
session.add_message("user", "old message") session.add_message("user", "old message")
@ -494,7 +497,6 @@ class TestAutoCompactIdleDetection:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_triggers_on_idle(self, tmp_path): async def test_auto_compact_triggers_on_idle(self, tmp_path):
"""Proactive auto-new archives expired session; _process_message reloads it."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6, prefix="old") _add_turns(session, 6, prefix="old")
@ -514,13 +516,16 @@ class TestAutoCompactIdleDetection:
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert len(archived_messages) == 4 assert len(archived_messages) == 4
assert not any(m["content"] == "old user 0" for m in session_after.messages) assert any(m["content"] == "old user 0" for m in session_after.messages)
assert not any(
m["content"] == "old user 0"
for m in session_after.get_history(max_messages=len(session_after.messages))
)
assert any(m["content"] == "new msg" for m in session_after.messages) assert any(m["content"] == "new msg" for m in session_after.messages)
await loop.close_mcp() await loop.close_mcp()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_auto_compact_when_active(self, tmp_path): async def test_no_auto_compact_when_active(self, tmp_path):
"""No auto-new should happen when session is recently active."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
session.add_message("user", "recent message") session.add_message("user", "recent message")
@ -558,7 +563,6 @@ class TestAutoCompactIdleDetection:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_with_slash_new(self, tmp_path): async def test_auto_compact_with_slash_new(self, tmp_path):
"""Auto-new fires before /new dispatches; session is cleared twice but idempotent."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
for i in range(4): for i in range(4):
@ -576,7 +580,6 @@ class TestAutoCompactIdleDetection:
assert "new session started" in response.content.lower() assert "new session started" in response.content.lower()
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
# Session is empty (auto-new archived and cleared, /new cleared again)
assert len(session_after.messages) == 0 assert len(session_after.messages) == 0
await loop.close_mcp() await loop.close_mcp()
@ -617,11 +620,10 @@ class TestAutoCompactIdleDetection:
class TestAutoCompactSystemMessages: class TestAutoCompactSystemMessages:
"""Test that auto-new also works for system messages.""" """System-message idle compaction tests."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_triggers_for_system_messages(self, tmp_path): async def test_auto_compact_triggers_for_system_messages(self, tmp_path):
"""Proactive auto-new archives expired session; system messages reload it."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6, prefix="old") _add_turns(session, 6, prefix="old")
@ -640,9 +642,10 @@ class TestAutoCompactSystemMessages:
await loop._process_message(msg) await loop._process_message(msg)
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert any(m["content"] == "old user 0" for m in session_after.messages)
assert not any( assert not any(
m["content"] == "old user 0" m["content"] == "old user 0"
for m in session_after.messages for m in session_after.get_history(max_messages=len(session_after.messages))
) )
await loop.close_mcp() await loop.close_mcp()
@ -652,7 +655,6 @@ class TestAutoCompactEdgeCases:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_with_nothing_summary(self, tmp_path): async def test_auto_compact_with_nothing_summary(self, tmp_path):
"""Auto-new should not inject when archive produces '(nothing)'."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6, prefix="thanks") _add_turns(session, 6, prefix="thanks")
@ -666,15 +668,17 @@ class TestAutoCompactEdgeCases:
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime()) await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES assert len(session_after.messages) == 12
assert len(session_after.get_history(max_messages=12)) == (
loop.auto_compact._RECENT_SUFFIX_MESSAGES
)
# "(nothing)" summary should not be stored # "(nothing)" summary should not be stored
assert "cli:test" not in loop.auto_compact._summaries assert "cli:test" not in loop.auto_compact._summaries
await loop.close_mcp() await loop.close_mcp()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_compact_archive_failure_still_keeps_recent_suffix(self, tmp_path): async def test_auto_compact_archive_failure_preserves_raw_history(self, tmp_path):
"""Auto-new should keep the recent suffix even if LLM archive falls back to raw dump."""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6, prefix="important") _add_turns(session, 6, prefix="important")
@ -687,7 +691,10 @@ class TestAutoCompactEdgeCases:
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime()) await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES assert len(session_after.messages) == 12
assert len(session_after.get_history(max_messages=12)) == (
loop.auto_compact._RECENT_SUFFIX_MESSAGES
)
await loop.close_mcp() await loop.close_mcp()
@ -725,13 +732,10 @@ class TestAutoCompactEdgeCases:
class TestAutoCompactIntegration: class TestAutoCompactIntegration:
"""End-to-end test of auto session new feature.""" """Idle compaction integration tests."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_full_lifecycle(self, tmp_path): async def test_full_lifecycle(self, tmp_path):
"""
Full lifecycle: messages -> idle -> auto-new -> archive -> clear -> summary injected as runtime context.
"""
loop = _make_loop(tmp_path, session_ttl_minutes=15) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
@ -759,6 +763,7 @@ class TestAutoCompactIntegration:
tool_calls=[], tool_calls=[],
) )
) )
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
msg = InboundMessage( msg = InboundMessage(
channel="cli", sender_id="user", chat_id="test", channel="cli", sender_id="user", chat_id="test",
@ -769,9 +774,13 @@ class TestAutoCompactIntegration:
# Phase 4: Verify # Phase 4: Verify
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
# The oldest messages should be trimmed from live session history assert any(
"past tense is used" in str(m.get("content", "")).lower()
for m in session_after.messages
)
assert not any( assert not any(
"past tense is used" in str(m.get("content", "")) for m in session_after.messages "past tense is used" in str(m.get("content", "")).lower()
for m in session_after.get_history(max_messages=len(session_after.messages))
) )
# Summary should NOT be persisted in session (ephemeral, one-shot) # Summary should NOT be persisted in session (ephemeral, one-shot)
@ -821,7 +830,7 @@ class TestAutoCompactIntegration:
class TestProactiveAutoCompact: class TestProactiveAutoCompact:
"""Test proactive auto-new on idle ticks (TimeoutError path in run loop).""" """Proactive idle compaction tests."""
@staticmethod @staticmethod
async def _run_check_expired(loop, active_session_keys=()): async def _run_check_expired(loop, active_session_keys=()):
@ -899,7 +908,10 @@ class TestProactiveAutoCompact:
await self._run_check_expired(loop) await self._run_check_expired(loop)
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES assert len(session_after.messages) == 10
assert len(session_after.get_history(max_messages=10)) == (
loop.auto_compact._RECENT_SUFFIX_MESSAGES
)
assert len(archived_messages) == 2 assert len(archived_messages) == 2
entry = loop.auto_compact._summaries.get("cli:test") entry = loop.auto_compact._summaries.get("cli:test")
assert entry is not None assert entry is not None
@ -1082,7 +1094,10 @@ class TestProactiveAutoCompact:
assert _fake_compact.state["count"] == 1 assert _fake_compact.state["count"] == 1
s1_after = loop.sessions.get_or_create("cli:expired_idle") s1_after = loop.sessions.get_or_create("cli:expired_idle")
assert len(s1_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES assert len(s1_after.messages) == 12
assert len(s1_after.get_history(max_messages=12)) == (
loop.auto_compact._RECENT_SUFFIX_MESSAGES
)
s2_after = loop.sessions.get_or_create("cli:expired_active") s2_after = loop.sessions.get_or_create("cli:expired_active")
assert len(s2_after.messages) == 12 # Preserved assert len(s2_after.messages) == 12 # Preserved
s3_after = loop.sessions.get_or_create("cli:recent") s3_after = loop.sessions.get_or_create("cli:recent")
@ -1211,7 +1226,10 @@ class TestSummaryPersistence:
# prepare_session should recover summary from metadata # prepare_session should recover summary from metadata
reloaded = loop.sessions.get_or_create("cli:test") reloaded = loop.sessions.get_or_create("cli:test")
assert len(reloaded.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES assert len(reloaded.messages) == 12
assert len(reloaded.get_history(max_messages=12)) == (
loop.auto_compact._RECENT_SUFFIX_MESSAGES
)
_, summary = loop.auto_compact.prepare_session(reloaded, "cli:test") _, summary = loop.auto_compact.prepare_session(reloaded, "cli:test")
assert summary is not None assert summary is not None

View File

@ -586,7 +586,7 @@ class TestConsolidatorTokenBudget:
class TestCompactIdleSession: class TestCompactIdleSession:
"""Tests for Consolidator.compact_idle_session — lock-protected idle truncation.""" """Idle compaction tests."""
@pytest.fixture @pytest.fixture
def real_consolidator(self, store, mock_provider): def real_consolidator(self, store, mock_provider):
@ -602,11 +602,9 @@ class TestCompactIdleSession:
) )
@pytest.mark.asyncio @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 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( mock_provider.chat_with_retry.return_value = MagicMock(
content="Summary of old conversation.", finish_reason="stop" content="Summary of old conversation.", finish_reason="stop"
) )
@ -624,9 +622,15 @@ class TestCompactIdleSession:
) )
assert result == "Summary of old conversation." assert result == "Summary of old conversation."
sessions.invalidate("cli:test")
reloaded = sessions.get_or_create("cli:test") reloaded = sessions.get_or_create("cli:test")
assert len(reloaded.messages) <= 8 assert len(reloaded.messages) == 40
assert reloaded.last_consolidated == 0 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") meta = reloaded.metadata.get("_last_summary")
assert meta is not None assert meta is not None
assert meta["text"] == "Summary of old conversation." assert meta["text"] == "Summary of old conversation."
@ -665,9 +669,7 @@ class TestCompactIdleSession:
async def test_raw_dumps_only_dropped_messages_on_llm_failure( async def test_raw_dumps_only_dropped_messages_on_llm_failure(
self, real_consolidator, mock_provider, store, runtime self, real_consolidator, mock_provider, store, runtime
): ):
"""Summarizing over the full tail must not widen what gets raw-dumped on """Extra summary context must not enter raw fallback. Regression for #4264."""
LLM failure: the breadcrumb should contain only the removed prefix, not
the retained suffix that stays live in the session. Regression for #4264."""
mock_provider.chat_with_retry.side_effect = RuntimeError("LLM unavailable") mock_provider.chat_with_retry.side_effect = RuntimeError("LLM unavailable")
sessions = real_consolidator.sessions sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:rawdrop") 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)) raw = "\n".join(e["content"] for e in store.read_unprocessed_history(since_cursor=0))
assert "[RAW]" in raw assert "[RAW]" in raw
assert "user msg 0" in raw # removed prefix is the breadcrumb assert "user msg 0" in raw
assert "RETAINED_SUFFIX_marker" not in raw # retained suffix not dumped 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 @pytest.mark.asyncio
async def test_idle_compact_writes_session_key_to_history( async def test_idle_compact_writes_session_key_to_history(
@ -757,10 +762,9 @@ class TestCompactIdleSession:
assert "_last_summary" not in reloaded.metadata assert "_last_summary" not in reloaded.metadata
@pytest.mark.asyncio @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 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") mock_provider.chat_with_retry.side_effect = RuntimeError("LLM unavailable")
sessions = real_consolidator.sessions sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:fail") session = sessions.get_or_create("cli:fail")
@ -778,9 +782,16 @@ class TestCompactIdleSession:
entries = store.read_unprocessed_history(since_cursor=0) entries = store.read_unprocessed_history(since_cursor=0)
assert any("[RAW]" in e["content"] for e in entries) assert any("[RAW]" in e["content"] for e in entries)
# Session should still be truncated
reloaded = sessions.get_or_create("cli:fail") 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 @pytest.mark.asyncio
async def test_respects_last_consolidated( async def test_respects_last_consolidated(
@ -802,6 +813,9 @@ class TestCompactIdleSession:
"cli:offset", runtime=runtime, max_suffix=4 "cli:offset", runtime=runtime, max_suffix=4
) )
assert result == "Tail summary." 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: # Verify only the unconsolidated tail was processed:
# 10 unconsolidated messages (50-59), keep suffix of 4 → archive 6 # 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 assert "u25" in user_content or "a25" in user_content
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_non_contiguous_suffix_archives_actual_dropped_messages( async def test_extended_suffix_archives_only_hidden_prefix(
self, self,
real_consolidator, real_consolidator,
mock_provider, mock_provider,
runtime, 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( mock_provider.chat_with_retry.return_value = MagicMock(
content="Tail summary.", finish_reason="stop" content="Tail summary.", finish_reason="stop"
) )
@ -837,7 +849,9 @@ class TestCompactIdleSession:
assert result == "Tail summary." assert result == "Tail summary."
reloaded = sessions.get_or_create("cli:noncontiguous") 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", "user-14",
"assistant-00", "assistant-00",
"assistant-01", "assistant-01",
@ -987,23 +1001,21 @@ class TestConsolidatorSessionRefresh:
# Simulate: background consolidation captures old reference # Simulate: background consolidation captures old reference
old_ref = session old_ref = session
# AutoCompact runs first and truncates to 8
await consolidator.compact_idle_session( await consolidator.compact_idle_session(
"cli:test", "cli:test",
runtime=runtime, runtime=runtime,
max_suffix=8, 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( await consolidator.maybe_consolidate_by_tokens(
old_ref, old_ref,
runtime=runtime, runtime=runtime,
) )
session_after = sessions.get_or_create("cli:test") session_after = sessions.get_or_create("cli:test")
# Messages should still be truncated (not restored to 40) assert len(session_after.messages) == 40
assert len(session_after.messages) <= 8 assert session_after.last_consolidated == 32
assert len(session_after.get_history(max_messages=40)) == 8
class TestRawArchiveTruncation: class TestRawArchiveTruncation: