diff --git a/hermes_state.py b/hermes_state.py index 2850544909..e5e35523fd 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -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 diff --git a/tests/hermes_state/test_canonical_title_guard.py b/tests/hermes_state/test_canonical_title_guard.py new file mode 100644 index 0000000000..b315ff785d --- /dev/null +++ b/tests/hermes_state/test_canonical_title_guard.py @@ -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