From 849aba1c8a976eef44c0b3600b44ce38d51fda74 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 17 Jun 2026 05:11:45 +0000 Subject: [PATCH] =?UTF-8?q?Release=20v0.51.474=20=E2=80=94=20reconcile=20W?= =?UTF-8?q?ebUI=20session=20detail=20source=20flags=20(#4348)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 6 ++ api/routes.py | 44 ++++++++++++- static/sessions.js | 15 ++++- ...est_issue2351_cli_session_source_filter.py | 61 +++++++++++++++++++ tests/test_webui_external_refresh_frontend.py | 10 +++ 5 files changed, 134 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f08c3a0..09b875087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/routes.py b/api/routes.py index 05185107b..1cdc80202 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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 diff --git a/static/sessions.js b/static/sessions.js index 0a2b7f6fb..7ea6ea946 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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) { diff --git a/tests/test_issue2351_cli_session_source_filter.py b/tests/test_issue2351_cli_session_source_filter.py index e78df8b87..a8dca6f85 100644 --- a/tests/test_issue2351_cli_session_source_filter.py +++ b/tests/test_issue2351_cli_session_source_filter.py @@ -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 diff --git a/tests/test_webui_external_refresh_frontend.py b/tests/test_webui_external_refresh_frontend.py index d9d27a58b..92b47e86e 100644 --- a/tests/test_webui_external_refresh_frontend.py +++ b/tests/test_webui_external_refresh_frontend.py @@ -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():