diff --git a/nanobot/agent/autocompact.py b/nanobot/agent/autocompact.py index 11e531039..4ad241170 100644 --- a/nanobot/agent/autocompact.py +++ b/nanobot/agent/autocompact.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Collection from datetime import datetime -from typing import TYPE_CHECKING, Any, Callable, Coroutine +from typing import TYPE_CHECKING, Callable, Coroutine from loguru import logger @@ -37,27 +37,6 @@ class AutoCompact: def _format_summary(text: str, last_active: datetime) -> str: return f"Previous conversation summary (last active {last_active.isoformat()}):\n{text}" - def _split_unconsolidated( - self, session: Session, - ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - """Split live session tail into archiveable prefix and retained recent suffix.""" - tail = list(session.messages[session.last_consolidated:]) - if not tail: - return [], [] - - probe = Session( - key=session.key, - messages=tail.copy(), - created_at=session.created_at, - updated_at=session.updated_at, - metadata={}, - last_consolidated=0, - ) - probe.retain_recent_legal_suffix(self._RECENT_SUFFIX_MESSAGES) - kept = probe.messages - cut = len(tail) - len(kept) - return tail[:cut], kept - def check_expired(self, schedule_background: Callable[[Coroutine], None], active_session_keys: Collection[str] = ()) -> None: """Schedule archival for idle sessions, skipping those with in-flight agent tasks.""" @@ -74,33 +53,17 @@ class AutoCompact: async def _archive(self, key: str) -> None: try: - self.sessions.invalidate(key) - session = self.sessions.get_or_create(key) - archive_msgs, kept_msgs = self._split_unconsolidated(session) - if not archive_msgs and not kept_msgs: - session.updated_at = datetime.now() - self.sessions.save(session) - return - - last_active = session.updated_at - summary = "" - if archive_msgs: - summary = await self.consolidator.archive(archive_msgs) or "" + summary = await self.consolidator.compact_idle_session( + key, self._RECENT_SUFFIX_MESSAGES, + ) if summary and summary != "(nothing)": - self._summaries[key] = (summary, last_active) - session.metadata["_last_summary"] = {"text": summary, "last_active": last_active.isoformat()} - session.messages = kept_msgs - session.last_consolidated = 0 - session.updated_at = datetime.now() - self.sessions.save(session) - if archive_msgs: - logger.info( - "Auto-compact: archived {} (archived={}, kept={}, summary={})", - key, - len(archive_msgs), - len(kept_msgs), - bool(summary), - ) + session = self.sessions.get_or_create(key) + meta = session.metadata.get("_last_summary") + if isinstance(meta, dict): + self._summaries[key] = ( + meta["text"], + datetime.fromisoformat(meta["last_active"]), + ) except Exception: logger.exception("Auto-compact: failed for {}", key) finally: diff --git a/tests/agent/test_auto_compact.py b/tests/agent/test_auto_compact.py index 0bc02a694..37fcbfdae 100644 --- a/tests/agent/test_auto_compact.py +++ b/tests/agent/test_auto_compact.py @@ -45,6 +45,73 @@ def _add_turns(session, turns: int, *, prefix: str = "msg") -> None: session.add_message("assistant", f"{prefix} assistant {i}") +def _make_fake_compact( + loop: AgentLoop, + *, + summary: str = "Summary.", + on_archive=None, + track_archived: list | None = None, + 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 + + state = {"count": 0} + + async def _fake_compact(key: str, max_suffix: int = 8) -> str: + state["count"] += 1 + session = loop.sessions.get_or_create(key) + + tail = list(session.messages[session.last_consolidated:]) + if not tail: + session.updated_at = datetime.now() + loop.sessions.save(session) + return "" + + probe = _Session( + key=session.key, + messages=tail.copy(), + created_at=session.created_at, + updated_at=session.updated_at, + metadata={}, + last_consolidated=0, + ) + probe.retain_recent_legal_suffix(max_suffix) + kept = probe.messages + cut = len(tail) - len(kept) + archive_msgs = tail[:cut] + + if not archive_msgs and not kept: + session.updated_at = datetime.now() + loop.sessions.save(session) + return "" + + last_active = session.updated_at + s = summary + if archive_msgs: + if on_archive: + result = on_archive(archive_msgs) + s = result if isinstance(result, str) else summary + if track_archived is not None: + track_archived.extend(archive_msgs) + + if s and s != "(nothing)": + session.metadata["_last_summary"] = { + "text": s, + "last_active": last_active.isoformat(), + } + + session.messages = kept + session.last_consolidated = 0 + session.updated_at = datetime.now() + loop.sessions.save(session) + return s + + # Attach state for count access + _fake_compact.state = state # type: ignore[attr-defined] + return _fake_compact + + class TestSessionTTLConfig: """Test session TTL configuration.""" @@ -201,10 +268,7 @@ class TestAutoCompact: s2.add_message("user", "recent") loop.sessions.save(s2) - async def _fake_archive(messages): - return "Summary." - - loop.consolidator.archive = _fake_archive + loop.consolidator.compact_idle_session = _make_fake_compact(loop) loop.auto_compact.check_expired(loop._schedule_background) await asyncio.sleep(0.1) @@ -222,12 +286,9 @@ class TestAutoCompact: loop.sessions.save(session) archived_messages = [] - - async def _fake_archive(messages): - archived_messages.extend(messages) - return "Summary." - - loop.consolidator.archive = _fake_archive + loop.consolidator.compact_idle_session = _make_fake_compact( + loop, track_archived=archived_messages, + ) await loop.auto_compact._archive("cli:test") @@ -246,10 +307,9 @@ class TestAutoCompact: _add_turns(session, 6, prefix="hello") loop.sessions.save(session) - async def _fake_archive(messages): - return "User said hello." - - loop.consolidator.archive = _fake_archive + loop.consolidator.compact_idle_session = _make_fake_compact( + loop, summary="User said hello.", + ) await loop.auto_compact._archive("cli:test") @@ -262,23 +322,16 @@ class TestAutoCompact: @pytest.mark.asyncio async def test_auto_compact_empty_session(self, tmp_path): - """_archive on empty session should not archive.""" + """_archive on empty session should not store a summary.""" loop = _make_loop(tmp_path, session_ttl_minutes=15) - archive_called = False - - async def _fake_archive(messages): - nonlocal archive_called - archive_called = True - return "Summary." - - loop.consolidator.archive = _fake_archive + loop.consolidator.compact_idle_session = _make_fake_compact(loop) await loop.auto_compact._archive("cli:test") - assert not archive_called session_after = loop.sessions.get_or_create("cli:test") assert len(session_after.messages) == 0 + assert "cli:test" not in loop.auto_compact._summaries await loop.close_mcp() @pytest.mark.asyncio @@ -290,18 +343,14 @@ class TestAutoCompact: session.last_consolidated = 18 loop.sessions.save(session) - archived_count = 0 - - async def _fake_archive(messages): - nonlocal archived_count - archived_count = len(messages) - return "Summary." - - loop.consolidator.archive = _fake_archive + archived_messages = [] + loop.consolidator.compact_idle_session = _make_fake_compact( + loop, track_archived=archived_messages, + ) await loop.auto_compact._archive("cli:test") - assert archived_count == 2 + assert len(archived_messages) == 2 await loop.close_mcp() @@ -334,12 +383,9 @@ class TestAutoCompactIdleDetection: loop.sessions.save(session) archived_messages = [] - - async def _fake_archive(messages): - archived_messages.extend(messages) - return "Summary." - - loop.consolidator.archive = _fake_archive + loop.consolidator.compact_idle_session = _make_fake_compact( + loop, track_archived=archived_messages, + ) # Simulate proactive archive completing before message arrives await loop.auto_compact._archive("cli:test") @@ -402,10 +448,7 @@ class TestAutoCompactIdleDetection: session.updated_at = datetime.now() - timedelta(minutes=20) loop.sessions.save(session) - async def _fake_archive(messages): - return "Summary." - - loop.consolidator.archive = _fake_archive + loop.consolidator.compact_idle_session = _make_fake_compact(loop) msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new") response = await loop._process_message(msg) @@ -466,10 +509,7 @@ class TestAutoCompactSystemMessages: session.updated_at = datetime.now() - timedelta(minutes=20) loop.sessions.save(session) - async def _fake_archive(messages): - return "Summary." - - loop.consolidator.archive = _fake_archive + loop.consolidator.compact_idle_session = _make_fake_compact(loop) # Simulate proactive archive completing before system message arrives await loop.auto_compact._archive("cli:test") @@ -547,12 +587,9 @@ class TestAutoCompactEdgeCases: loop.sessions.save(session) archived_messages = [] - - async def _fake_archive(messages): - archived_messages.extend(messages) - return "Summary." - - loop.consolidator.archive = _fake_archive + loop.consolidator.compact_idle_session = _make_fake_compact( + loop, track_archived=archived_messages, + ) # Simulate proactive archive completing before message arrives await loop.auto_compact._archive("cli:test") @@ -644,10 +681,7 @@ class TestAutoCompactIntegration: session.updated_at = datetime.now() - timedelta(minutes=20) loop.sessions.save(session) - async def _fake_archive(messages): - return "Summary." - - loop.consolidator.archive = _fake_archive + loop.consolidator.compact_idle_session = _make_fake_compact(loop) # Simulate proactive archive completing before message arrives await loop.auto_compact._archive("cli:test") @@ -704,12 +738,9 @@ class TestProactiveAutoCompact: loop.sessions.save(session) archived_messages = [] - - async def _fake_archive(messages): - archived_messages.extend(messages) - return "User chatted about old things." - - loop.consolidator.archive = _fake_archive + loop.consolidator.compact_idle_session = _make_fake_compact( + loop, summary="User chatted about old things.", track_archived=archived_messages, + ) await self._run_check_expired(loop) @@ -748,14 +779,14 @@ class TestProactiveAutoCompact: started = asyncio.Event() block_forever = asyncio.Event() - async def _slow_archive(messages): + async def _slow_compact(key, max_suffix=8): nonlocal archive_count archive_count += 1 started.set() await block_forever.wait() return "Summary." - loop.consolidator.archive = _slow_archive + loop.consolidator.compact_idle_session = _slow_compact # First call starts archiving via callback loop.auto_compact.check_expired(loop._schedule_background) @@ -781,10 +812,10 @@ class TestProactiveAutoCompact: session.updated_at = datetime.now() - timedelta(minutes=20) loop.sessions.save(session) - async def _failing_archive(messages): + async def _failing_compact(key, max_suffix=8): raise RuntimeError("LLM down") - loop.consolidator.archive = _failing_archive + loop.consolidator.compact_idle_session = _failing_compact # Should not raise await self._run_check_expired(loop) @@ -795,24 +826,18 @@ class TestProactiveAutoCompact: @pytest.mark.asyncio async def test_proactive_archive_skips_empty_sessions(self, tmp_path): - """Proactive archive should not call LLM for sessions with no un-consolidated messages.""" + """Proactive archive should not produce a summary for sessions with no messages.""" loop = _make_loop(tmp_path, session_ttl_minutes=15) session = loop.sessions.get_or_create("cli:test") session.updated_at = datetime.now() - timedelta(minutes=20) loop.sessions.save(session) - archive_called = False - - async def _fake_archive(messages): - nonlocal archive_called - archive_called = True - return "Summary." - - loop.consolidator.archive = _fake_archive + loop.consolidator.compact_idle_session = _make_fake_compact(loop) await self._run_check_expired(loop) - assert not archive_called + # Empty session should not produce a summary + assert "cli:test" not in loop.auto_compact._summaries await loop.close_mcp() @pytest.mark.asyncio @@ -824,18 +849,12 @@ class TestProactiveAutoCompact: session.updated_at = datetime.now() - timedelta(minutes=20) loop.sessions.save(session) - archive_count = 0 - - async def _fake_archive(messages): - nonlocal archive_count - archive_count += 1 - return "Summary." - - loop.consolidator.archive = _fake_archive + _fake_compact = _make_fake_compact(loop) + loop.consolidator.compact_idle_session = _fake_compact # Simulate an active agent task for this session await self._run_check_expired(loop, active_session_keys={"cli:test"}) - assert archive_count == 0 + assert _fake_compact.state["count"] == 0 session_after = loop.sessions.get_or_create("cli:test") assert len(session_after.messages) == 12 # All messages preserved @@ -851,22 +870,16 @@ class TestProactiveAutoCompact: session.updated_at = datetime.now() - timedelta(minutes=20) loop.sessions.save(session) - archive_count = 0 - - async def _fake_archive(messages): - nonlocal archive_count - archive_count += 1 - return "Summary." - - loop.consolidator.archive = _fake_archive + _fake_compact = _make_fake_compact(loop) + loop.consolidator.compact_idle_session = _fake_compact # First tick: active task, skip await self._run_check_expired(loop, active_session_keys={"cli:test"}) - assert archive_count == 0 + assert _fake_compact.state["count"] == 0 # Second tick: task completed, should archive await self._run_check_expired(loop) - assert archive_count == 1 + assert _fake_compact.state["count"] == 1 await loop.close_mcp() @pytest.mark.asyncio @@ -888,18 +901,12 @@ class TestProactiveAutoCompact: s3.add_message("user", "recent") loop.sessions.save(s3) - archive_count = 0 - - async def _fake_archive(messages): - nonlocal archive_count - archive_count += 1 - return "Summary." - - loop.consolidator.archive = _fake_archive + _fake_compact = _make_fake_compact(loop) + loop.consolidator.compact_idle_session = _fake_compact await self._run_check_expired(loop, active_session_keys={"cli:expired_active"}) - assert archive_count == 1 + assert _fake_compact.state["count"] == 1 s1_after = loop.sessions.get_or_create("cli:expired_idle") assert len(s1_after.messages) == loop.auto_compact._RECENT_SUFFIX_MESSAGES s2_after = loop.sessions.get_or_create("cli:expired_active") @@ -917,22 +924,16 @@ class TestProactiveAutoCompact: session.updated_at = datetime.now() - timedelta(minutes=20) loop.sessions.save(session) - archive_count = 0 - - async def _fake_archive(messages): - nonlocal archive_count - archive_count += 1 - return "Summary." - - loop.consolidator.archive = _fake_archive + _fake_compact = _make_fake_compact(loop) + loop.consolidator.compact_idle_session = _fake_compact # First tick: archives the session await self._run_check_expired(loop) - assert archive_count == 1 + assert _fake_compact.state["count"] == 1 # Second tick: should NOT re-schedule (updated_at is fresh after clear) await self._run_check_expired(loop) - assert archive_count == 1 # Still 1, not re-scheduled + assert _fake_compact.state["count"] == 1 # Still 1, not re-scheduled await loop.close_mcp() @pytest.mark.asyncio @@ -943,22 +944,15 @@ class TestProactiveAutoCompact: session.updated_at = datetime.now() - timedelta(minutes=20) loop.sessions.save(session) - archive_count = 0 - - async def _fake_archive(messages): - nonlocal archive_count - archive_count += 1 - return "Summary." - - loop.consolidator.archive = _fake_archive + loop.consolidator.compact_idle_session = _make_fake_compact(loop) # First tick: skips (no messages), refreshes updated_at await self._run_check_expired(loop) - assert archive_count == 0 + assert "cli:test" not in loop.auto_compact._summaries # Second tick: should NOT re-schedule because updated_at is fresh await self._run_check_expired(loop) - assert archive_count == 0 + assert "cli:test" not in loop.auto_compact._summaries await loop.close_mcp() @pytest.mark.asyncio @@ -970,18 +964,12 @@ class TestProactiveAutoCompact: session.updated_at = datetime.now() - timedelta(minutes=20) loop.sessions.save(session) - archive_count = 0 - - async def _fake_archive(messages): - nonlocal archive_count - archive_count += 1 - return "Summary." - - loop.consolidator.archive = _fake_archive + _fake_compact = _make_fake_compact(loop) + loop.consolidator.compact_idle_session = _fake_compact # First compact cycle await loop.auto_compact._archive("cli:test") - assert archive_count == 1 + assert _fake_compact.state["count"] == 1 # User returns, sends new messages msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="second topic") @@ -995,7 +983,7 @@ class TestProactiveAutoCompact: # Second compact cycle should succeed await loop.auto_compact._archive("cli:test") - assert archive_count == 2 + assert _fake_compact.state["count"] == 2 await loop.close_mcp() @@ -1011,10 +999,9 @@ class TestSummaryPersistence: session.updated_at = datetime.now() - timedelta(minutes=20) loop.sessions.save(session) - async def _fake_archive(messages): - return "User said hello." - - loop.consolidator.archive = _fake_archive + loop.consolidator.compact_idle_session = _make_fake_compact( + loop, summary="User said hello.", + ) await loop.auto_compact._archive("cli:test") @@ -1036,10 +1023,9 @@ class TestSummaryPersistence: session.updated_at = last_active loop.sessions.save(session) - async def _fake_archive(messages): - return "User said hello." - - loop.consolidator.archive = _fake_archive + loop.consolidator.compact_idle_session = _make_fake_compact( + loop, summary="User said hello.", + ) # Archive await loop.auto_compact._archive("cli:test") @@ -1069,10 +1055,7 @@ class TestSummaryPersistence: session.updated_at = datetime.now() - timedelta(minutes=20) loop.sessions.save(session) - async def _fake_archive(messages): - return "Summary." - - loop.consolidator.archive = _fake_archive + loop.consolidator.compact_idle_session = _make_fake_compact(loop) await loop.auto_compact._archive("cli:test") @@ -1100,10 +1083,7 @@ class TestSummaryPersistence: session.updated_at = datetime.now() - timedelta(minutes=20) loop.sessions.save(session) - async def _fake_archive(messages): - return "Summary." - - loop.consolidator.archive = _fake_archive + loop.consolidator.compact_idle_session = _make_fake_compact(loop) await loop.auto_compact._archive("cli:test") @@ -1129,10 +1109,9 @@ class TestSummaryPersistence: session.updated_at = datetime.now() - timedelta(minutes=20) loop.sessions.save(session) - async def _fake_archive(messages): - return "First summary." - - loop.consolidator.archive = _fake_archive + loop.consolidator.compact_idle_session = _make_fake_compact( + loop, summary="First summary.", + ) await loop.auto_compact._archive("cli:test") # Consume the first summary via hot path @@ -1148,10 +1127,9 @@ class TestSummaryPersistence: session.updated_at = datetime.now() - timedelta(minutes=20) loop.sessions.save(session) - async def _fake_archive2(messages): - return "Second summary." - - loop.consolidator.archive = _fake_archive2 + loop.consolidator.compact_idle_session = _make_fake_compact( + loop, summary="Second summary.", + ) await loop.auto_compact._archive("cli:test") # The second archive writes a new summary @@ -1173,10 +1151,9 @@ class TestSummaryPersistence: session.updated_at = datetime.now() - timedelta(minutes=20) loop.sessions.save(session) - async def _fake_archive(messages): - return "Old summary." - - loop.consolidator.archive = _fake_archive + loop.consolidator.compact_idle_session = _make_fake_compact( + loop, summary="Old summary.", + ) await loop.auto_compact._archive("cli:test") # Verify summary exists before /new diff --git a/tests/agent/test_autocompact_unit.py b/tests/agent/test_autocompact_unit.py index d501770dd..1d3277a01 100644 --- a/tests/agent/test_autocompact_unit.py +++ b/tests/agent/test_autocompact_unit.py @@ -38,7 +38,7 @@ def _make_autocompact( sessions = MagicMock(spec=SessionManager) if consolidator is None: consolidator = MagicMock() - consolidator.archive = AsyncMock(return_value="Summary.") + consolidator.compact_idle_session = AsyncMock(return_value="Summary.") return AutoCompact( sessions=sessions, consolidator=consolidator, @@ -178,62 +178,6 @@ class TestFormatSummary: assert result.startswith("Previous conversation summary (last active ") -# --------------------------------------------------------------------------- -# _split_unconsolidated -# --------------------------------------------------------------------------- - - -class TestSplitUnconsolidated: - """Test AutoCompact._split_unconsolidated splitting logic.""" - - def test_empty_session_returns_both_empty(self): - """Empty session should return ([], []).""" - ac = _make_autocompact() - session = _make_session(messages=[]) - archive, kept = ac._split_unconsolidated(session) - assert archive == [] - assert kept == [] - - def test_all_messages_archivable_when_more_than_suffix(self): - """Session with many messages should archive a prefix and keep suffix.""" - ac = _make_autocompact() - msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)] - session = _make_session(messages=msgs) - archive, kept = ac._split_unconsolidated(session) - assert len(archive) > 0 - assert len(kept) <= AutoCompact._RECENT_SUFFIX_MESSAGES - - def test_fewer_messages_than_suffix_returns_empty_archive(self): - """Session with fewer messages than suffix should have empty archive.""" - ac = _make_autocompact() - msgs = [{"role": "user", "content": f"u{i}"} for i in range(3)] - session = _make_session(messages=msgs) - archive, kept = ac._split_unconsolidated(session) - assert archive == [] - assert len(kept) == len(msgs) - - def test_respects_last_consolidated_offset(self): - """Only messages after last_consolidated should be considered.""" - ac = _make_autocompact() - msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)] - # First 10 are already consolidated - session = _make_session(messages=msgs, last_consolidated=10) - archive, kept = ac._split_unconsolidated(session) - # Only the tail of 10 messages is considered for splitting - assert all(m["content"] in [f"u{i}" for i in range(10, 20)] for m in kept) - assert all(m["content"] in [f"u{i}" for i in range(10, 20)] for m in archive) - - def test_retain_recent_legal_suffix_keeps_last_n(self): - """The kept suffix should be at most _RECENT_SUFFIX_MESSAGES long.""" - ac = _make_autocompact() - # 20 user messages = 20 messages total, all after last_consolidated=0 - msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)] - session = _make_session(messages=msgs) - archive, kept = ac._split_unconsolidated(session) - assert len(kept) <= AutoCompact._RECENT_SUFFIX_MESSAGES - assert len(archive) == len(msgs) - len(kept) - - # --------------------------------------------------------------------------- # check_expired # --------------------------------------------------------------------------- @@ -313,126 +257,71 @@ class TestCheckExpired: # --------------------------------------------------------------------------- -class TestArchive: - """Test AutoCompact._archive async method.""" +class TestArchiveDelegates: + """_archive should delegate all session mutation to Consolidator.""" @pytest.mark.asyncio - async def test_empty_session_updates_timestamp_no_archive_call(self): - """Empty session should refresh updated_at and not call consolidator.archive.""" + async def test_calls_compact_idle_session(self): ac = _make_autocompact() mock_sm = MagicMock(spec=SessionManager) - empty_session = _make_session(messages=[]) - mock_sm.get_or_create.return_value = empty_session ac.sessions = mock_sm - ac.consolidator.archive = AsyncMock(return_value="Summary.") + ac.consolidator.compact_idle_session = AsyncMock(return_value="Summary.") await ac._archive("cli:test") - ac.consolidator.archive.assert_not_called() - mock_sm.save.assert_called_once_with(empty_session) - # updated_at was refreshed - assert empty_session.updated_at > datetime.now() - timedelta(seconds=5) + ac.consolidator.compact_idle_session.assert_awaited_once_with( + "cli:test", ac._RECENT_SUFFIX_MESSAGES, + ) @pytest.mark.asyncio - async def test_archive_returns_empty_string_no_summary_stored(self): - """If archive returns empty string, no summary should be stored.""" + async def test_populates_summaries_from_metadata(self): ac = _make_autocompact() mock_sm = MagicMock(spec=SessionManager) - msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)] - session = _make_session(messages=msgs) + session = _make_session( + metadata={"_last_summary": {"text": "Hello.", "last_active": "2026-05-13T10:00:00"}} + ) mock_sm.get_or_create.return_value = session ac.sessions = mock_sm - ac.consolidator.archive = AsyncMock(return_value="") + ac.consolidator.compact_idle_session = AsyncMock(return_value="Hello.") await ac._archive("cli:test") - assert "cli:test" not in ac._summaries - - @pytest.mark.asyncio - async def test_archive_returns_nothing_no_summary_stored(self): - """If archive returns '(nothing)', no summary should be stored.""" - ac = _make_autocompact() - mock_sm = MagicMock(spec=SessionManager) - msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)] - session = _make_session(messages=msgs) - mock_sm.get_or_create.return_value = session - ac.sessions = mock_sm - ac.consolidator.archive = AsyncMock(return_value="(nothing)") - - await ac._archive("cli:test") - - assert "cli:test" not in ac._summaries - - @pytest.mark.asyncio - async def test_archive_exception_caught_key_removed_from_archiving(self): - """If archive raises, exception is caught and key removed from _archiving.""" - ac = _make_autocompact() - mock_sm = MagicMock(spec=SessionManager) - msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)] - session = _make_session(messages=msgs) - mock_sm.get_or_create.return_value = session - ac.sessions = mock_sm - ac.consolidator.archive = AsyncMock(side_effect=RuntimeError("LLM down")) - - # Should not raise - await ac._archive("cli:test") - - assert "cli:test" not in ac._archiving - - @pytest.mark.asyncio - async def test_successful_archive_stores_summary_in_summaries_and_metadata(self): - """Successful archive should store summary in _summaries dict and metadata.""" - ac = _make_autocompact() - mock_sm = MagicMock(spec=SessionManager) - msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)] - last_active = datetime(2026, 5, 13, 10, 0, 0) - session = _make_session(messages=msgs, updated_at=last_active) - mock_sm.get_or_create.return_value = session - ac.sessions = mock_sm - ac.consolidator.archive = AsyncMock(return_value="User discussed AI.") - - await ac._archive("cli:test") - - # _summaries entry = ac._summaries.get("cli:test") assert entry is not None - assert entry[0] == "User discussed AI." - assert entry[1] == last_active - # metadata - meta = session.metadata.get("_last_summary") - assert meta is not None - assert meta["text"] == "User discussed AI." - assert "last_active" in meta + assert entry[0] == "Hello." @pytest.mark.asyncio - async def test_finally_block_always_removes_from_archiving(self): - """Finally block should always remove key from _archiving, even on error.""" + async def test_no_summary_when_compact_returns_empty(self): ac = _make_autocompact() mock_sm = MagicMock(spec=SessionManager) - msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)] - session = _make_session(messages=msgs) - mock_sm.get_or_create.return_value = session ac.sessions = mock_sm - ac.consolidator.archive = AsyncMock(side_effect=RuntimeError("fail")) + ac.consolidator.compact_idle_session = AsyncMock(return_value="") - # Pre-add key to archiving to verify it gets removed - ac._archiving.add("cli:test") await ac._archive("cli:test") - assert "cli:test" not in ac._archiving + + assert "cli:test" not in ac._summaries @pytest.mark.asyncio - async def test_finally_removes_from_archiving_on_success(self): - """Finally block should remove key from _archiving on success too.""" + async def test_no_summary_when_compact_returns_nothing(self): ac = _make_autocompact() mock_sm = MagicMock(spec=SessionManager) - msgs = [{"role": "user", "content": f"u{i}"} for i in range(20)] - session = _make_session(messages=msgs) - mock_sm.get_or_create.return_value = session ac.sessions = mock_sm - ac.consolidator.archive = AsyncMock(return_value="Summary.") + ac.consolidator.compact_idle_session = AsyncMock(return_value="(nothing)") + + await ac._archive("cli:test") + + assert "cli:test" not in ac._summaries + + @pytest.mark.asyncio + async def test_exception_still_removes_from_archiving(self): + ac = _make_autocompact() + mock_sm = MagicMock(spec=SessionManager) + ac.sessions = mock_sm + ac.consolidator.compact_idle_session = AsyncMock(side_effect=RuntimeError("fail")) ac._archiving.add("cli:test") await ac._archive("cli:test") + assert "cli:test" not in ac._archiving