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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user