fix(webui): keep idle compaction out of session recency

This commit is contained in:
chengyongru
2026-06-30 23:38:32 +08:00
committed by Xubin Ren
parent bfbae5a7b3
commit 3403b87641
7 changed files with 145 additions and 25 deletions
+10 -8
View File
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.memory import LAST_COMPACTED_AT_META
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.command import CommandContext
@@ -88,10 +89,11 @@ def _make_fake_compact(
async def _fake_compact(key: str, max_suffix: int = 8) -> str:
state["count"] += 1
session = loop.sessions.get_or_create(key)
compacted_at = datetime.now().isoformat()
tail = list(session.messages[session.last_consolidated:])
if not tail:
session.updated_at = datetime.now()
session.metadata[LAST_COMPACTED_AT_META] = compacted_at
loop.sessions.save(session)
return ""
@@ -111,7 +113,7 @@ def _make_fake_compact(
archive_msgs = result.dropped[result.already_consolidated_count:]
if not archive_msgs and not kept:
session.updated_at = datetime.now()
session.metadata[LAST_COMPACTED_AT_META] = compacted_at
loop.sessions.save(session)
return ""
@@ -132,7 +134,7 @@ def _make_fake_compact(
session.messages = kept
session.last_consolidated = 0
session.updated_at = datetime.now()
session.metadata[LAST_COMPACTED_AT_META] = compacted_at
loop.sessions.save(session)
return s
@@ -1021,14 +1023,14 @@ class TestProactiveAutoCompact:
await self._run_check_expired(loop)
assert _fake_compact.state["count"] == 1
# Second tick: should NOT re-schedule (updated_at is fresh after clear)
# Second tick: should NOT re-schedule (maintenance timestamp is fresh)
await self._run_check_expired(loop)
assert _fake_compact.state["count"] == 1 # Still 1, not re-scheduled
await loop.close_mcp()
@pytest.mark.asyncio
async def test_empty_skip_refreshes_updated_at_prevents_reschedule(self, tmp_path):
"""Empty session skip refreshes updated_at, preventing immediate re-scheduling."""
async def test_empty_skip_records_compaction_prevents_reschedule(self, tmp_path):
"""Empty session skip records maintenance, preventing immediate re-scheduling."""
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)
@@ -1036,11 +1038,11 @@ class TestProactiveAutoCompact:
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
# First tick: skips (no messages), refreshes updated_at
# First tick: skips (no messages), records compaction metadata
await self._run_check_expired(loop)
assert "cli:test" not in loop.auto_compact._summaries
# Second tick: should NOT re-schedule because updated_at is fresh
# Second tick: should NOT re-schedule because maintenance metadata is fresh
await self._run_check_expired(loop)
assert "cli:test" not in loop.auto_compact._summaries
await loop.close_mcp()
+19
View File
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.autocompact import AutoCompact
from nanobot.agent.memory import LAST_COMPACTED_AT_META
from nanobot.session.manager import Session, SessionManager
@@ -273,6 +274,24 @@ class TestCheckExpired:
scheduler.assert_not_called()
assert "dream:20260602-155256" not in ac._archiving
def test_already_compacted_session_skips(self):
"""Expired session already maintained after last activity should not be re-scheduled."""
ac = _make_autocompact(ttl=15)
mock_sm = MagicMock(spec=SessionManager)
last_active = datetime(2026, 1, 1, 10, 0, 0)
mock_sm.list_sessions.return_value = [
{"key": "cli:done", "updated_at": last_active.isoformat()},
]
mock_sm.read_session_metadata.return_value = {
"metadata": {LAST_COMPACTED_AT_META: datetime(2026, 1, 1, 10, 30, 0).isoformat()},
}
ac.sessions = mock_sm
scheduler = MagicMock()
ac.check_expired(scheduler)
scheduler.assert_not_called()
# ---------------------------------------------------------------------------
# _archive
+11 -3
View File
@@ -6,6 +6,7 @@ import pytest
from nanobot.agent.memory import (
_ARCHIVE_SUMMARY_MAX_CHARS,
LAST_COMPACTED_AT_META,
Consolidator,
MemoryStore,
)
@@ -430,9 +431,11 @@ class TestCompactIdleSession:
)
sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:test")
old_ts = session.updated_at
for i in range(20):
session.add_message("user", f"user msg {i}")
session.add_message("assistant", f"assistant msg {i}")
session.updated_at = old_ts
sessions.save(session)
result = await real_consolidator.compact_idle_session("cli:test", max_suffix=8)
@@ -445,6 +448,8 @@ class TestCompactIdleSession:
assert meta is not None
assert meta["text"] == "Summary of old conversation."
assert "last_active" in meta
assert LAST_COMPACTED_AT_META in reloaded.metadata
assert reloaded.updated_at == old_ts
@pytest.mark.asyncio
async def test_summarizes_retained_suffix_not_just_dropped_prefix(
@@ -518,8 +523,10 @@ class TestCompactIdleSession:
assert entries[0]["session_key"] == "cli:test"
@pytest.mark.asyncio
async def test_empty_session_refreshes_timestamp(self, real_consolidator):
"""Empty session with old updated_at → refreshed after call, returns ''."""
async def test_empty_session_records_compaction_without_refreshing_timestamp(
self, real_consolidator
):
"""Empty session with old updated_at records maintenance separately."""
from datetime import datetime, timedelta
sessions = real_consolidator.sessions
@@ -532,7 +539,8 @@ class TestCompactIdleSession:
assert result == ""
reloaded = sessions.get_or_create("cli:empty")
assert reloaded.updated_at > old_ts
assert reloaded.updated_at == old_ts
assert LAST_COMPACTED_AT_META in reloaded.metadata
@pytest.mark.asyncio
async def test_nothing_summary_not_stored(self, real_consolidator, mock_provider):
+27
View File
@@ -101,6 +101,7 @@ def test_webui_session_list_uses_webui_transcript_activity_for_sort(
old_session.created_at = datetime(2026, 6, 15, 10, 0, 0)
old_session.updated_at = datetime(2026, 6, 15, 10, 0, 0)
old_session.add_message("user", "old metadata")
old_session.messages[-1]["timestamp"] = "2026-06-15T10:00:00"
old_session.updated_at = datetime(2026, 6, 15, 10, 0, 0)
manager.save(old_session)
@@ -108,6 +109,7 @@ def test_webui_session_list_uses_webui_transcript_activity_for_sort(
newer_metadata.created_at = datetime(2026, 6, 15, 11, 0, 0)
newer_metadata.updated_at = datetime(2026, 6, 15, 11, 0, 0)
newer_metadata.add_message("user", "newer metadata")
newer_metadata.messages[-1]["timestamp"] = "2026-06-15T11:00:00"
newer_metadata.updated_at = datetime(2026, 6, 15, 11, 0, 0)
manager.save(newer_metadata)
@@ -141,6 +143,7 @@ def test_webui_session_list_rescans_when_transcript_changes(
session.created_at = datetime(2026, 6, 15, 10, 0, 0)
session.updated_at = datetime(2026, 6, 15, 10, 0, 0)
session.add_message("user", "preview")
session.messages[-1]["timestamp"] = "2026-06-15T10:00:00"
session.updated_at = datetime(2026, 6, 15, 10, 0, 0)
manager.save(session)
@@ -169,6 +172,30 @@ def test_webui_session_list_rescans_when_transcript_changes(
assert rows[0]["updated_at"].startswith("2026-06-15T12:30:00")
def test_webui_session_list_sorts_by_message_activity_not_maintenance_timestamp(
tmp_path: Path,
) -> None:
manager = SessionManager(tmp_path)
old = manager.get_or_create("websocket:old")
old.created_at = datetime(2026, 6, 5, 10, 0, 0)
old.add_message("user", "old real activity")
old.messages[-1]["timestamp"] = "2026-06-05T10:00:00"
old.updated_at = datetime(2026, 6, 30, 17, 40, 0)
manager.save(old)
newer = manager.get_or_create("websocket:newer")
newer.created_at = datetime(2026, 6, 24, 10, 0, 0)
newer.add_message("user", "newer real activity")
newer.messages[-1]["timestamp"] = "2026-06-24T10:00:00"
newer.updated_at = datetime(2026, 6, 24, 10, 0, 0)
manager.save(newer)
rows = list_webui_sessions(manager)
assert [row["key"] for row in rows] == ["websocket:newer", "websocket:old"]
assert rows[1]["updated_at"] == "2026-06-05T10:00:00"
def list_webui_sessions(manager: SessionManager) -> list[dict]:
return session_list_index.list_webui_sessions(manager)