From 91ceb609962fe83d996c3a4d9c996c2d617035ed Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 12 Aug 2026 18:35:09 -0300 Subject: [PATCH 1/2] fix(sessions): dedup title on collision instead of leaving blank (#6964) --- api/state_sync.py | 15 ++++- tests/test_issue6964_title_dedup.py | 95 +++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue6964_title_dedup.py diff --git a/api/state_sync.py b/api/state_sync.py index caf03da0..26225a56 100644 --- a/api/state_sync.py +++ b/api/state_sync.py @@ -210,6 +210,10 @@ def sync_session_title(session_id: str, title: str, profile: Optional[str] = Non title refreshes (where state.db already holds the initial auto-title) are effectively no-ops at the state.db layer -- acceptable because the primary goal is ensuring ``hermes sessions list`` is not blank. + + On a title collision (two sessions with the same auto-title), the title is + de-duplicated via ``get_next_title_in_lineage`` (e.g. "My Session" -> + "My Session #2") and retried, so the second session is never left blank. """ if not title: return @@ -219,7 +223,16 @@ def sync_session_title(session_id: str, title: str, profile: Optional[str] = Non try: # Ensure the session row exists (idempotent) so the UPDATE has a target. db.ensure_session(session_id=session_id, source='webui') - db.set_auto_title_if_empty(session_id, title) + try: + db.set_auto_title_if_empty(session_id, title) + except ValueError: + # state.db enforces uniqueness on sessions.title, so a byte-identical + # auto-title generated for two sessions raises ValueError here. Derive + # a de-duplicated variant (e.g. "My Session" -> "My Session #2") and + # retry instead of leaving the second row blank (#6964). + alt = db.get_next_title_in_lineage(title) + if alt and alt != title: + db.set_auto_title_if_empty(session_id, alt) except Exception: logger.debug("Failed to sync session title to state.db for %s", session_id) finally: diff --git a/tests/test_issue6964_title_dedup.py b/tests/test_issue6964_title_dedup.py new file mode 100644 index 00000000..5e3f5525 --- /dev/null +++ b/tests/test_issue6964_title_dedup.py @@ -0,0 +1,95 @@ +"""Regression tests for state.db title de-dup on collision (#6964). + +Follow-up to #6892 (sync auto-generated session titles to state.db). +When two WebUI sessions generate the SAME auto-title, hermes-agent's +state.db uniqueness rule makes the second ``set_auto_title_if_empty`` +raise ValueError, which ``sync_session_title`` used to swallow at debug +level -- leaving the second row blank in ``hermes sessions list``. +These tests prove the collision path now de-duplicates (suffix variant) +instead of leaving the row untitled, and that unique titles are unchanged. +""" + +import pytest + + +def _make_db(tmp_path): + hermes_state = pytest.importorskip("hermes_state") + SessionDB = hermes_state.SessionDB + return SessionDB(db_path=tmp_path / "state.db") + + +@pytest.mark.requires_agent_modules +def test_sync_session_title_dedups_second_session_on_collision(tmp_path, monkeypatch): + """Two sessions with the same auto-title: the 2nd gets a de-duplicated + variant instead of being left blank in state.db.""" + from api import state_sync + + db = _make_db(tmp_path) + try: + # Route sync_session_title at the real temp DB (a fresh handle each call, + # mirroring production, but pointed at our tmp state.db). + monkeypatch.setattr( + state_sync, + "_get_state_db", + lambda profile=None: _make_db(tmp_path), + ) + + state_sync.sync_session_title("sess-1", "Same Title", profile="default") + state_sync.sync_session_title("sess-2", "Same Title", profile="default") + + t1 = db.get_session_title("sess-1") + t2 = db.get_session_title("sess-2") + assert t1 == "Same Title" + # De-duplicated with a lineage suffix -- never left blank. + assert t2 == "Same Title #2" + assert t1 != t2 + finally: + db.close() + + +@pytest.mark.requires_agent_modules +def test_sync_session_title_dedup_handles_numbered_base(tmp_path, monkeypatch): + """A colliding title that already carries a '#N' suffix keeps incrementing + deterministically (e.g. 'Same Title #2' -> 'Same Title #3').""" + from api import state_sync + + db = _make_db(tmp_path) + try: + monkeypatch.setattr( + state_sync, + "_get_state_db", + lambda profile=None: _make_db(tmp_path), + ) + + state_sync.sync_session_title("sess-1", "Same Title #2", profile="default") + state_sync.sync_session_title("sess-2", "Same Title #2", profile="default") + + t1 = db.get_session_title("sess-1") + t2 = db.get_session_title("sess-2") + assert t1 == "Same Title #2" + assert t2 == "Same Title #3" + assert t1 != t2 + finally: + db.close() + + +@pytest.mark.requires_agent_modules +def test_sync_session_title_unique_titles_unchanged(tmp_path, monkeypatch): + """Non-colliding titles keep their exact value (no suffix added).""" + from api import state_sync + + db = _make_db(tmp_path) + try: + monkeypatch.setattr( + state_sync, + "_get_state_db", + lambda profile=None: _make_db(tmp_path), + ) + + state_sync.sync_session_title("sess-a", "Alpha Title", profile="default") + state_sync.sync_session_title("sess-b", "Beta Title", profile="default") + + assert db.get_session_title("sess-a") == "Alpha Title" + assert db.get_session_title("sess-b") == "Beta Title" + finally: + db.close() From fac34706683bed04d2a94e691ffea3ccc326ea10 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 13 Aug 2026 00:39:07 +0000 Subject: [PATCH 2/2] docs(changelog): stamp #6964 title-dedup + add manual-rename-during-retry test Codex SHOULD-FIX: add a committed test proving the de-dup retry variant never clobbers a user-source manual rename (retry goes through the same provenance rank guard). Additive test-only; no behavior change to the contributor's fix. Co-authored-by: webtecnica --- CHANGELOG.md | 1 + tests/test_issue6964_title_dedup.py | 40 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 375c86e7..35ec1c2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed +- **Two WebUI sessions with the same auto-generated title no longer leave the second one blank.** Follow-up to the `state.db` title sync: hermes-agent enforces a uniqueness rule on session titles, so when two sessions independently generated a byte-identical auto-title, the second sync raised (and silently swallowed) a collision error and left that session untitled in `hermes sessions list`. The sync now catches the collision, derives a de-duplicated variant via the existing lineage helper (`"My Session"` → `"My Session #2"`), and retries — and because the retry goes through the same provenance guard, a title you set manually is still never overwritten. Thanks @webtecnica. (#6964) - **Custom-provider models keep their exact identity across catalog refreshes.** When a session used a model served through a custom provider whose upstream id contains a namespace (e.g. `z-ai/glm-5.2` under a `tokenrouter` custom endpoint), a catalog refresh could mis-resolve the picker selection — matching a *different* model whose bare suffix collided, or (in an interim fix) fabricating a new bare-id option instead of re-selecting the real one. Model resolution now matches the exact routed id first, and only falls back to a provider-hinted bare-id repair (preserving the older #6195 behavior for legacy sessions that stored just the suffix) when there is exactly one unambiguous candidate — otherwise it declines to guess. The resolved value is always an existing dropdown option, so re-renders no longer inject duplicates or lose the selection. Thanks @josephcy95. (#6944) - **WebUI no longer crashes a send when running against an older hermes-agent.** The streaming path passed `persist_user_timestamp=` to `agent.run_conversation()` unconditionally, so a WebUI paired with an older agent whose `run_conversation()` predates that keyword raised `TypeError: unexpected keyword argument` and the message failed. The three call sites now check the callable's signature first (via a small `_supports_kwarg` introspection helper) and only pass the timestamp when the agent accepts it — the same defensive pattern already used for `moa_config`. Modern agents are unaffected (the timestamp is still passed with the identical value); older agents degrade gracefully instead of erroring. Thanks @harsh2hell. (#6935) - **`hermes sessions list` no longer shows blank titles for WebUI sessions.** Background title generation wrote the auto-title to the WebUI session sidecar but never to hermes-agent's `state.db`, so the CLI/TUI session list showed those WebUI sessions untitled. The title is now bridged into `state.db` after it's generated (and on adaptive refresh) via `set_auto_title_if_empty`, which respects title provenance — a name you set manually via CLI/Gateway/TUI (`user` source) is never overwritten by the auto-title, an already-auto-titled row stays put, and an existing session's `source` is preserved (the sync creates the row with INSERT-OR-IGNORE semantics). Thanks @liuguangyong93. (#6892) diff --git a/tests/test_issue6964_title_dedup.py b/tests/test_issue6964_title_dedup.py index 5e3f5525..63d190cf 100644 --- a/tests/test_issue6964_title_dedup.py +++ b/tests/test_issue6964_title_dedup.py @@ -93,3 +93,43 @@ def test_sync_session_title_unique_titles_unchanged(tmp_path, monkeypatch): assert db.get_session_title("sess-b") == "Beta Title" finally: db.close() + + +@pytest.mark.requires_agent_modules +def test_sync_session_title_dedup_retry_never_clobbers_manual_rename(tmp_path, monkeypatch): + """The de-dup retry still respects title provenance: if the second session + was manually renamed (``user`` source) before the colliding auto-title sync + runs, the retry with the ``#2`` variant must NOT overwrite the user's name. + + Codex #6966 SHOULD-FIX: covers the manual-rename-during-retry interleaving + (the existing tests cover manual-rename-before-sync only indirectly).""" + from api import state_sync + + db = _make_db(tmp_path) + try: + monkeypatch.setattr( + state_sync, + "_get_state_db", + lambda profile=None: _make_db(tmp_path), + ) + + # sess-1 takes "Same Title" via the normal auto-title sync. + state_sync.sync_session_title("sess-1", "Same Title", profile="default") + + # sess-2 is manually renamed by the user (higher-ranked `user` source) + # BEFORE the colliding auto-title sync fires for it. + db.ensure_session(session_id="sess-2", source="webui") + db.set_session_title("sess-2", "My Own Name") + + # Now the colliding auto-title sync runs for sess-2. It collides on + # "Same Title", derives "Same Title #2", and retries -- but the retry + # goes through the same provenance rank guard, so the user's manual + # name must survive. + state_sync.sync_session_title("sess-2", "Same Title", profile="default") + + assert db.get_session_title("sess-1") == "Same Title" + # The manual (user-source) rename is preserved -- never clobbered by + # either the initial auto-title or the de-dup retry variant. + assert db.get_session_title("sess-2") == "My Own Name" + finally: + db.close()