fix: simplify session recency activity tracking

maintainer edit: remove the _last_compacted_at maintenance state, gate idle compaction on whether a session still has a removable tail, and sort WebUI sessions by the latest visible transcript activity.
This commit is contained in:
chengyongru
2026-06-30 23:38:32 +08:00
committed by Xubin Ren
parent 3403b87641
commit 840ba5af33
7 changed files with 94 additions and 88 deletions
+7 -11
View File
@@ -8,7 +8,6 @@ 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
@@ -89,11 +88,9 @@ 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.metadata[LAST_COMPACTED_AT_META] = compacted_at
loop.sessions.save(session)
return ""
@@ -113,7 +110,6 @@ def _make_fake_compact(
archive_msgs = result.dropped[result.already_consolidated_count:]
if not archive_msgs and not kept:
session.metadata[LAST_COMPACTED_AT_META] = compacted_at
loop.sessions.save(session)
return ""
@@ -134,7 +130,6 @@ def _make_fake_compact(
session.messages = kept
session.last_consolidated = 0
session.metadata[LAST_COMPACTED_AT_META] = compacted_at
loop.sessions.save(session)
return s
@@ -1023,27 +1018,28 @@ class TestProactiveAutoCompact:
await self._run_check_expired(loop)
assert _fake_compact.state["count"] == 1
# Second tick: should NOT re-schedule (maintenance timestamp is fresh)
# Second tick: should NOT re-schedule because the session has no removable tail.
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_records_compaction_prevents_reschedule(self, tmp_path):
"""Empty session skip records maintenance, preventing immediate re-scheduling."""
async def test_empty_session_does_not_schedule_idle_compact(self, tmp_path):
"""Empty expired sessions have no removable tail and should not schedule."""
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)
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
_fake_compact = _make_fake_compact(loop)
loop.consolidator.compact_idle_session = _fake_compact
# First tick: skips (no messages), records compaction metadata
await self._run_check_expired(loop)
assert _fake_compact.state["count"] == 0
assert "cli:test" not in loop.auto_compact._summaries
# Second tick: should NOT re-schedule because maintenance metadata is fresh
await self._run_check_expired(loop)
assert _fake_compact.state["count"] == 0
assert "cli:test" not in loop.auto_compact._summaries
await loop.close_mcp()
+10 -8
View File
@@ -6,7 +6,6 @@ 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
@@ -201,8 +200,11 @@ class TestCheckExpired:
"""Expired session should trigger schedule_background."""
ac = _make_autocompact(ttl=15)
mock_sm = MagicMock(spec=SessionManager)
old_ts = (datetime.now() - timedelta(minutes=20)).isoformat()
mock_sm.list_sessions.return_value = [{"key": "cli:old", "updated_at": old_ts}]
old_dt = datetime.now() - timedelta(minutes=20)
session = _make_session("cli:old", updated_at=old_dt)
_add_turns(session, 5)
mock_sm.list_sessions.return_value = [{"key": "cli:old", "updated_at": old_dt.isoformat()}]
mock_sm.get_or_create.return_value = session
ac.sessions = mock_sm
scheduled = []
@@ -274,17 +276,17 @@ 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."""
def test_already_trimmed_session_skips(self):
"""Expired session with no removable tail should not be re-scheduled."""
ac = _make_autocompact(ttl=15)
mock_sm = MagicMock(spec=SessionManager)
last_active = datetime(2026, 1, 1, 10, 0, 0)
session = _make_session("cli:done", updated_at=last_active)
_add_turns(session, 2)
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()},
}
mock_sm.get_or_create.return_value = session
ac.sessions = mock_sm
scheduler = MagicMock()
+3 -5
View File
@@ -6,7 +6,6 @@ import pytest
from nanobot.agent.memory import (
_ARCHIVE_SUMMARY_MAX_CHARS,
LAST_COMPACTED_AT_META,
Consolidator,
MemoryStore,
)
@@ -448,7 +447,6 @@ 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
@@ -523,10 +521,10 @@ class TestCompactIdleSession:
assert entries[0]["session_key"] == "cli:test"
@pytest.mark.asyncio
async def test_empty_session_records_compaction_without_refreshing_timestamp(
async def test_empty_session_does_not_refresh_timestamp(
self, real_consolidator
):
"""Empty session with old updated_at records maintenance separately."""
"""Empty session with old updated_at does not look active after compaction."""
from datetime import datetime, timedelta
sessions = real_consolidator.sessions
@@ -540,7 +538,7 @@ class TestCompactIdleSession:
reloaded = sessions.get_or_create("cli:empty")
assert reloaded.updated_at == old_ts
assert LAST_COMPACTED_AT_META in reloaded.metadata
assert reloaded.metadata == {}
@pytest.mark.asyncio
async def test_nothing_summary_not_stored(self, real_consolidator, mock_provider):