From bca7cd0eb0a81242fec7aa3d31a0bcb19fa9b643 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:01:26 -0700 Subject: [PATCH] fix(state): projected compression tip inherits the root's title when the tip is untitled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hermes peer dm` resolves the target's canonical Bot Chat with GET /api/sessions?title=Bot%20Chat&include_hidden=1. list_sessions_rich admits the hidden root via the chain search, then _project_compression_tips overwrites every surfaced field — title included — with the live tip's. The title is carried root->tip by the agent AFTER publish_compression_child's transaction; a rotation cut off in between (crash, closed app, the tip's title write failing) leaves "Bot Chat" on the ended root and NULL on the tip, so the projected row carries title=None, the handler's exact-title filter drops it, the peer POSTs a duplicate and the UNIQUE(title) guard answers 400 "Title already in use" (#106165). Fix at the projection: fall back to the root's title only when the tip has none (a titled tip keeps winning). Same COALESCE in the bounded recent- sessions lister, the other place that projects a lineage onto its tip. This replaces the handler-level fallback in PR #106365 (a second lookup path bolted onto _handle_list_sessions with try/except: pass) with a 5-line fix at the one place the title is lost, so every list consumer sees the name. Salvage of #106365 by @finn763. --- hermes_state_sessions.py | 7 ++++++- tests/test_hermes_state.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/hermes_state_sessions.py b/hermes_state_sessions.py index a7039236bc..82e59eded0 100644 --- a/hermes_state_sessions.py +++ b/hermes_state_sessions.py @@ -967,6 +967,11 @@ class SessionSessionsMixin: ): if key in tip_row: merged[key] = tip_row[key] + if merged.get("title") is None: + # The title is carried root->tip AFTER the publish transaction; a rotation cut off in + # between leaves it on the ended root, and exact-title lookups (`hermes peer dm` -> + # canonical "Bot Chat") must still see the lineage under its name (#106165). + merged["title"] = s.get("title") merged["_lineage_root_id"] = s["id"] merged["_lineage_ids"] = chain projected.append(merged) @@ -1089,7 +1094,7 @@ class SessionSessionsMixin: tip.id, tip.source, tip.model, - tip.title, + COALESCE(tip.title, s.title) AS title, s.started_at AS started_at, tip.ended_at, tip.end_reason, diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index bb676562b9..99e6438e9f 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -1708,6 +1708,21 @@ class TestSessionTitleLineage: # The unrelated holder keeps its title. assert db.get_session("a")["title"] == "shared" + def test_projected_tip_inherits_root_title_when_untitled(self, db): + """A rotation that ended the root before the title carry ran leaves the name on the + root only; the projected lineage row must still surface it (exact-title lookups such as + `hermes peer dm` -> canonical "Bot Chat", #106165). A titled tip keeps its own title.""" + import time as _time + self._make_compression_chain(db, _time.time() - 3600) + db.set_session_title("root", "Bot Chat") + + rows = db.list_sessions_rich(limit=50, order_by_last_active=True, search_query="Bot Chat") + assert [(r["id"], r["title"], r["_lineage_root_id"]) for r in rows] == [("tip", "Bot Chat", "root")] + + db.set_session_title("tip", "renamed tip") + rows = db.list_sessions_rich(limit=50, order_by_last_active=True) + assert [(r["id"], r["title"]) for r in rows] == [("tip", "renamed tip")] + class TestSanitizeTitle: