From bee7b8a9e43e012615f1f1c3b0c13d677636cde2 Mon Sep 17 00:00:00 2001 From: n Date: Mon, 7 Sep 2026 22:56:13 +0000 Subject: [PATCH] fix(state-sync): repoint to renamed agent SessionDB API + fix agent-coupled tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Hermes Agent's state-module refactor (agent commit 53db597201, released v2026.9.7) renamed SessionDB.set_auto_title_if_empty -> set_auto_title( session_id, title, *, source) and (agent commit 14791b4d4e) dropped the tools.approval._ApprovalEntry back-compat facade (class moved, unchanged, to tools.approval_gateway_wait). Production impact (real, silent): api.state_sync.sync_session_title called the removed set_auto_title_if_empty, whose AttributeError was swallowed by the surrounding `except Exception: logger.debug(...)`, so WebUI auto-generated session titles stopped persisting to state.db on agent >= v2026.9.7 — hermes sessions list went blank again (the exact #6892 regression). Now calls set_auto_title with LLM provenance, which preserves the identical semantics (only fills NULL/lower-authority title, returns False untouched on a manual rename, ValueError -> get_next_title_in_lineage #N de-dup). Test fixes (agent-coupled; skip on GitHub CI which installs no agent, so they only surface on an agent-equipped box like our gate): 1. tools.approval._ApprovalEntry -> idempotent autouse conftest compat shim backfilling the class from its post-split location only when the installed agent lacks the dropped facade. No production code imports it. 2. test_goal_command_webui::test_profile_goal_falls_back_when_session_db_path_is_frozen was missing the monkeypatch.syspath_prepend(...) that every sibling test in the file has, so its bare `import hermes_state` failed under full-suite ordering after a predecessor stripped the agent dir from sys.path. Added it. Verified: authoritative 5-shard CI-equivalent split all green (0 failed); each failure proven pre-existing on clean master (not this diff). --- CHANGELOG.md | 2 ++ api/state_sync.py | 19 ++++++++++++----- tests/conftest.py | 35 ++++++++++++++++++++++++++++++++ tests/test_goal_command_webui.py | 7 +++++++ 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f0e4522..f9b89e59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ - **Docker: secrets ending in `=` (like a base64 shared secret or password) are no longer truncated when the container reloads its environment after dropping privileges, so shared-secret auth and `HERMES_WEBUI_PASSWORD` matching keep working.** `docker_init.bash`'s `load_env()` reloaded the saved env snapshot with `while IFS='=' read -r key value`, and Bash silently drops a single trailing `=` from the last field — so any value ending in one `=` (e.g. `openssl rand -base64 32` output, which is 44 chars ending in `=`) came back one character short. The reload now reads each line whole and splits at the first `=` only, preserving embedded and trailing `=`; blank/comment lines are skipped and lines without `=` keep their prior empty-value behavior. Thanks @webtecnica. (#7433, closes #7432) +- **WebUI session titles are written to `state.db` again on the latest Hermes Agent, so `hermes sessions list` isn't blank for WebUI sessions.** The agent's `SessionDB.set_auto_title_if_empty` was renamed to `set_auto_title(session_id, title, *, source)` in a state-module refactor (released in agent v2026.9.7); the WebUI's background title sync still called the old name, whose `AttributeError` was silently swallowed — so auto-generated titles stopped persisting. The sync now calls `set_auto_title` with LLM provenance, preserving the exact prior behavior (only fills a NULL/lower-authority title, never clobbers a manual rename, de-duplicates colliding titles with a `#N` lineage suffix). Also repoints the gateway-approval test suite's `tools.approval._ApprovalEntry` reference to its post-split location (`tools.approval_gateway_wait`) via a test-only compatibility shim, so the agent-coupled tests run green across both the pre- and post-split agent. + - **Regenerating a response no longer refetches the entire conversation, so regenerate on a long chat is fast instead of stalling (and silently cancelling).** Clicking "regenerate" used to pull the whole transcript back from the server before it could rebuild the request, which on a large session caused a long UI freeze and could time out and silently drop the regeneration. The regeneration authority now reads only a bounded, sidecar-anchored tail of recent turns instead of the full transcript — and it is exact by construction: it takes the tail's prefix proof, keys, and rows from a single WAL-consistent database snapshot (a `data_version` check forces a full read if the database is written mid-snapshot), reuses the canonical projection and durable ordering so the bounded rows match the full reader byte-for-byte, and falls back to a full read whenever the skipped prefix isn't provably identical or the tail contains a duplicated turn key. If any of those invariants can't be met it reads the whole transcript exactly as before, so a concurrent edit or an unusual session can never produce a stale or reordered regeneration. Thanks @webtecnica. (#7204, closes #6826) - **Loading the session sidebar is faster on installs with many delegated-subagent sessions — the subagent classification no longer runs a separate database probe per row.** Building `/api/sessions` used to open a `state.db` connection for every session that might be a delegated child, an N+1 pattern that grew with session count. The classification now reads the authoritative `state.db` source for all candidate rows through the existing batched overlay (one read-only connection, chunked 500-ID `IN` queries), and a rich-read failure recovers the remaining IDs in a single bounded source-only pass rather than falling back to per-row queries. Behavior is unchanged — a delegated child whose index row still says `webui`/`fork` while its `state.db` source is `subagent` is classified read-only exactly as before. Thanks @starship-s. (#7231) diff --git a/api/state_sync.py b/api/state_sync.py index 26225a56..0dd33566 100644 --- a/api/state_sync.py +++ b/api/state_sync.py @@ -205,9 +205,11 @@ def sync_session_title(session_id: str, title: str, profile: Optional[str] = Non titles for WebUI sessions. This function bridges that gap and is called from the background title update/refresh paths after a title is persisted. - Uses ``set_auto_title_if_empty`` so it will only populate a NULL title and - never overwrite a manual rename made via CLI/Gateway/TUI. This means - title refreshes (where state.db already holds the initial auto-title) are + Uses ``set_auto_title`` (LLM provenance) so it will only populate a row that + is NULL or holds a lower-authority auto-title, and never overwrites a manual + rename made via CLI/Gateway/TUI (``set_auto_title`` returns ``False``, + untouched, when a higher-authority title holds the row). This means 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. @@ -223,8 +225,15 @@ 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') + # hermes-agent's SessionDB.set_auto_title_if_empty was renamed to + # set_auto_title(session_id, title, *, source) in the state-module + # split (agent commit 53db597201, released v2026.9.7). set_auto_title + # preserves the same "only populate NULL / never clobber a manual + # rename" semantics (returns False, untouched, when a higher-authority + # title holds the row) and requires an explicit auto source. + _llm_source = getattr(db, "TITLE_SOURCE_LLM", "llm") try: - db.set_auto_title_if_empty(session_id, title) + db.set_auto_title(session_id, title, source=_llm_source) except ValueError: # state.db enforces uniqueness on sessions.title, so a byte-identical # auto-title generated for two sessions raises ValueError here. Derive @@ -232,7 +241,7 @@ def sync_session_title(session_id: str, title: str, profile: Optional[str] = Non # 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) + db.set_auto_title(session_id, alt, source=_llm_source) except Exception: logger.debug("Failed to sync session title to state.db for %s", session_id) finally: diff --git a/tests/conftest.py b/tests/conftest.py index d28b4099..f11dba34 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1246,6 +1246,41 @@ def _restore_hermes_cli_module(): sys.path[:] = _REAL_SYS_PATH +# ── hermes-agent API-drift compat: tools.approval._ApprovalEntry ───────────── +# The core agent (commit 14791b4d4e, released in v2026.9.7) split tools/approval.py +# into smart/human-wait/gateway-wait modules and dropped 43 back-compat facade +# re-exports — including the module-level ``tools.approval._ApprovalEntry`` alias. +# The class itself still exists, unchanged, at ``tools.approval_gateway_wait._ApprovalEntry`` +# (identical ``__init__(self, data)`` contract: threading.Event + data dict + an +# auto-stamped request_id). Several webui gateway-approval tests reference +# ``_ApprovalEntry`` through ``tools.approval`` (its pre-split public location): +# on a box with the NEW agent installed the attribute is gone (AttributeError via +# the module's PEP-562 ``__getattr__``); on an OLD agent it is still present. No +# PRODUCTION webui code imports the dropped facade (verified: api/route_approvals.py +# and api/streaming.py import only surviving names), so this is purely test-side +# coupling to a moved internal symbol. +# +# This runs as an AUTOUSE fixture (not at conftest import time) because the agent +# dir is only added to sys.path by the earlier autouse guards / server fixture — +# ``tools.approval`` is not importable at conftest module load. The backfill is +# idempotent and only aliases the class when the installed agent lacks it, using +# the exact class object the agent uses internally. It keeps the tests working +# across both the pre-split and post-split agent without touching ~22 call sites, +# and is a no-op when tools.approval isn't importable (agent absent → tests skip). +@pytest.fixture(autouse=True) +def _backfill_approval_entry_facade(): + try: + import tools.approval as _approval_mod + except Exception: + return # agent not importable here → agent-dependent tests skip anyway + if getattr(_approval_mod, "_ApprovalEntry", None) is None: + try: + from tools.approval_gateway_wait import _ApprovalEntry as _entry_cls + except Exception: + return # unexpected layout → let tests skip/fail loudly, don't fake it + _approval_mod._ApprovalEntry = _entry_cls + + # ── Per-test session cleanup ────────────────────────────────────────────────── @pytest.fixture(autouse=True) diff --git a/tests/test_goal_command_webui.py b/tests/test_goal_command_webui.py index 57033f28..a049b27d 100644 --- a/tests/test_goal_command_webui.py +++ b/tests/test_goal_command_webui.py @@ -249,6 +249,13 @@ def test_profile_goal_falls_back_when_session_db_path_is_frozen(monkeypatch, tmp """A context API alone cannot make an import-time SessionDB path profile-safe.""" from api import goals as webui_goals native_goals = pytest.importorskip("hermes_cli.goals", reason="hermes-agent not installed") + # Ensure the agent dir is on sys.path before importing the sibling top-level + # `hermes_state` module. importorskip("hermes_cli.goals") can succeed from a + # cached sys.modules entry even after a prior shard test stripped the agent + # dir from sys.path, but the bare `import hermes_state` below is uncached and + # would then fail with ModuleNotFoundError under full-suite ordering (every + # other test in this file already does this prepend). + monkeypatch.syspath_prepend(str(Path(native_goals.__file__).resolve().parents[1])) import hermes_state frozen_db_path = tmp_path / "frozen-home" / "state.db"