feat(sessions): generic 'hidden' session flag (sidebar-hide, still resumable) (#86797)

* feat(sessions): generic 'hidden' session flag (sidebar-hide, still resumable)

Adds a source-orthogonal, archive-orthogonal 'hidden' session flag meaning
'don't show in the global Sessions sidebar, but stay fully resumable by the
surface that owns it'. Mirrors the existing archived/pinned capability end to
end, so it's a generic widening (any plugin that owns its own session lifecycle
- kanban, Bot Mode, future plugins - can keep its sessions out of the shared
recents list) rather than a per-plugin special-case.

- Schema: hidden INTEGER NOT NULL DEFAULT 0 on sessions (additive; lands on
  existing DBs via the declarative _reconcile_columns ADD COLUMN path, same as
  archived/pinned - no version-gated migration).
- DB: SessionDB.set_session_hidden(session_id, hidden) (clones set_session_pinned
  incl. the compression-lineage recursive CTE); list_sessions_rich gains
  include_hidden=False, appending 's.hidden = 0' by default so hidden rows drop
  from every listing path (and the REST sidebar endpoints inherit it with no
  change).
- Gateway: session.set_hidden RPC (mirrors session.title); session.create accepts
  hidden=true, deferred via pending_hidden and applied in _ensure_session_db_row
  when the row is lazily created (mirrors pending_title).
- REST parity: PATCH /api/sessions/{id} accepts+bool-validates 'hidden' ->
  set_session_hidden; _session_response exposes it.

Enables Hermes-Bot-Mode to hide canonical 'Bot Chat' sessions from the sidebar
(NousResearch/Hermes-Bot-Mode#46) WITHOUT retagging source (which would mis-set
the agent platform). Bot Chats keep source=desktop. Gateway RPC needs a
SERVE-backend restart to take effect live. 1 focused test (default-exclude /
include_hidden / unhide round-trip).

* fix: teach lost-and-found recovery about the 55-column sessions layout

Adding the 'hidden' column makes the current sessions table 55 columns. The
SQLite lost-and-found recovery classifier keys off the physical field count
(SESSIONS_LAYOUT_NFIELDS) to identify a salvaged sessions row, so a recovered
current-layout row (nfield=55) would otherwise be unrecognized and dropped.
Add 55 to the frozenset (54/52 stay as historical prefixes) and update the
column-count assertions + synthetic current-layout insert in the recovery test.

---------

