diff --git a/hermes_state.py b/hermes_state.py index 8c3b3079ceb..ae4190fec03 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -6491,6 +6491,7 @@ class SessionDB: id_query: str = None, search_query: str = None, compact_rows: bool = False, + include_pinned: bool = False, ) -> List[Dict[str, Any]]: """List sessions with preview (first user message) and last active timestamp. @@ -6530,6 +6531,14 @@ class SessionDB: the SELECT so SQLite never copies it out of the B-tree page — a significant I/O saving on large databases where the blob routinely runs to tens of kilobytes per row. + + Pass ``include_pinned=True`` to back-fill any conversation carrying the + durable ``pinned`` flag that the LIMIT/OFFSET window left out. A pin is + a "this must always be reachable" statement, so a pinned conversation + aging past the requested page is a bug, not a paging outcome — the + desktop sidebar would render an empty Pinned section. Back-filled rows + obey the same filters (source, archived, min_message_count) as the + page: an archived or filtered-out conversation stays out. """ # Rows carry token/cost totals — drain queued deltas first so # listings (sidebar, /resume, dashboards) show exact counters. @@ -6577,6 +6586,9 @@ class SessionDB: where_clauses.append("s.archived = 0") where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" + # Snapshot the filter params before the query builders below extend + # them with LIMIT/OFFSET — the pinned back-fill reuses the same WHERE. + base_where_params = list(params) # Optional session-id filter, pushed into SQL so callers (Desktop # session-id search) don't have to fetch every row and filter in @@ -6730,6 +6742,45 @@ class SessionDB: s.pop("_effective_last_active", None) sessions.append(s) + # Back-fill pinned conversations the page missed. A pin outlives + # recency, so this runs BEFORE compression projection below — a + # back-filled root then projects to its live tip exactly like a row + # that had made the page on its own. One extra query, bounded by the + # number of pins (a handful), never N+1 per pin. + if include_pinned: + seen_ids = {s["id"] for s in sessions} + pinned_where = ( + f"{where_sql} AND s.pinned = 1" if where_sql else "WHERE s.pinned = 1" + ) + _sel = self._compact_session_cols() if compact_rows else "s.*" + pinned_query = f""" + SELECT {_sel}, + COALESCE( + (SELECT {_PREVIEW_RAW_SELECT} + FROM messages m + WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL + ORDER BY m.timestamp, m.id LIMIT 1), + '' + ) AS _preview_raw, + COALESCE( + (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id), + s.started_at + ) AS last_active + FROM sessions s + {pinned_where} + ORDER BY s.started_at DESC + """ + with self._read_ctx() as conn: + pinned_cursor = conn.execute(pinned_query, base_where_params) + pinned_rows = pinned_cursor.fetchall() + for row in pinned_rows: + s = dict(row) + if s["id"] in seen_ids: + continue + s["preview"] = _shape_preview(s.pop("_preview_raw", "")) + seen_ids.add(s["id"]) + sessions.append(s) + # Project compression roots forward to their tips. Each row whose # end_reason is 'compression' has a continuation child; replace the # surfaced fields (id, message_count, title, last_active, ended_at, diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 89017c6d4ce..1aad825feed 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -6960,6 +6960,99 @@ class TestSessionPinAndStaleArchive: assert self._pinned(db, "root") == 1 assert self._pinned(db, "tip") == 1 + # ── pinned back-fill past the page window ───────────────────────────── + def test_pinned_session_survives_the_limit_window(self, db): + """A pin outlives recency: paging must not evict a pinned row. + + Without ``include_pinned`` the desktop's Pinned section renders empty + for any conversation that has aged off the sidebar page. + """ + for i in range(6): + self._make_idle(db, f"s{i}", days_idle=6 - i) + db.set_session_pinned("s0", True) # the oldest — off a 3-row page + + def ids(**kw): + return [ + s["id"] + for s in db.list_sessions_rich( + limit=3, min_message_count=1, order_by_last_active=True, **kw + ) + ] + + page = ids() + assert "s0" not in page, "precondition: the pin is off the page" + + with_pins = ids(include_pinned=True) + assert "s0" in with_pins + # The page itself is untouched; the pin is additive. + assert with_pins[:3] == page + assert len(with_pins) == len(page) + 1 + + def test_pinned_backfill_still_obeys_the_page_filters(self, db): + """A back-filled pin is not a bypass — archived/short rows stay out.""" + for i in range(4): + self._make_idle(db, f"f{i}", days_idle=4 - i) + self._make_idle(db, "archived_pin", days_idle=9) + db.set_session_pinned("archived_pin", True) + db.set_session_archived("archived_pin", True) + # Pinned but with no messages at all. + db.create_session(session_id="empty_pin", source="cli") + db.set_session_pinned("empty_pin", True) + + ids = [ + s["id"] + for s in db.list_sessions_rich( + limit=2, + min_message_count=1, + order_by_last_active=True, + include_pinned=True, + ) + ] + + assert "archived_pin" not in ids + assert "empty_pin" not in ids + + def test_pinned_backfill_does_not_duplicate_an_on_page_row(self, db): + for i in range(3): + self._make_idle(db, f"p{i}", days_idle=3 - i) + db.set_session_pinned("p2", True) # newest — already on the page + + ids = [ + s["id"] + for s in db.list_sessions_rich( + limit=3, + min_message_count=1, + order_by_last_active=True, + include_pinned=True, + ) + ] + + assert ids.count("p2") == 1 + + def test_pinned_backfill_projects_a_compression_root_to_its_tip(self, db): + """A back-filled root goes through tip projection like any other row.""" + for i in range(4): + self._make_idle(db, f"n{i}", days_idle=4 - i) + + self._make_idle(db, "old_root", days_idle=30) + db.end_session("old_root", end_reason="compression") + db.create_session( + session_id="old_tip", source="cli", parent_session_id="old_root" + ) + db.append_message(session_id="old_tip", role="user", content="continued") + db.set_session_pinned("old_root", True) + + rows = db.list_sessions_rich( + limit=2, + min_message_count=1, + order_by_last_active=True, + include_pinned=True, + ) + backfilled = next(s for s in rows if s.get("_lineage_root_id") == "old_root") + + # Surfaced under the live tip's identity, keyed on the durable root. + assert backfilled["id"] == "old_tip" + # ── stale archive ───────────────────────────────────────────────────── def test_archives_only_sessions_idle_past_threshold(self, db): self._make_idle(db, "stale", days_idle=5)