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:
+28
-1
@@ -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
|
||||
Reference in New Issue
Block a user