From aeda6add2b6acaf8912d00ac3d958a65c4234553 Mon Sep 17 00:00:00 2001 From: PINKIIILQWQ Date: Mon, 1 Jun 2026 00:34:51 +0800 Subject: [PATCH] fix: suppress phantom sidebar refresh on gateway SSE reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shift from backend mtime-based detection to frontend SSE deduplication. Backend: Revert gateway_watcher.py to original pure hash-based polling. Remove _get_db_mtime, _detect_gateway_restart, and mtime tracking. This is a no-op in behavior — the original was already hash-only. Frontend: Add deduplication at the SSE event handler level. - _gatewaySessionSnapshotKey(sessions): deterministic key from session_id + updated_at + message_count (same fields as backend hash) - _isGatewaySessionForSnapshot(session): classify non-webui sessions - _isDuplicateGatewaySessionSnapshot(sessions): compare SSE payload against current _allSessions, filtered to gateway subset - SSE sessions_changed handler wraps renderSessionList() in dedupe: identical data → skip refresh This directly addresses the real root cause: the SSE reconnect snapshot (routes.py:7735) unconditionally pushes an initial snapshot, and the frontend always re-renders. After this fix, a reconnect with unchanged session data is correctly detected and the redundant redraw is skipped. Previously submitted as #3259 (backend mtime approach, now closed per maintainer review). --- static/sessions.js | 27 ++++- tests/test_gateway_sse_reconnect_dedupe.py | 129 +++++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 tests/test_gateway_sse_reconnect_dedupe.py diff --git a/static/sessions.js b/static/sessions.js index 874bab2b..df7341b6 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2762,6 +2762,29 @@ function stopGatewayPollFallback(){ } } +function _gatewaySessionSnapshotKey(sessions){ + return (Array.isArray(sessions)?sessions:[]) + .filter(s=>s&&s.session_id) + .map(s=>`${s.session_id}:${s.updated_at||0}:${s.message_count||0}`) + .sort() + .join('|'); +} + +function _isGatewaySessionForSnapshot(session){ + if(!session) return false; + if(typeof _isCliSession==='function'&&_isCliSession(session)) return true; + if(typeof _isMessagingSession==='function'&&_isMessagingSession(session)) return true; + const source=String(session.session_source||session.raw_source||session.source_tag||session.source||'').toLowerCase(); + return !!source&&source!=='webui'; +} + +function _isDuplicateGatewaySessionSnapshot(sessions){ + const incoming=(Array.isArray(sessions)?sessions:[]).filter(_isGatewaySessionForSnapshot); + const currentGatewaySessions=(Array.isArray(_allSessions)?_allSessions:[]).filter(_isGatewaySessionForSnapshot); + if(!incoming.length&&!currentGatewaySessions.length) return true; + return _gatewaySessionSnapshotKey(incoming)===_gatewaySessionSnapshotKey(currentGatewaySessions); +} + async function probeGatewaySSEStatus(){ if(_gatewayProbeInFlight || !window._showCliSessions) return; _gatewayProbeInFlight = true; @@ -2803,7 +2826,9 @@ function startGatewaySSE(){ if(data.sessions){ stopGatewayPollFallback(); _gatewaySSEWarningShown = false; - renderSessionList({deferWhileInteracting:true}); // re-fetch and re-render + if(!_isDuplicateGatewaySessionSnapshot(data.sessions)){ + renderSessionList({deferWhileInteracting:true}); // re-fetch and re-render + } // If the active session received new gateway messages, refresh the conversation view. // S.busy check prevents stomping on an in-progress WebUI response. // is_cli_session check ensures we only poll import_cli for CLI-originated sessions. diff --git a/tests/test_gateway_sse_reconnect_dedupe.py b/tests/test_gateway_sse_reconnect_dedupe.py new file mode 100644 index 00000000..df2e9180 --- /dev/null +++ b/tests/test_gateway_sse_reconnect_dedupe.py @@ -0,0 +1,129 @@ +"""Regression coverage for gateway SSE reconnect refresh dedupe.""" + +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SESSIONS_JS = ROOT / "static" / "sessions.js" +GATEWAY_WATCHER = ROOT / "api" / "gateway_watcher.py" + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _block(src: str, start: str, end: str) -> str: + i = src.index(start) + j = src.index(end, i) + return src[i:j] + + +def test_gateway_watcher_remains_hash_only(): + """The watcher should not try to infer restarts from state.db mtime.""" + src = _read(GATEWAY_WATCHER) + poll = _block(src, " def _poll_loop(self):", "\n_watcher:") + + assert "_get_db_mtime" not in src + assert "_detect_gateway_restart" not in src + assert "current_hash = _snapshot_hash(sessions)" in poll + assert "if current_hash != self._last_hash:" in poll + assert "_notify_subscribers(sessions)" in poll + + +def test_gateway_sse_dedupes_reconnect_snapshot_before_refresh(): + """Reconnect initial snapshots should not force a sidebar refetch.""" + src = _read(SESSIONS_JS) + handler = _block( + src, + "_gatewaySSE.addEventListener('sessions_changed'", + "_gatewaySSE.onerror", + ) + + assert "function _gatewaySessionSnapshotKey" in src + assert "function _isDuplicateGatewaySessionSnapshot" in src + assert "if(!_isDuplicateGatewaySessionSnapshot(data.sessions))" in handler + assert "renderSessionList({deferWhileInteracting:true}); // re-fetch and re-render" in handler + + +def test_gateway_snapshot_key_matches_backend_hash_fields(): + """Frontend dedupe must compare the same fields that drive watcher events.""" + src = _read(SESSIONS_JS) + key_fn = _block( + src, + "function _gatewaySessionSnapshotKey", + "\n\nfunction _isGatewaySessionForSnapshot", + ) + + assert "s.session_id" in key_fn + assert "s.updated_at||0" in key_fn + assert "s.message_count||0" in key_fn + assert ".sort()" in key_fn + + +def test_gateway_snapshot_dedupe_logic_filters_symmetrically(): + """Exercise the dedupe helpers, including null and webui noise.""" + script = r""" +function _isCliSession(session) { + return session && (session.session_source === 'cli' || session.raw_source === 'cli' || session.is_cli_session === true); +} +function _isMessagingSession(session) { + return session && session.session_source === 'messaging'; +} +function _gatewaySessionSnapshotKey(sessions){ + return (Array.isArray(sessions)?sessions:[]) + .filter(s=>s&&s.session_id) + .map(s=>`${s.session_id}:${s.updated_at||0}:${s.message_count||0}`) + .sort() + .join('|'); +} +function _isGatewaySessionForSnapshot(session){ + if(!session) return false; + if(typeof _isCliSession==='function'&&_isCliSession(session)) return true; + if(typeof _isMessagingSession==='function'&&_isMessagingSession(session)) return true; + const source=String(session.session_source||session.raw_source||session.source_tag||session.source||'').toLowerCase(); + return !!source&&source!=='webui'; +} +function _isDuplicateGatewaySessionSnapshot(sessions){ + const incoming=(Array.isArray(sessions)?sessions:[]).filter(_isGatewaySessionForSnapshot); + const currentGatewaySessions=(Array.isArray(globalThis._allSessions)?globalThis._allSessions:[]).filter(_isGatewaySessionForSnapshot); + if(!incoming.length&&!currentGatewaySessions.length) return true; + return _gatewaySessionSnapshotKey(incoming)===_gatewaySessionSnapshotKey(currentGatewaySessions); +} + +globalThis._allSessions = [ + {session_id:'cli-1', updated_at:10, message_count:2, session_source:'cli'}, + {session_id:'msg-1', updated_at:20, message_count:5, session_source:'messaging'}, + {session_id:'web-1', updated_at:30, message_count:1, session_source:'webui'}, + null, +]; + +const duplicateWithNoise = [ + null, + {session_id:'web-2', updated_at:99, message_count:1, session_source:'webui'}, + {session_id:'msg-1', updated_at:20, message_count:5, session_source:'messaging'}, + {session_id:'cli-1', updated_at:10, message_count:2, session_source:'cli'}, +]; +if(!_isDuplicateGatewaySessionSnapshot(duplicateWithNoise)) throw new Error('expected duplicate snapshot'); + +const changed = [ + {session_id:'cli-1', updated_at:10, message_count:3, session_source:'cli'}, + {session_id:'msg-1', updated_at:20, message_count:5, session_source:'messaging'}, +]; +if(_isDuplicateGatewaySessionSnapshot(changed)) throw new Error('expected changed snapshot'); + +globalThis._allSessions = [{session_id:'web-1', updated_at:1, message_count:1, session_source:'webui'}]; +if(!_isDuplicateGatewaySessionSnapshot([null, {session_id:'web-2', session_source:'webui'}])) throw new Error('expected empty gateway snapshot duplicate'); +""" + subprocess.run(["node", "-e", script], check=True) + + +def test_load_session_persists_only_after_metadata_loads(): + """Do not overwrite the last good localStorage sid before /api/session succeeds.""" + src = _read(SESSIONS_JS) + load = _block(src, "async function loadSession(sid)", "\n const activeStreamId=") + api_pos = load.index("data = await api(`/api/session") + persist_pos = load.index("localStorage.setItem('hermes-webui-session',S.session.session_id)") + + assert "_persistActiveSession" not in src + assert persist_pos > api_pos