diff --git a/nanobot/agent/autocompact.py b/nanobot/agent/autocompact.py index 6af614d35..3526ae16e 100644 --- a/nanobot/agent/autocompact.py +++ b/nanobot/agent/autocompact.py @@ -134,10 +134,21 @@ class AutoCompact: if entry: return session, self._format_summary(entry[0], entry[1]) # Cold path: summary persisted in session metadata (process restarted). + # Persisted metadata may outlive schema changes; a malformed summary must + # not abort turn preparation. meta = session.metadata.get("_last_summary") if isinstance(meta, dict): - return session, self._format_summary( - cast(str, meta["text"]), - datetime.fromisoformat(cast(str, meta["last_active"])), - ) + summary_meta = cast(dict[str, object], meta) + text = summary_meta.get("text") + if isinstance(text, str) and text: + raw_last_active = summary_meta.get("last_active") + try: + last_active = ( + datetime.fromisoformat(raw_last_active) + if isinstance(raw_last_active, str) + else session.updated_at + ) + except ValueError: + last_active = session.updated_at + return session, self._format_summary(text, last_active) return session, None diff --git a/tests/agent/test_autocompact_unit.py b/tests/agent/test_autocompact_unit.py index e73daa452..f9c26c014 100644 --- a/tests/agent/test_autocompact_unit.py +++ b/tests/agent/test_autocompact_unit.py @@ -592,6 +592,58 @@ class TestPrepareSession: assert summary is not None assert "Cold summary." in summary + def test_cold_path_tolerates_malformed_last_active(self): + """A malformed persisted last_active must not raise on the turn path. + + prepare_session runs from _compact_session on every turn. Persisted + _last_summary can be hand-edited or written by another version, so a bad + last_active should degrade gracefully (mirror estimate_session_prompt_tokens + and _archive) instead of crashing the turn. + """ + ac = _make_autocompact(ttl=0) + fallback = datetime(2026, 1, 2, 3, 4, 5) + session = _make_session( + metadata={ + "_last_summary": {"text": "Cold summary.", "last_active": "not-a-date"}, + }, + updated_at=fallback, + ) + + result_session, summary = ac.prepare_session(session, "cli:test") + + assert result_session is session + assert summary is not None + assert "Cold summary." in summary + assert fallback.isoformat() in summary + + def test_cold_path_tolerates_missing_last_active(self): + """A _last_summary dict without last_active must not raise.""" + ac = _make_autocompact(ttl=0) + fallback = datetime(2026, 1, 2, 3, 4, 5) + session = _make_session( + metadata={"_last_summary": {"text": "Cold summary."}}, + updated_at=fallback, + ) + + result_session, summary = ac.prepare_session(session, "cli:test") + + assert result_session is session + assert summary is not None + assert "Cold summary." in summary + assert fallback.isoformat() in summary + + def test_cold_path_missing_text_returns_none(self): + """A _last_summary without a non-empty string text yields no summary.""" + ac = _make_autocompact() + session = _make_session(metadata={ + "_last_summary": {"last_active": datetime(2026, 1, 1).isoformat()}, + }) + + result_session, summary = ac.prepare_session(session, "cli:test") + + assert result_session is session + assert summary is None + def test_no_summary_available_returns_none(self): """When no summary is available, should return (session, None).""" ac = _make_autocompact()