Co-authored-by: Teknium <teknium1@users.noreply.github.com>
This commit is contained in:
Teknium
2026-08-15 00:31:37 -07:00
committed by GitHub
parent d2672a349b
commit fbaea9bddc
8 changed files with 153 additions and 9 deletions
+6 -4
View File
@@ -3308,11 +3308,11 @@ class APIServerAdapter(BasePlatformAdapter):
"output_tokens", "cache_read_tokens", "cache_write_tokens",
"reasoning_tokens", "estimated_cost_usd", "actual_cost_usd",
"api_call_count", "parent_session_id", "last_active", "preview",
"_lineage_root_id", "pinned", "archived",
"_lineage_root_id", "pinned", "archived", "hidden",
)
payload = {key: session.get(key) for key in safe_keys if key in session}
# SQLite stores these as 0/1; clients reconcile against a real boolean.
for flag in ("pinned", "archived"):
for flag in ("pinned", "archived", "hidden"):
if flag in payload:
payload[flag] = bool(payload[flag])
# Avoid exposing full system prompts/model_config through the client API;
@@ -3534,12 +3534,12 @@ class APIServerAdapter(BasePlatformAdapter):
# sidebar owns (the "keep" flag exempts a chat from the auto-archive
# sweep). Rejecting them here was silently 400ing every pin the desktop
# made, so pins only ever lived in that one app's localStorage.
allowed = {"title", "end_reason", "pinned", "archived"}
allowed = {"title", "end_reason", "pinned", "archived", "hidden"}
unknown = sorted(set(body) - allowed)
if unknown:
return web.json_response(_openai_error(f"Unsupported session fields: {', '.join(unknown)}", code="unsupported_session_field"), status=400)
for flag in ("pinned", "archived"):
for flag in ("pinned", "archived", "hidden"):
if flag in body and not isinstance(body[flag], bool):
return web.json_response(_openai_error(f"'{flag}' must be a boolean", code="invalid_session_field"), status=400)
@@ -3553,6 +3553,8 @@ class APIServerAdapter(BasePlatformAdapter):
await asyncio.to_thread(db.set_session_pinned, session_id, body["pinned"])
if "archived" in body:
await asyncio.to_thread(db.set_session_archived, session_id, body["archived"])
if "hidden" in body:
await asyncio.to_thread(db.set_session_hidden, session_id, body["hidden"])
if body.get("end_reason"):
await asyncio.to_thread(db.end_session, session_id, str(body["end_reason"]))
session = await asyncio.to_thread(db.get_session, session_id) or session
+1 -1
View File
@@ -47,7 +47,7 @@ KNOWN_SOURCES = frozenset({
# Historical physical layouts of the sessions table. Columns are only ever
# appended (ALTER TABLE ADD COLUMN), so an older record is a strict prefix of
# the current column order.
SESSIONS_LAYOUT_NFIELDS = frozenset({54, 52})
SESSIONS_LAYOUT_NFIELDS = frozenset({55, 54, 52})
SESSIONS_LEGACY_MINIMAL_NFIELD = 14
SESSION_MODEL_USAGE_NFIELD = 18
+57
View File
@@ -7599,6 +7599,60 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
rowcount = self._execute_write(_do)
return rowcount > 0
def set_session_hidden(self, session_id: str, hidden: bool) -> bool:
"""Hide or unhide a session (and its whole compression lineage).
``hidden`` is a generic "don't show in the global Sessions sidebar"
flag: a hidden session is dropped from the default
:meth:`list_sessions_rich` listing (which omits ``include_hidden``) but
stays fully resumable by the surface that owns it — useful for plugins
that manage their own sessions (e.g. kanban) and don't want them
cluttering the shared recents list. Like :meth:`set_session_archived`
/ :meth:`set_session_pinned` the whole compression chain is flipped as
a unit, so hiding the surfaced tip hides the root (and vice-versa) no
matter which id the caller holds. Returns True when at least one row
changed.
"""
def _do(conn):
cursor = conn.execute(
"""
WITH RECURSIVE
ancestors(id) AS (
SELECT ?
UNION
SELECT parent.id
FROM ancestors a
JOIN sessions child ON child.id = a.id
JOIN sessions parent ON parent.id = child.parent_session_id
WHERE parent.end_reason = 'compression'
),
descendants(id) AS (
SELECT ?
UNION
SELECT child.id
FROM descendants d
JOIN sessions parent ON parent.id = d.id
JOIN sessions child ON child.parent_session_id = parent.id
WHERE parent.end_reason = 'compression'
),
lineage(id) AS (
SELECT id FROM ancestors
UNION
SELECT id FROM descendants
)
UPDATE sessions
SET hidden = ?
WHERE id IN (SELECT id FROM lineage)
""",
(session_id, session_id, 1 if hidden else 0),
)
rowcount = cursor.rowcount
if rowcount is None or rowcount < 0:
rowcount = conn.execute("SELECT changes()").fetchone()[0]
return rowcount
rowcount = self._execute_write(_do)
return rowcount > 0
def set_session_read(self, session_id: str, read: bool = True) -> bool:
"""Mark a session read or unread (and its whole compression lineage).
@@ -7870,6 +7924,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
compact_rows: bool = False,
include_pinned: bool = False,
session_key: str = None,
include_hidden: bool = False,
) -> List[Dict[str, Any]]:
"""List sessions with preview (first user message) and last active timestamp.
@@ -7972,6 +8027,8 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
where_clauses.append("s.archived = 1")
elif not include_archived:
where_clauses.append("s.archived = 0")
if not include_hidden:
where_clauses.append("s.hidden = 0")
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
# Snapshot the filter params before the query builders below extend
+1
View File
@@ -310,6 +310,7 @@ CREATE TABLE IF NOT EXISTS sessions (
rewind_count INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
hidden INTEGER NOT NULL DEFAULT 0,
last_read_at REAL,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id),
FOREIGN KEY (system_prompt_hash) REFERENCES system_prompts(hash)
@@ -324,10 +324,10 @@ def _make_synthetic_lost_and_found(
]
finally:
schema.close()
assert len(sessions_columns) == 54
assert len(sessions_columns) == 55
assert len(usage_columns) == 18
max_fields = 54
max_fields = 55
conn = sqlite3.connect(str(path), isolation_level=None)
try:
cells = ", ".join(f"c{i}" for i in range(max_fields))
@@ -354,8 +354,8 @@ def _make_synthetic_lost_and_found(
}
return [base.get(column) for column in sessions_columns[:ncols]]
# Current 54-column layout and historical 52-column layout.
insert(54, 1, session_row("20260101_010101_aaa001", 54))
# Current 55-column layout and historical 52-column layout.
insert(55, 1, session_row("20260101_010101_aaa001", 55))
insert(52, 2, session_row("20260202_020202_bbb002", 52))
# 14-column legacy layout: identity + a plausible epoch timestamp.
legacy = ["20250303_030303_ccc003", "cli", 1_741_000_000.0] + [None] * 11
+44
View File
@@ -0,0 +1,44 @@
import pytest
from hermes_state import SessionDB
@pytest.fixture
def db(tmp_path):
database = SessionDB(tmp_path / "state.db")
try:
yield database
finally:
database.close()
def test_hidden_excluded_by_default_included_on_request(db):
db.create_session("visible", source="cli")
db.create_session("secret", source="cli")
# Give both a message so the default min_message_count filter keeps them.
for sid in ("visible", "secret"):
db._conn.execute(
"UPDATE sessions SET message_count = 1 WHERE id = ?", (sid,)
)
db._conn.commit()
# Flip the hidden flag on one session.
assert db.set_session_hidden("secret", True) is True
assert db.get_session("secret")["hidden"] == 1
assert db.get_session("visible")["hidden"] == 0
# Default listing drops the hidden row; include_hidden=True surfaces it.
default_ids = {s["id"] for s in db.list_sessions_rich(min_message_count=1)}
assert default_ids == {"visible"}
all_ids = {
s["id"]
for s in db.list_sessions_rich(min_message_count=1, include_hidden=True)
}
assert all_ids == {"visible", "secret"}
# Unhiding brings it back into the default listing.
assert db.set_session_hidden("secret", False) is True
assert db.get_session("secret")["hidden"] == 0
unhidden_ids = {s["id"] for s in db.list_sessions_rich(min_message_count=1)}
assert unhidden_ids == {"visible", "secret"}
+32
View File
@@ -98,6 +98,7 @@ def _(rid, params: dict) -> dict:
"create_service_tier_override": create_service_tier_override,
"parent_session_id": parent_session_id,
"pending_title": title or None,
"pending_hidden": is_truthy_value(params.get("hidden", False)),
"profile_home": str(profile_home) if profile_home is not None else None,
"running": False,
"session_key": key,
@@ -1105,6 +1106,37 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5007, str(e))
@method("session.set_hidden")
def _(rid, params: dict) -> dict:
"""Set/clear the generic ``hidden`` flag on a session (and its lineage).
Mirrors the durable ``pinned``/``archived`` setters: a hidden session is
dropped from the default global Sessions list (``list_sessions_rich``
without ``include_hidden``) but stays fully resumable by the surface that
owns it — for plugins that manage their own sessions and don't want them
cluttering the shared recents list. Flips the whole compression chain as a
unit in the DB layer.
"""
session, err = _sess_nowait(params, rid)
if err:
return err
hidden = is_truthy_value(params.get("hidden", True))
with _session_db(session) as db:
if db is None:
return _db_unavailable_error(rid, code=5007)
key = session["session_key"]
try:
changed = db.set_session_hidden(key, hidden)
if not changed:
# No row yet (write deferred to the first prompt): remember the
# intent so _ensure_session_db_row is born hidden, mirroring the
# pending_title deferral.
session["pending_hidden"] = hidden
return _ok(rid, {"hidden": hidden, "session_key": key})
except Exception as e:
return _err(rid, 5007, str(e))
@method("message.react")
def _(rid, params: dict) -> dict:
"""Set or clear one author's emoji reaction on a persisted message.
+8
View File
@@ -2976,6 +2976,14 @@ def _ensure_session_db_row(session: dict) -> None:
# means the launch/default profile (matches run_agent's convention).
profile_name=Path(profile_home).name if profile_home else None,
)
# A session can be born hidden (session.create hidden=true, or a
# session.set_hidden that arrived before the row existed): apply the
# deferred intent now that the row exists, mirroring pending_title.
if session.get("pending_hidden"):
try:
db.set_session_hidden(key, True)
except Exception:
logger.debug("failed to apply pending hidden flag", exc_info=True)
except Exception as exc:
# Disk-full is not a soft failure: if we swallow it here, prompt.submit
# returns {"status":"streaming"} and the user's message vanishes with