diff --git a/api/models.py b/api/models.py index a92c2840..1ab6e537 100644 --- a/api/models.py +++ b/api/models.py @@ -2195,6 +2195,70 @@ def _has_live_sidebar_state(session: dict) -> bool: ) +def _is_intentionally_background_sidebar_session(session: dict) -> bool: + sid = str(session.get('session_id') or '') + source = session.get('source_tag') or session.get('source') + return source == 'cron' or sid.startswith('cron_') + + +def _preserve_messageful_sidebar_discoverability( + candidates: list[dict], + visible: list[dict], +) -> list[dict]: + """Keep at least one messageful row per non-background conversation visible. + + The normal sidebar filters intentionally hide empty drafts, cron/background + rows, and duplicate pre-compression snapshots. They must not make the only + messageful representative of a conversation disappear. If every visible row + for a lineage was filtered out, rescue the best hidden messageful row and + mark it so callers can surface or audit the degraded state. + """ + sessions_by_id = { + str(session.get('session_id')): session + for session in candidates + if session.get('session_id') + } + covered_roots = { + _sidebar_lineage_root_id(session, sessions_by_id) + for session in visible + if _sidebar_message_count(session) > 0 + } + visible_ids = { + str(session.get('session_id')) + for session in visible + if session.get('session_id') + } + rescue_by_root: dict[str, dict] = {} + for session in candidates: + sid = str(session.get('session_id') or '') + if not sid or sid in visible_ids: + continue + if _sidebar_message_count(session) <= 0: + continue + if _is_intentionally_background_sidebar_session(session): + continue + root = _sidebar_lineage_root_id(session, sessions_by_id) + if root in covered_roots: + continue + current = rescue_by_root.get(root) + if current is None or ( + _sidebar_message_count(session), _session_sort_timestamp(session) + ) > ( + _sidebar_message_count(current), _session_sort_timestamp(current) + ): + rescued = dict(session) + rescued['discoverability_warning'] = 'rescued_messageful_hidden_session' + rescue_by_root[root] = rescued + if not rescue_by_root: + return visible + rescued_rows = sorted( + rescue_by_root.values(), + key=lambda session: (session.get('pinned', False), _session_sort_timestamp(session)), + reverse=True, + ) + return visible + rescued_rows + + def _prefer_fuller_snapshots_for_sidebar(sessions: list[dict]) -> list[dict]: """Expose a hidden snapshot when it is the fuller transcript for a lineage. @@ -2412,7 +2476,8 @@ def all_sessions(diag=None): and not s.get('worktree_path') )] result = _prefer_fuller_snapshots_for_sidebar(result) - result = [s for s in result if not _hide_from_default_sidebar(s)] + visible_result = [s for s in result if not _hide_from_default_sidebar(s)] + result = _preserve_messageful_sidebar_discoverability(result, visible_result) _strip_sidebar_internal_flags(result) # Backfill: sessions created before Sprint 22 have no profile tag. # Attribute them to 'default' so the client profile filter works correctly. @@ -2450,7 +2515,8 @@ def all_sessions(diag=None): and not getattr(s, 'worktree_path', None) )] result = _prefer_fuller_snapshots_for_sidebar(result) - result = [s for s in result if not _hide_from_default_sidebar(s)] + visible_result = [s for s in result if not _hide_from_default_sidebar(s)] + result = _preserve_messageful_sidebar_discoverability(result, visible_result) _strip_sidebar_internal_flags(result) for s in result: if not s.get('profile'): diff --git a/tests/test_session_discoverability_invariants.py b/tests/test_session_discoverability_invariants.py new file mode 100644 index 00000000..4c553bee --- /dev/null +++ b/tests/test_session_discoverability_invariants.py @@ -0,0 +1,62 @@ +"""Regression coverage for session sidebar discoverability invariants.""" + + +def test_messageful_hidden_snapshot_is_preserved_when_no_visible_representative(): + from api.models import _preserve_messageful_sidebar_discoverability + + hidden_snapshot = { + "session_id": "root_snapshot", + "title": "Long conversation snapshot", + "message_count": 42, + "pre_compression_snapshot": True, + } + + result = _preserve_messageful_sidebar_discoverability( + candidates=[hidden_snapshot], + visible=[], + ) + + assert [row["session_id"] for row in result] == ["root_snapshot"] + assert result[0]["discoverability_warning"] == "rescued_messageful_hidden_session" + + +def test_messageful_hidden_snapshot_stays_hidden_when_continuation_is_visible(): + from api.models import _preserve_messageful_sidebar_discoverability + + hidden_snapshot = { + "session_id": "root_snapshot", + "title": "Archived snapshot", + "message_count": 42, + "pre_compression_snapshot": True, + } + visible_tip = { + "session_id": "tip_session", + "parent_session_id": "root_snapshot", + "title": "Visible continuation", + "message_count": 50, + } + + result = _preserve_messageful_sidebar_discoverability( + candidates=[hidden_snapshot, visible_tip], + visible=[visible_tip], + ) + + assert [row["session_id"] for row in result] == ["tip_session"] + + +def test_intentional_background_sessions_are_not_rescued_into_sidebar(): + from api.models import _preserve_messageful_sidebar_discoverability + + cron_row = { + "session_id": "cron_digest_001", + "title": "Digest", + "source_tag": "cron", + "message_count": 12, + } + + result = _preserve_messageful_sidebar_discoverability( + candidates=[cron_row], + visible=[], + ) + + assert result == []