fix(state): renaming a bot's canonical Bot Chat is refused — the title IS the identity (#92473)

Bot Mode resolves the forever-chat by exact-title lookup on
(profile, 'Bot Chat'); no session-id pointer exists. A user rename
therefore orphaned the whole conversation: resolution missed, the next
click minted an empty replacement, and UNIQUE(title) then blocked ever
renaming back. Refuse the rename at SessionDB._set_session_title — the
single write path every surface funnels through (gateway session.title,
/title, CLI rename, REST). Hidden discriminates the registry row, so a
normal visible session a user happens to call 'Bot Chat' stays freely
renameable; re-asserting the same canonical title stays a no-op.
This commit is contained in:
Teknium
2026-08-26 01:32:23 -07:00
parent a74ffb9ee8
commit ad7b7255ab
2 changed files with 99 additions and 1 deletions
+28 -1
View File
@@ -9096,6 +9096,13 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
TITLE_SOURCE_USER: 2,
}
# Bot Mode's forever-chat registry: the session titled exactly this, on a
# bot's profile, IS the bot's canonical chat — resolved by exact-title
# lookup on every open (no session-id pointer exists). The title is the
# identity, which is why _set_session_title refuses user renames of a
# hidden row holding it (#92473).
CANONICAL_BOT_CHAT_TITLE = "Bot Chat"
@classmethod
def _title_rank(cls, source: Optional[str]) -> int:
"""Rank a stored title_source. NULL means a pre-provenance row.
@@ -9222,11 +9229,31 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
def _do(conn):
current = conn.execute(
"SELECT title, title_source FROM sessions WHERE id = ?",
"SELECT title, title_source, hidden FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if current is None:
return 0
# The canonical Bot Chat's NAME is its identity: Bot Mode resolves
# the forever-chat by exact-title lookup on every open, so renaming
# the row orphans the entire conversation — the next click mints an
# empty replacement and UNIQUE(title) then blocks ever renaming
# back (#92473). Refuse the rename at the single write path every
# surface funnels through (gateway session.title, /title, CLI
# rename, REST). Hidden is the discriminator: canonical chats are
# born hidden; an ordinary visible session a user happens to call
# "Bot Chat" stays freely renameable.
if (
is_user
and (current["title"] or "") == self.CANONICAL_BOT_CHAT_TITLE
and bool(current["hidden"])
and title != self.CANONICAL_BOT_CHAT_TITLE
):
raise ValueError(
"This is the bot's canonical Bot Chat — its name is its "
"identity, and renaming it would orphan the conversation. "
"To start fresh, create a new bot instead."
)
if not is_user and current["title"] is not None:
if self._title_rank(current["title_source"]) >= new_rank:
return 0
@@ -0,0 +1,71 @@
"""The canonical Bot Chat's title is its identity — renames must be refused.
Bot Mode resolves a bot's forever-chat by exact-title lookup on
(profile, "Bot Chat") every time it opens; there is no session-id pointer.
A user rename therefore orphans the whole conversation: resolution misses,
the next click mints an empty replacement, and UNIQUE(title) then blocks
renaming the original back (#92473).
The guard lives in SessionDB._set_session_title — the single write path
every rename surface funnels through (gateway session.title RPC, /title,
CLI rename, REST) — and keys on hidden + exact canonical title so ordinary
sessions a user happens to call "Bot Chat" stay freely renameable.
"""
import pytest
from hermes_state import SessionDB
@pytest.fixture
def db(tmp_path):
return SessionDB(tmp_path / "state.db")
def _make_canonical(db, session_id="forever"):
db.create_session(session_id, source="desktop")
assert db.set_session_title(session_id, SessionDB.CANONICAL_BOT_CHAT_TITLE)
assert db.set_session_hidden(session_id, True)
return session_id
def test_user_rename_of_canonical_bot_chat_is_refused(db):
sid = _make_canonical(db)
with pytest.raises(ValueError, match="canonical Bot Chat"):
db.set_session_title(sid, "My cool chat")
# Identity intact: exact-title lookup still finds the forever chat.
row = db.get_session_by_title(SessionDB.CANONICAL_BOT_CHAT_TITLE)
assert row and row["id"] == sid
def test_clearing_the_canonical_title_is_refused(db):
sid = _make_canonical(db)
with pytest.raises(ValueError, match="canonical Bot Chat"):
db.set_session_title(sid, "")
row = db.get_session_by_title(SessionDB.CANONICAL_BOT_CHAT_TITLE)
assert row and row["id"] == sid
def test_rewriting_the_same_canonical_title_is_a_noop_not_an_error(db):
# The plugin's eager session.title write re-asserts the canonical title
# on creation paths; that must never start failing.
sid = _make_canonical(db)
assert db.set_session_title(sid, SessionDB.CANONICAL_BOT_CHAT_TITLE)
def test_visible_session_titled_bot_chat_stays_renameable(db):
# hidden discriminates the registry row: a normal visible session the
# user happened to name "Bot Chat" is not canonical and renames freely.
db.create_session("ordinary", source="cli")
assert db.set_session_title("ordinary", SessionDB.CANONICAL_BOT_CHAT_TITLE)
assert db.set_session_title("ordinary", "renamed away")
assert db.get_session("ordinary")["title"] == "renamed away"
def test_auto_titler_still_cannot_touch_the_canonical_row(db):
# Pre-existing provenance contract, re-pinned here: user-authority title
# outranks derived/llm, so the turn-start auto-titler can never displace
# the registry name.
sid = _make_canonical(db)
assert not db.set_auto_title(sid, "Chat about groceries", source=SessionDB.TITLE_SOURCE_LLM)
row = db.get_session_by_title(SessionDB.CANONICAL_BOT_CHAT_TITLE)
assert row and row["id"] == sid