nanobot/tests/agent/test_autocompact_unit.py
chengyongru d1a94dae8a
refactor(dream): replace two-phase Dream class with simple cron + process_direct (#3990)
* refactor(dream): replace two-phase Dream class with simple cron + process_direct

- Remove the heavyweight Dream class (AgentRunner-based two-phase system)
  from nanobot/agent/memory.py
- Delete dream_phase1.md and dream_phase2.md templates
- New dream.md template serves as the consolidation prompt
- Cron callback uses agent.process_direct(prompt, session_key=\"dream\")
  instead of agent.dream.run()
- Always performs git auto_commit after execution
- /dream command updated to use process_direct + git commit
- DreamConfig kept for backward compatibility; deprecated fields
  (model_override, max_batch_size, max_iterations, annotate_line_ages)
  are ignored but accepted in config
- interval_h remains configurable via agents.defaults.dream.interval_h
- Update tests and webui settings to match new architecture

* feat(loop): add ephemeral mode to process_direct, skip history writes for Dream

When ephemeral=True, _state_save skips enforce_file_cap (which calls
raw_archive -> append_history) and consolidator.maybe_consolidate_by_tokens.
This prevents Dream sessions from creating a positive feedback loop where
they process their own output. The session IS still saved to disk.

* fix(loop): skip extra hooks for ephemeral sessions (Dream)

* feat(dream): per-run timestamped sessions with rotation for WebUI

* test(config): restore DreamConfig schedule and alias tests

* fix(dream): include LLM response summary in git auto-commit message

The old two-phase Dream class included the Phase 1 analysis in the git
commit message body. The new single-phase version lost this. Restore it
by extracting resp.content from the process_direct return value and
appending it to the commit message in both the cron handler and the
/dream command.

* fix(test): accept ephemeral kwarg in test_openai_api fake_process

* refactor(dream): merge dream_session.py into MemoryStore

The standalone dream_session.py module only contained three small helpers
that all revolve around MemoryStore concerns (session keys, commit messages,
file pruning). Fold them into MemoryStore as @staticmethod to reduce
indirection and avoid a 35-line module with no independent reason to exist.

* fix(test): address code review — patch correct instance, use actual function

- Fix test_ephemeral_skips_raw_archive to patch loop.context.memory
  instead of the fixture's separate MemoryStore instance
- Fix TestDreamCommitMessage to call MemoryStore.build_dream_commit_message
  instead of reimplementing the logic inline
- Move Dream helpers in memory.py above the Consolidator section comment
  to avoid misleading visual boundary

* fix(dream): gate cursor advancement and restrict tools

maintainer edit: Dream now processes backlog from the oldest unprocessed entries, only advances the cursor after a completed ephemeral run, and uses a restricted file-only tool registry for background consolidation.

* fix(dream): skip idle compact for dream sessions

Dream runs use internal dream:* sessions that are pruned by Dream retention. Exclude them from AutoCompact scheduling, archive execution, and summary injection so idle-session compaction cannot truncate Dream transcripts.

* fix(dream): keep batched history isolated

* feat(dream): tag archived memory for single-phase Dream

---------

Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
2026-06-02 22:46:47 +08:00

504 lines
18 KiB
Python

"""Direct unit tests for AutoCompact class methods in isolation."""
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.autocompact import AutoCompact
from nanobot.session.manager import Session, SessionManager
def _make_session(
key: str = "cli:test",
messages: list | None = None,
last_consolidated: int = 0,
updated_at: datetime | None = None,
metadata: dict | None = None,
) -> Session:
"""Create a Session with sensible defaults for testing."""
session = Session(
key=key,
messages=messages or [],
metadata=metadata or {},
last_consolidated=last_consolidated,
)
if updated_at is not None:
session.updated_at = updated_at
return session
def _make_autocompact(
ttl: int = 15,
sessions: SessionManager | None = None,
consolidator: MagicMock | None = None,
) -> AutoCompact:
"""Create an AutoCompact with mock dependencies."""
if sessions is None:
sessions = MagicMock(spec=SessionManager)
if consolidator is None:
consolidator = MagicMock()
consolidator.compact_idle_session = AsyncMock(return_value="Summary.")
return AutoCompact(
sessions=sessions,
consolidator=consolidator,
session_ttl_minutes=ttl,
)
def _add_turns(session: Session, turns: int, *, prefix: str = "msg") -> None:
"""Append simple user/assistant turns to a session."""
for i in range(turns):
session.add_message("user", f"{prefix} user {i}")
session.add_message("assistant", f"{prefix} assistant {i}")
# ---------------------------------------------------------------------------
# __init__
# ---------------------------------------------------------------------------
class TestInit:
"""Test AutoCompact.__init__ stores constructor arguments correctly."""
def test_stores_ttl(self):
"""_ttl should match session_ttl_minutes argument."""
ac = _make_autocompact(ttl=30)
assert ac._ttl == 30
def test_default_ttl_is_zero(self):
"""Default TTL should be 0."""
ac = _make_autocompact(ttl=0)
assert ac._ttl == 0
def test_archiving_set_is_empty(self):
"""_archiving should start as an empty set."""
ac = _make_autocompact()
assert ac._archiving == set()
def test_summaries_dict_is_empty(self):
"""_summaries should start as an empty dict."""
ac = _make_autocompact()
assert ac._summaries == {}
def test_stores_sessions_reference(self):
"""sessions attribute should reference the passed SessionManager."""
mock_sm = MagicMock(spec=SessionManager)
ac = _make_autocompact(sessions=mock_sm)
assert ac.sessions is mock_sm
def test_stores_consolidator_reference(self):
"""consolidator attribute should reference the passed Consolidator."""
mock_c = MagicMock()
ac = _make_autocompact(consolidator=mock_c)
assert ac.consolidator is mock_c
# ---------------------------------------------------------------------------
# _is_expired
# ---------------------------------------------------------------------------
class TestIsExpired:
"""Test AutoCompact._is_expired edge cases."""
def test_ttl_zero_always_false(self):
"""TTL=0 means auto-compact is disabled; always returns False."""
ac = _make_autocompact(ttl=0)
old = datetime.now() - timedelta(days=365)
assert ac._is_expired(old) is False
def test_none_timestamp_returns_false(self):
"""None timestamp should return False."""
ac = _make_autocompact(ttl=15)
assert ac._is_expired(None) is False
def test_empty_string_timestamp_returns_false(self):
"""Empty string timestamp should return False (falsy)."""
ac = _make_autocompact(ttl=15)
assert ac._is_expired("") is False
def test_exactly_at_boundary_is_expired(self):
"""Timestamp exactly at TTL boundary should be expired (>=)."""
ac = _make_autocompact(ttl=15)
now = datetime(2026, 1, 1, 12, 0, 0)
ts = now - timedelta(minutes=15)
assert ac._is_expired(ts, now=now) is True
def test_just_under_boundary_not_expired(self):
"""Timestamp just under TTL boundary should NOT be expired."""
ac = _make_autocompact(ttl=15)
now = datetime(2026, 1, 1, 12, 0, 0)
ts = now - timedelta(minutes=14, seconds=59)
assert ac._is_expired(ts, now=now) is False
def test_iso_string_parses_correctly(self):
"""ISO format string timestamp should be parsed and evaluated."""
ac = _make_autocompact(ttl=15)
now = datetime(2026, 1, 1, 12, 0, 0)
ts = (now - timedelta(minutes=20)).isoformat()
assert ac._is_expired(ts, now=now) is True
def test_custom_now_parameter(self):
"""Custom 'now' parameter should override datetime.now()."""
ac = _make_autocompact(ttl=10)
ts = datetime(2026, 1, 1, 10, 0, 0)
# 9 minutes later → not expired
now_under = datetime(2026, 1, 1, 10, 9, 0)
assert ac._is_expired(ts, now=now_under) is False
# 10 minutes later → expired
now_over = datetime(2026, 1, 1, 10, 10, 0)
assert ac._is_expired(ts, now=now_over) is True
# ---------------------------------------------------------------------------
# _format_summary
# ---------------------------------------------------------------------------
class TestFormatSummary:
"""Test AutoCompact._format_summary static method."""
def test_contains_isoformat_timestamp(self):
"""Output should contain last_active as isoformat."""
last_active = datetime(2026, 5, 13, 14, 30, 0)
result = AutoCompact._format_summary("Some text", last_active)
assert "2026-05-13T14:30:00" in result
def test_contains_summary_text(self):
"""Output should contain the provided text verbatim."""
last_active = datetime(2026, 1, 1)
result = AutoCompact._format_summary("User discussed Python.", last_active)
assert "User discussed Python." in result
def test_output_starts_with_label(self):
"""Output should start with the standard prefix."""
last_active = datetime(2026, 1, 1)
result = AutoCompact._format_summary("text", last_active)
assert result.startswith("Previous conversation summary (last active ")
# ---------------------------------------------------------------------------
# check_expired
# ---------------------------------------------------------------------------
class TestCheckExpired:
"""Test AutoCompact.check_expired scheduling logic."""
def test_empty_sessions_list(self):
"""No sessions → schedule_background should never be called."""
ac = _make_autocompact(ttl=15)
mock_sm = MagicMock(spec=SessionManager)
mock_sm.list_sessions.return_value = []
ac.sessions = mock_sm
scheduler = MagicMock()
ac.check_expired(scheduler)
scheduler.assert_not_called()
def test_expired_session_schedules_background(self):
"""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}]
ac.sessions = mock_sm
scheduled = []
def scheduler(coro):
scheduled.append(coro)
coro.close()
ac.check_expired(scheduler)
assert len(scheduled) == 1
assert "cli:old" in ac._archiving
def test_active_session_key_skips(self):
"""Session in active_session_keys should be skipped."""
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:busy", "updated_at": old_ts}]
ac.sessions = mock_sm
scheduler = MagicMock()
ac.check_expired(scheduler, active_session_keys={"cli:busy"})
scheduler.assert_not_called()
def test_session_already_in_archiving_skips(self):
"""Session already in _archiving set should be skipped."""
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:dup", "updated_at": old_ts}]
ac.sessions = mock_sm
ac._archiving.add("cli:dup")
scheduler = MagicMock()
ac.check_expired(scheduler)
scheduler.assert_not_called()
def test_session_with_no_key_skips(self):
"""Session info with empty/missing key should be skipped."""
ac = _make_autocompact(ttl=15)
mock_sm = MagicMock(spec=SessionManager)
mock_sm.list_sessions.return_value = [{"key": "", "updated_at": "old"}]
ac.sessions = mock_sm
scheduler = MagicMock()
ac.check_expired(scheduler)
scheduler.assert_not_called()
def test_session_with_missing_key_field_skips(self):
"""Session info dict without 'key' field should be skipped."""
ac = _make_autocompact(ttl=15)
mock_sm = MagicMock(spec=SessionManager)
mock_sm.list_sessions.return_value = [{"updated_at": "old"}]
ac.sessions = mock_sm
scheduler = MagicMock()
ac.check_expired(scheduler)
scheduler.assert_not_called()
def test_dream_session_skips(self):
"""Internal Dream sessions should not be scheduled for idle compact."""
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": "dream:20260602-155256", "updated_at": old_ts},
]
ac.sessions = mock_sm
scheduler = MagicMock()
ac.check_expired(scheduler)
scheduler.assert_not_called()
assert "dream:20260602-155256" not in ac._archiving
# ---------------------------------------------------------------------------
# _archive
# ---------------------------------------------------------------------------
class TestArchiveDelegates:
"""_archive should delegate all session mutation to Consolidator."""
@pytest.mark.asyncio
async def test_calls_compact_idle_session(self):
ac = _make_autocompact()
mock_sm = MagicMock(spec=SessionManager)
ac.sessions = mock_sm
ac.consolidator.compact_idle_session = AsyncMock(return_value="Summary.")
await ac._archive("cli:test")
ac.consolidator.compact_idle_session.assert_awaited_once_with(
"cli:test", ac._RECENT_SUFFIX_MESSAGES,
)
@pytest.mark.asyncio
async def test_dream_session_is_ignored(self):
ac = _make_autocompact()
ac.consolidator.compact_idle_session = AsyncMock(return_value="Summary.")
ac._archiving.add("dream:20260602-155256")
await ac._archive("dream:20260602-155256")
ac.consolidator.compact_idle_session.assert_not_awaited()
assert "dream:20260602-155256" not in ac._archiving
@pytest.mark.asyncio
async def test_populates_summaries_from_metadata(self):
ac = _make_autocompact()
mock_sm = MagicMock(spec=SessionManager)
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.compact_idle_session = AsyncMock(return_value="Hello.")
await ac._archive("cli:test")
entry = ac._summaries.get("cli:test")
assert entry is not None
assert entry[0] == "Hello."
@pytest.mark.asyncio
async def test_no_summary_when_compact_returns_empty(self):
ac = _make_autocompact()
mock_sm = MagicMock(spec=SessionManager)
ac.sessions = mock_sm
ac.consolidator.compact_idle_session = AsyncMock(return_value="")
await ac._archive("cli:test")
assert "cli:test" not in ac._summaries
@pytest.mark.asyncio
async def test_no_summary_when_compact_returns_nothing(self):
ac = _make_autocompact()
mock_sm = MagicMock(spec=SessionManager)
ac.sessions = mock_sm
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
# ---------------------------------------------------------------------------
# prepare_session
# ---------------------------------------------------------------------------
class TestPrepareSession:
"""Test AutoCompact.prepare_session logic."""
def test_key_in_archiving_reloads_session(self):
"""If key is in _archiving, session should be reloaded via get_or_create."""
ac = _make_autocompact()
mock_sm = MagicMock(spec=SessionManager)
reloaded = _make_session(key="cli:test")
mock_sm.get_or_create.return_value = reloaded
ac.sessions = mock_sm
ac._archiving.add("cli:test")
original_session = _make_session()
result_session, summary = ac.prepare_session(original_session, "cli:test")
mock_sm.get_or_create.assert_called_once_with("cli:test")
assert result_session is reloaded
def test_expired_session_reloads(self):
"""If session is expired, it should be reloaded via get_or_create."""
ac = _make_autocompact(ttl=15)
mock_sm = MagicMock(spec=SessionManager)
reloaded = _make_session(key="cli:test", updated_at=datetime.now())
mock_sm.get_or_create.return_value = reloaded
ac.sessions = mock_sm
old_session = _make_session(updated_at=datetime.now() - timedelta(minutes=20))
result_session, summary = ac.prepare_session(old_session, "cli:test")
mock_sm.get_or_create.assert_called_once_with("cli:test")
assert result_session is reloaded
def test_hot_path_summary_from_summaries(self):
"""Summary from _summaries dict should be returned (hot path)."""
ac = _make_autocompact()
session = _make_session()
last_active = datetime(2026, 5, 13, 14, 0, 0)
ac._summaries["cli:test"] = ("Hot summary.", last_active)
result_session, summary = ac.prepare_session(session, "cli:test")
assert result_session is session
assert summary is not None
assert "Hot summary." in summary
assert "Previous conversation summary" in summary
def test_hot_path_pops_summary_one_shot(self):
"""Hot path should pop the summary (one-shot; second call returns None)."""
ac = _make_autocompact()
session = _make_session()
last_active = datetime(2026, 1, 1)
ac._summaries["cli:test"] = ("One-shot.", last_active)
_, summary1 = ac.prepare_session(session, "cli:test")
assert summary1 is not None
# Second call: hot path entry was popped
_, summary2 = ac.prepare_session(session, "cli:test")
assert summary2 is None
def test_cold_path_summary_from_metadata(self):
"""When _summaries is empty, summary should come from metadata (cold path)."""
ac = _make_autocompact()
last_active = datetime(2026, 5, 13, 14, 0, 0)
session = _make_session(metadata={
"_last_summary": {
"text": "Cold summary.",
"last_active": last_active.isoformat(),
},
})
result_session, summary = ac.prepare_session(session, "cli:test")
assert result_session is session
assert summary is not None
assert "Cold summary." in summary
def test_no_summary_available_returns_none(self):
"""When no summary is available, should return (session, None)."""
ac = _make_autocompact()
session = _make_session()
result_session, summary = ac.prepare_session(session, "cli:test")
assert result_session is session
assert summary is None
def test_dream_session_skips_reload_and_summaries(self):
"""Internal Dream sessions should not reload or receive compact summaries."""
ac = _make_autocompact(ttl=15)
mock_sm = MagicMock(spec=SessionManager)
ac.sessions = mock_sm
key = "dream:20260602-155256"
ac._archiving.add(key)
ac._summaries[key] = ("Hot summary.", datetime(2026, 6, 2, 15, 52, 56))
session = _make_session(
key=key,
updated_at=datetime.now() - timedelta(minutes=20),
metadata={
"_last_summary": {
"text": "Cold summary.",
"last_active": "2026-06-02T15:52:56",
},
},
)
result_session, summary = ac.prepare_session(session, key)
mock_sm.get_or_create.assert_not_called()
assert result_session is session
assert summary is None
assert key not in ac._archiving
assert key not in ac._summaries
def test_cold_path_metadata_not_dict_returns_none(self):
"""If metadata _last_summary is not a dict, should return None summary."""
ac = _make_autocompact()
session = _make_session(metadata={"_last_summary": "not a dict"})
result_session, summary = ac.prepare_session(session, "cli:test")
assert result_session is session
assert summary is None
def test_hot_path_takes_priority_over_metadata(self):
"""Hot path (_summaries) should take priority over metadata."""
ac = _make_autocompact()
session = _make_session(metadata={
"_last_summary": {
"text": "Cold summary.",
"last_active": datetime(2026, 1, 1).isoformat(),
},
})
last_active = datetime(2026, 5, 13, 14, 0, 0)
ac._summaries["cli:test"] = ("Hot summary.", last_active)
_, summary = ac.prepare_session(session, "cli:test")
assert "Hot summary." in summary
# After hot path pops, cold path would kick in on next call