mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 12:40:18 +00:00
Release v0.51.474 — reconcile WebUI session detail source flags (#4348)
Ships @dso2ng's fix: /api/session now reconciles stale CLI source flags against the state.db WebUI-origin row, and _isExternalSession() refuses to classify an explicit WebUI-source session as external — so a WebUI-native transcript with a stale sidecar isn't force-reloaded like an imported one. Rebased onto master (contributor's net code-diff applied; byte-identical to PR head). CHANGELOG stamped v0.51.474. Co-authored-by: dso2ng <dso2ng@users.noreply.github.com>
This commit is contained in:
@@ -3,6 +3,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.474] — 2026-06-17 — Release QI (reconcile WebUI session source flags)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A WebUI-native session is no longer force-reloaded as if it were an imported CLI transcript (#4348).** When a WebUI-origin session carried a stale sidecar that still said `raw_source=cli` / `is_cli_session=true` (while state.db correctly recorded it as WebUI-origin), `/api/session` returned the stale flags and the browser opened it as an external session — entering the periodic external-refresh reload path that could rebuild the transcript while you were reading it. The detail endpoint now reconciles its source flags against the state.db row (the source of truth) before redaction, and the frontend `_isExternalSession()` refuses to classify an explicit WebUI-source session as external. Keeps the list and detail endpoints on one source-classification contract; imported CLI/messaging sessions are unaffected. Thanks @dso2ng.
|
||||
|
||||
## [v0.51.473] — 2026-06-17 — Release QH (clean cancelled user context chains)
|
||||
|
||||
### Fixed
|
||||
|
||||
+43
-1
@@ -4580,6 +4580,46 @@ def _normalize_sidebar_source_flags(session: dict) -> dict:
|
||||
return normalized
|
||||
|
||||
|
||||
def _reconcile_session_detail_source_flags(session: dict, state_meta: dict) -> dict:
|
||||
"""Return a /api/session payload whose source flags match state.db truth.
|
||||
|
||||
WebUI-origin sidecars can carry stale CLI/import flags after older repair or
|
||||
import paths touched the JSON file. The sidebar projection already trusts the
|
||||
state.db source row for those sessions; the detail endpoint must do the same
|
||||
or the frontend opens a WebUI-native transcript as an external session and
|
||||
starts the destructive active-refresh reload loop.
|
||||
"""
|
||||
if not isinstance(session, dict):
|
||||
return session
|
||||
if not _session_source_is_webui(state_meta):
|
||||
return dict(session)
|
||||
|
||||
reconciled = dict(session)
|
||||
reconciled["is_cli_session"] = False
|
||||
reconciled["read_only"] = False
|
||||
reconciled["source_tag"] = _safe_first(state_meta.get("source_tag"), "webui")
|
||||
reconciled["raw_source"] = _safe_first(state_meta.get("raw_source"), "webui")
|
||||
reconciled["session_source"] = _safe_first(state_meta.get("session_source"), "webui")
|
||||
reconciled["source_label"] = _safe_first(state_meta.get("source_label"), "WebUI")
|
||||
if state_meta.get("source"):
|
||||
reconciled["source"] = state_meta["source"]
|
||||
|
||||
for key in ("message_count", "actual_message_count"):
|
||||
if state_meta.get(key) is not None:
|
||||
reconciled[key] = max(
|
||||
_numeric_count(reconciled.get(key)),
|
||||
_numeric_count(state_meta.get(key)),
|
||||
)
|
||||
for key in ("created_at", "updated_at", "last_message_at"):
|
||||
if state_meta.get(key) is not None:
|
||||
current = reconciled.get(key)
|
||||
try:
|
||||
reconciled[key] = max(float(current or 0), float(state_meta.get(key) or 0))
|
||||
except (TypeError, ValueError):
|
||||
reconciled[key] = state_meta[key]
|
||||
return reconciled
|
||||
|
||||
|
||||
def _session_source_is_webui(session: dict) -> bool:
|
||||
"""Return True for state.db/sidebar rows that describe WebUI-origin sessions."""
|
||||
if not isinstance(session, dict):
|
||||
@@ -7205,7 +7245,9 @@ def handle_get(handler, parsed) -> bool:
|
||||
float(raw.get("updated_at") or 0),
|
||||
_merged_last_message_at,
|
||||
)
|
||||
if cli_meta and _is_messaging_session_record(cli_meta):
|
||||
if cli_meta and _session_source_is_webui(cli_meta):
|
||||
raw = _reconcile_session_detail_source_flags(raw, cli_meta)
|
||||
elif cli_meta and _is_messaging_session_record(cli_meta):
|
||||
raw = _merge_cli_sidebar_metadata(raw, cli_meta)
|
||||
# ``message_count`` in /api/session is the display coordinate
|
||||
# space used for pagination and the header badge. Messaging
|
||||
|
||||
+14
-1
@@ -1420,8 +1420,21 @@ function _isMessagingSession(session) {
|
||||
* before the WebUI can read or send messages into it.
|
||||
* Covers both legacy CLI sessions and messaging-source sessions.
|
||||
*/
|
||||
function _isWebUiSourceSession(session) {
|
||||
if (!session) return false;
|
||||
const source = (
|
||||
session.session_source
|
||||
|| session.raw_source
|
||||
|| session.source_tag
|
||||
|| session.source
|
||||
|| ''
|
||||
).toLowerCase();
|
||||
return source === 'webui';
|
||||
}
|
||||
|
||||
function _isExternalSession(session) {
|
||||
return !!(session && (session.is_cli_session || _isMessagingSession(session)));
|
||||
if (!session || _isWebUiSourceSession(session)) return false;
|
||||
return !!(session.is_cli_session || _isMessagingSession(session));
|
||||
}
|
||||
|
||||
function _externalImportPayload(session) {
|
||||
|
||||
@@ -110,6 +110,67 @@ def test_webui_source_overrides_stale_cli_flag_even_with_default_title():
|
||||
assert _normalize_sidebar_source_flags(stale_webui)["is_cli_session"] is False
|
||||
|
||||
|
||||
def test_webui_state_db_source_overrides_stale_cli_detail_payload():
|
||||
from api.routes import _reconcile_session_detail_source_flags
|
||||
|
||||
detail_payload = {
|
||||
"session_id": "webui-tip",
|
||||
"title": "Long WebUI session",
|
||||
"source_tag": "cli",
|
||||
"raw_source": "cli",
|
||||
"session_source": "cli",
|
||||
"source_label": "CLI",
|
||||
"is_cli_session": True,
|
||||
"read_only": True,
|
||||
"message_count": 24,
|
||||
}
|
||||
state_db_row = {
|
||||
"session_id": "webui-tip",
|
||||
"source_tag": "webui",
|
||||
"raw_source": "webui",
|
||||
"session_source": "webui",
|
||||
"source_label": "WebUI",
|
||||
"message_count": 26,
|
||||
}
|
||||
|
||||
reconciled = _reconcile_session_detail_source_flags(detail_payload, state_db_row)
|
||||
|
||||
assert reconciled["is_cli_session"] is False
|
||||
assert reconciled["read_only"] is False
|
||||
assert reconciled["source_tag"] == "webui"
|
||||
assert reconciled["raw_source"] == "webui"
|
||||
assert reconciled["session_source"] == "webui"
|
||||
assert reconciled["source_label"] == "WebUI"
|
||||
assert reconciled["message_count"] == 26
|
||||
|
||||
|
||||
def test_real_cli_source_survives_detail_source_reconcile():
|
||||
from api.routes import _reconcile_session_detail_source_flags
|
||||
|
||||
detail_payload = {
|
||||
"session_id": "cli-tip",
|
||||
"source_tag": "cli",
|
||||
"raw_source": "cli",
|
||||
"session_source": "cli",
|
||||
"source_label": "CLI",
|
||||
"is_cli_session": True,
|
||||
"read_only": True,
|
||||
}
|
||||
state_db_row = {
|
||||
"session_id": "cli-tip",
|
||||
"source_tag": "cli",
|
||||
"raw_source": "cli",
|
||||
"session_source": "cli",
|
||||
"source_label": "CLI",
|
||||
}
|
||||
|
||||
reconciled = _reconcile_session_detail_source_flags(detail_payload, state_db_row)
|
||||
|
||||
assert reconciled["is_cli_session"] is True
|
||||
assert reconciled["read_only"] is True
|
||||
assert reconciled["source_tag"] == "cli"
|
||||
|
||||
|
||||
def test_real_cli_sidebar_cli_flag_is_preserved_before_frontend_response():
|
||||
from api.routes import _normalize_sidebar_source_flags
|
||||
|
||||
|
||||
@@ -22,6 +22,16 @@ def test_active_session_external_refresh_uses_metadata_then_force_reload():
|
||||
assert "remoteCount > localCount || remoteLast > localLast" in SESSIONS_JS
|
||||
assert "if(S.busy || S.activeStreamId) return;" in SESSIONS_JS
|
||||
assert "document.hidden" in SESSIONS_JS
|
||||
assert "externalRefreshReason:reason||'poll'" in SESSIONS_JS
|
||||
|
||||
|
||||
def test_webui_source_never_counts_as_external_session():
|
||||
assert "function _isWebUiSourceSession(session)" in SESSIONS_JS
|
||||
assert "if (!session || _isWebUiSourceSession(session)) return false;" in SESSIONS_JS
|
||||
external_start = SESSIONS_JS.index("function _isExternalSession(session)")
|
||||
external_body = SESSIONS_JS[external_start : external_start + 300]
|
||||
assert "_isWebUiSourceSession(session)" in external_body
|
||||
assert "session.is_cli_session || _isMessagingSession(session)" in external_body
|
||||
|
||||
|
||||
def test_active_session_external_refresh_has_focus_and_visibility_hooks():
|
||||
|
||||
Reference in New Issue
Block a user