Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions hermes_state_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1267,7 +1267,9 @@ def list_sessions_rich(
) -> List[Dict[str, Any]]:
"""List sessions with preview and ``last_active`` in one query. ``order_by_last_active`` sorts
by the chain TIP via a recursive CTE (the only path honouring ``id_query`` / ``search_query``);
``include_pinned`` back-fills pins the page missed, still obeying the other filters."""
``include_pinned`` back-fills pins the page missed, still obeying the other
filters except archived: a pin is an explicit keep, so a pinned row stamped
archived must still return."""
self.flush_token_counts() # rows carry token/cost totals
where_clauses, params = _session_filter_where(
exclude_children=not include_children, source=source, sources=sources, session_key=session_key,
Expand All @@ -1281,7 +1283,6 @@ def list_sessions_rich(
if not include_hidden and not archived_only:
where_clauses.append("s.hidden = 0")
where_sql = _where_sql(where_clauses)
base_where_params = list(params) # pinned back-fill reuses the WHERE before LIMIT/OFFSET
# Shared projection head of the three list queries (whitespace is part of the SQL text).
select_head = (
f"SELECT {self._compact_session_cols() if compact_rows else 's.*'}"
Expand Down Expand Up @@ -1342,17 +1343,27 @@ def list_sessions_rich(
params.extend([limit, offset])
sessions = [self._list_row(row) for row in self._read_all(query, params)]
# Pinned back-fill runs BEFORE compression projection so a back-filled root
# projects to its tip like any other row.
# projects to its tip like any other row. Do not inherit the archived
# constraint: the sidebar lists with include_archived=False, and a pin
# must stay reachable even when that row is also archived.
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"
pinned_clauses, pinned_params = _session_filter_where(
exclude_children=not include_children, source=source, sources=sources,
session_key=session_key, exclude_sources=exclude_sources, cwd_prefix=cwd_prefix,
min_message_count=min_message_count, archived_only=False, include_archived=True,
)
if not include_hidden and not archived_only:
pinned_clauses.append("s.hidden = 0")
pinned_clauses.append("s.pinned = 1")
pinned_where = _where_sql(pinned_clauses)
pinned_query = f"""
{select_head}{_sql_session_last_active("s")} AS last_active
{from_sessions}
{pinned_where}
ORDER BY s.started_at DESC
"""
for row in self._read_all(pinned_query, base_where_params):
for row in self._read_all(pinned_query, pinned_params):
s = self._list_row(row)
if s["id"] not in seen_ids:
seen_ids.add(s["id"])
Expand Down
101 changes: 101 additions & 0 deletions tests/hermes_state/test_pinned_archived_sidebar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Pinned rows must stay reachable from the sidebar session query.

The sidebar calls ``list_sessions_rich`` with ``include_archived=False`` and
``include_pinned=True``. A pin is an explicit keep: the back-fill must not
inherit the archived constraint, and the stale-session sweep must not archive
a pinned row.
"""
import time

import pytest

from hermes_state import SessionDB


@pytest.fixture
def db(tmp_path):
return SessionDB(tmp_path / "state.db")


def _sidebar_ids(db, **extra):
"""Same flags as the desktop sidebar slice."""
return [
row["id"]
for row in db.list_sessions_rich(
limit=20,
offset=0,
min_message_count=1,
include_archived=False,
archived_only=False,
order_by_last_active=True,
compact_rows=True,
include_pinned=True,
**extra,
)
]


def _seed(db, sid, *, source="cli", pinned=False, archived=False):
db.create_session(session_id=sid, source=source)
db.append_message(session_id=sid, role="user", content=f"msg {sid}")
if pinned:
assert db.set_session_pinned(sid, True)
if archived:
assert db.set_session_archived(sid, True)


def test_sidebar_query_returns_pinned_archived_session(db):
"""A pinned row stamped archived is absent from the page and must back-fill."""
_seed(db, "live")
_seed(db, "pin-archived", pinned=True, archived=True)
_seed(db, "archived-only", archived=True)

page = [
row["id"]
for row in db.list_sessions_rich(
limit=20,
offset=0,
min_message_count=1,
include_archived=False,
order_by_last_active=True,
compact_rows=True,
include_pinned=False,
)
]
assert "pin-archived" not in page
assert "archived-only" not in page

ids = _sidebar_ids(db)
assert "live" in ids
assert "archived-only" not in ids
assert "pin-archived" in ids
row = db.get_session("pin-archived")
assert row["pinned"] == 1
assert row["archived"] == 1


def test_pinned_backfill_still_obeys_other_filters(db):
"""Dropping the archived constraint must not drop source exclusion."""
_seed(db, "cron-pin", source="cron", pinned=True, archived=True)
assert "cron-pin" not in _sidebar_ids(db, exclude_sources=["cron"])


def test_stale_sweep_does_not_archive_pinned_session(db):
"""The idle sweep retires an unpinned stale row and leaves a pin active."""
db.create_session(session_id="keep", source="cli")
db.append_message(session_id="keep", role="user", content="old pin")
db.set_session_pinned("keep", True)
db.create_session(session_id="drop", source="cli")
db.append_message(session_id="drop", role="user", content="old")
old = time.time() - 10 * 86400
db._conn.execute("UPDATE sessions SET started_at = ? WHERE id IN (?, ?)", (old, "keep", "drop"))
db._conn.execute(
"UPDATE messages SET timestamp = ? WHERE session_id IN (?, ?)",
(old, "keep", "drop"),
)
db._conn.commit()

assert db.archive_stale_sessions(3) == 1
assert db.get_session("keep")["archived"] == 0
assert db.get_session("keep")["pinned"] == 1
assert db.get_session("drop")["archived"] == 1
Loading