Merge pull request #6969 from nesquena/release/stage-6966-1786581547

Release: de-dup session title on collision (#6964)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-08-12 17:44:22 -07:00
commit f3e0e29815
3 files changed
+150 -1

No files matched your search

+1
View File
@@ -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)
+14 -1
View File
@@ -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:
+135
View File
@@ -0,0 +1,135 @@
"""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()
@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()