diff --git a/api/routes.py b/api/routes.py index 25796f0b..019fbf1c 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1598,6 +1598,7 @@ def _session_list_cache_key( show_cron_sessions: bool, include_archived: bool = False, source_filter: str | None = None, + sidebar_source: str | None = None, ) -> tuple: return ( _session_list_cache_profile_scope(active_profile), @@ -1607,6 +1608,7 @@ def _session_list_cache_key( bool(show_cron_sessions), bool(include_archived), source_filter, + sidebar_source, ) @@ -1925,6 +1927,7 @@ def _build_session_list_cache_payload( show_cron_sessions: bool, include_archived: bool = False, source_filter: str | None = None, + sidebar_source: str | None = None, diag=None, ) -> dict: diag_stage = diag.stage if diag is not None else lambda *_a, **_k: None @@ -2097,6 +2100,18 @@ def _build_session_list_cache_payload( ) archived_count = archived_webui_count + archived_cli_count scoped = archived_scoped if include_archived else visible_scoped + webui_session_count = sum( + 1 for s in scoped + if not _is_cli_session_for_settings(s) + ) + cli_session_count = sum( + 1 for s in scoped + if _is_cli_session_for_settings(s) + ) + if sidebar_source == "webui": + scoped = [s for s in scoped if not _is_cli_session_for_settings(s)] + elif sidebar_source == "cli": + scoped = [s for s in scoped if _is_cli_session_for_settings(s)] if not include_archived: diag_stage("filter_archived_sessions") diag_stage("visible_lineage_metadata") @@ -2110,6 +2125,8 @@ def _build_session_list_cache_payload( "archived_count": archived_count, "archived_webui_count": archived_webui_count, "archived_cli_count": archived_cli_count, + "webui_session_count": webui_session_count, + "cli_session_count": cli_session_count, "include_archived": include_archived, "all_profiles": all_profiles, "active_profile": active_profile, @@ -2128,7 +2145,7 @@ def _session_list_payload_to_response(payload: dict) -> dict: for s in runtime_rows: item = _sidebar_session_response_item(s) if isinstance(s, dict) else {} safe_merged.append(item) - return { + response = { "sessions": safe_merged, "cli_count": int(payload.get("cli_count", 0)), "archived_count": int(payload.get("archived_count", 0)), @@ -2141,6 +2158,11 @@ def _session_list_payload_to_response(payload: dict) -> dict: "server_time": time.time(), "server_tz": time.strftime("%z"), } + if "webui_session_count" in payload: + response["webui_session_count"] = int(payload.get("webui_session_count", 0)) + if "cli_session_count" in payload: + response["cli_session_count"] = int(payload.get("cli_session_count", 0)) + return response def _get_cached_session_list_payload( @@ -9733,6 +9755,9 @@ def handle_get(handler, parsed) -> bool: active_profile = get_active_profile_name() all_profiles = _all_profiles_enabled(parsed) include_archived = _query_flag(parsed, "include_archived") + sidebar_source = parse_qs(parsed.query).get("sidebar_source", [""])[0].strip().lower() or None + if sidebar_source not in ("webui", "cli"): + sidebar_source = None key = _session_list_cache_key( active_profile=active_profile, all_profiles=all_profiles, @@ -9741,6 +9766,7 @@ def handle_get(handler, parsed) -> bool: show_cron_sessions=show_cron_sessions, include_archived=include_archived, source_filter=agent_session_source_filter, + sidebar_source=sidebar_source, ) # Keep the visible /api/sessions contract unchanged even though the # heavy lifting now lives in the cache builder: profile scoping via @@ -9756,6 +9782,7 @@ def handle_get(handler, parsed) -> bool: show_cron_sessions=show_cron_sessions, include_archived=include_archived, source_filter=agent_session_source_filter, + sidebar_source=sidebar_source, diag=diag, ), diag=diag, diff --git a/static/sessions.js b/static/sessions.js index c5606e3a..f01741bc 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -175,6 +175,7 @@ let _sessionCompletionUnread = null; let _sessionObservedStreaming = null; const _sessionStreamingById = new Map(); const _sessionListSnapshotById = new Map(); +const _sessionListSourceById = new Map(); let _sessionListPointerActive = false; let _sessionListLastScrollAt = 0; let _pendingSessionListPayload = null; @@ -449,6 +450,16 @@ function _purgeStaleInflightEntries() { if (s && s.session_id) sessionsById.set(s.session_id, s); } } + const sourceById = typeof _sessionListSourceById !== 'undefined' + && _sessionListSourceById + && typeof _sessionListSourceById.get === 'function' + ? _sessionListSourceById + : null; + const currentSidebarSource = typeof _allSessionsScope !== 'undefined' + && _allSessionsScope + && typeof _allSessionsScope.sidebarSource === 'string' + ? _allSessionsScope.sidebarSource + : null; for (const sid of Object.keys(INFLIGHT)) { // #4354: purge stale INFLIGHT even for a hung/idle session, BUT skip the one // session actively mid-send (#2689 start-race) — during /api/chat/start the @@ -458,6 +469,10 @@ function _purgeStaleInflightEntries() { continue; } if (!sessionsById.has(sid)) { + const knownSource = sourceById ? sourceById.get(sid) : null; + if (currentSidebarSource && (!knownSource || knownSource !== currentSidebarSource)) { + continue; + } // Session is absent from _allSessions — it was deleted / archived / // filtered and can never stream again, so drop the entry. delete INFLIGHT[sid]; @@ -474,8 +489,37 @@ function _purgeStaleInflightEntries() { } } +function _rememberSessionListSource(s, sid = null, allowScopeFallback = true) { + const resolvedSid = sid || (s && s.session_id); + if (!resolvedSid) return; + let source = null; + if (s && typeof _isCliSession === 'function') { + source = _isCliSession(s) ? 'cli' : 'webui'; + } + if (!source && Array.isArray(_allSessions)) { + const cached = _allSessions.find(item => item && item.session_id === resolvedSid); + if (cached && typeof _isCliSession === 'function') { + source = _isCliSession(cached) ? 'cli' : 'webui'; + } + } + if (!source + && allowScopeFallback + && typeof _allSessionsScope !== 'undefined' + && _allSessionsScope + && typeof _allSessionsScope.sidebarSource === 'string') { + source = _allSessionsScope.sidebarSource; + } + if (source + && typeof _sessionListSourceById !== 'undefined' + && _sessionListSourceById + && typeof _sessionListSourceById.set === 'function') { + _sessionListSourceById.set(resolvedSid, source); + } +} + function _rememberRenderedStreamingState(s, isStreaming) { if (!s || !s.session_id || !isStreaming) return; + if (typeof _rememberSessionListSource === 'function') _rememberSessionListSource(s); _sessionStreamingById.set(s.session_id, true); _rememberObservedStreamingSession(s); } @@ -580,6 +624,7 @@ function _renderRuntimeJournalAnchorActivityScene(activeStreamId, sid){ function _rememberRenderedSessionSnapshot(s) { if (!s || !s.session_id) return; + if (typeof _rememberSessionListSource === 'function') _rememberSessionListSource(s); const previous = _sessionListSnapshotById.get(s.session_id); if (previous) return; _sessionListSnapshotById.set(s.session_id, { @@ -614,6 +659,7 @@ function _markSessionCompletedInList(session, previousSid = null) { pending_started_at: null, is_streaming: false, }; + if (typeof _rememberSessionListSource === 'function') _rememberSessionListSource(_allSessions[idx], finalSid); _sessionStreamingById.set(finalSid, false); _forgetObservedStreamingSession(finalSid); if (previousSid && previousSid !== finalSid) { @@ -625,6 +671,7 @@ function _markSessionCompletedInList(session, previousSid = null) { _sessionStreamingById.delete(previousSid); _forgetObservedStreamingSession(previousSid); _sessionListSnapshotById.delete(previousSid); + _sessionListSourceById.delete(previousSid); } _sessionListSnapshotById.set(finalSid, { message_count: messageCount, @@ -636,10 +683,23 @@ function _markSessionCompletedInList(session, previousSid = null) { function _markPollingCompletionUnreadTransitions(sessions) { if (!Array.isArray(sessions)) return; const seen = new Set(); + const sourceById = typeof _sessionListSourceById !== 'undefined' + && _sessionListSourceById + && typeof _sessionListSourceById.get === 'function' + && typeof _sessionListSourceById.keys === 'function' + && typeof _sessionListSourceById.delete === 'function' + ? _sessionListSourceById + : new Map(); + const currentSidebarSource = typeof _allSessionsScope !== 'undefined' + && _allSessionsScope + && typeof _allSessionsScope.sidebarSource === 'string' + ? _allSessionsScope.sidebarSource + : null; for (const s of sessions) { if (!s || !s.session_id) continue; const sid = s.session_id; seen.add(sid); + if (typeof _rememberSessionListSource === 'function') _rememberSessionListSource(s, sid); const wasStreaming = _sessionStreamingById.get(sid); const isStreaming = _isSessionEffectivelyStreaming(s); const previousSnapshot = _sessionListSnapshotById.get(sid); @@ -677,11 +737,18 @@ function _markPollingCompletionUnreadTransitions(sessions) { last_message_at: lastMessageAt, }); } - for (const sid of Array.from(_sessionStreamingById.keys())) { - if (!seen.has(sid)) _sessionStreamingById.delete(sid); - } - for (const sid of Array.from(_sessionListSnapshotById.keys())) { - if (!seen.has(sid)) _sessionListSnapshotById.delete(sid); + const staleRuntimeStateSids = new Set([ + ...Array.from(_sessionStreamingById.keys()), + ...Array.from(_sessionListSnapshotById.keys()), + ...Array.from(sourceById.keys()), + ]); + for (const sid of staleRuntimeStateSids) { + if (seen.has(sid)) continue; + const knownSource = sourceById.get(sid); + if (currentSidebarSource && (!knownSource || knownSource !== currentSidebarSource)) continue; + _sessionStreamingById.delete(sid); + _sessionListSnapshotById.delete(sid); + sourceById.delete(sid); } } @@ -1561,6 +1628,29 @@ function _sessionSourceLabel(filter, count) { return filter === 'cli' ? `CLI sessions (${n})` : `WebUI sessions (${n})`; } +function _clearSessionSourceTabCounts() { + _serverWebuiSessionCount = null; + _serverCliSessionCount = null; +} + +function _requestedSessionSidebarSource() { + return window._showCliSessions ? _sessionSourceFilter : 'webui'; +} + +function _sessionListQueryString() { + const qs = new URLSearchParams(); + qs.set('sidebar_source', _requestedSessionSidebarSource()); + if(_showAllProfiles) qs.set('all_profiles','1'); + if(_showArchived) qs.set('include_archived','1'); + return `?${qs.toString()}`; +} + +function _sessionSourceTabCount(filter, renderedWebuiSessionCount, renderedCliSessionCount) { + const serverCount = filter === 'cli' ? _serverCliSessionCount : _serverWebuiSessionCount; + if (Number.isFinite(serverCount)) return serverCount; + return filter === 'cli' ? renderedCliSessionCount : renderedWebuiSessionCount; +} + function _setSessionSourceFilter(filter) { const next = filter === 'cli' ? 'cli' : 'webui'; if (_sessionSourceFilter === next) return; @@ -1570,6 +1660,7 @@ function _setSessionSourceFilter(filter) { _sessionSelectMode = false; try { localStorage.setItem('hermes-session-source-filter', next); } catch (_e) {} renderSessionListFromCache(); + void renderSessionList({deferWhileInteracting:false}); } function _restoreSessionSourceFilter() { @@ -2673,6 +2764,8 @@ let _showAllProfiles = false; // false = filter to active profile only let _otherProfileCount = 0; // count of sessions from other profiles (server-reported) let _archivedWebuiCount = 0; // archived WebUI sessions not fetched until requested let _archivedCliCount = 0; // archived non-WebUI sessions not fetched until requested +let _serverWebuiSessionCount = null; // explicit server count for WebUI sessions +let _serverCliSessionCount = null; // explicit server count for CLI sessions let _sessionSourceFilter = 'webui'; // 'webui' keeps WebUI chats separate from read-only CLI sessions _restoreSessionSourceFilter(); let _sessionActionMenu = null; @@ -3625,6 +3718,7 @@ function _shouldKeepLocalOnlyOptimisticSessionRow(local){ function _dropStaleOptimisticSessionRow(sid){ if(!sid) return; + if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid, false); if(INFLIGHT&&INFLIGHT[sid]){ delete INFLIGHT[sid]; if(typeof clearInflightState==='function') clearInflightState(sid); @@ -3746,6 +3840,14 @@ function _applySessionListPayload(sessData, projData){ _otherProfileCount = sessData.other_profile_count || 0; _archivedWebuiCount = Number(sessData.archived_webui_count ?? sessData.archived_count ?? 0); _archivedCliCount = Number(sessData.archived_cli_count ?? 0); + _serverWebuiSessionCount = Object.prototype.hasOwnProperty.call(sessData, 'webui_session_count') + ? Number(sessData.webui_session_count) + : null; + _serverCliSessionCount = Object.prototype.hasOwnProperty.call(sessData, 'cli_session_count') + ? Number(sessData.cli_session_count) + : null; + if (!Number.isFinite(_serverWebuiSessionCount)) _serverWebuiSessionCount = null; + if (!Number.isFinite(_serverCliSessionCount)) _serverCliSessionCount = null; // Capture server clock for clock-skew compensation (issue #1144). // server_time is epoch seconds from the server's time.time(). // _serverTimeDelta = client - server, so (Date.now() - _serverTimeDelta) @@ -3770,6 +3872,7 @@ function _applySessionListPayload(sessData, projData){ ? sessData.active_profile : (S.activeProfile || 'default'), allProfiles: !!_showAllProfiles, + sidebarSource: _requestedSessionSidebarSource(), }; _syncSessionAttentionSoundState(_allSessions); _pruneLineageReportCacheToVisibleSessions(_allSessions); @@ -3826,10 +3929,7 @@ async function _runRenderSessionListRefresh(opts, _gen){ if(!deferWhileInteracting) _pendingSessionListPayload=null; try{ if(!($('sessionSearch').value||'').trim()) _contentSearchResults = []; - const qs = new URLSearchParams(); - if(_showAllProfiles) qs.set('all_profiles','1'); - if(_showArchived) qs.set('include_archived','1'); - const sessionListQS = qs.toString() ? `?${qs.toString()}` : ''; + const sessionListQS = _sessionListQueryString(); const sessionRequestOpts={ timeoutToast:false, timeoutMs:_sessionListHasLoadedOnce?30000:_SESSION_LIST_BOOT_TIMEOUT_MS, @@ -3877,10 +3977,12 @@ async function _runRenderSessionListRefresh(opts, _gen){ const _curScope = { profile: S.activeProfile || 'default', allProfiles: !!_showAllProfiles, + sidebarSource: _requestedSessionSidebarSource(), }; const _scopeMatches = _allSessionsScope && _allSessionsScope.profile === _curScope.profile - && _allSessionsScope.allProfiles === _curScope.allProfiles; + && _allSessionsScope.allProfiles === _curScope.allProfiles + && _allSessionsScope.sidebarSource === _curScope.sidebarSource; // #4671: the /api/sessions fetch failed — clear the skeleton flag so this error // render (matched cache, or empty rows for a mismatched scope) replaces the // up-front profile-switch skeleton instead of stranding it. @@ -3889,7 +3991,8 @@ async function _runRenderSessionListRefresh(opts, _gen){ renderSessionListFromCache(); } else { _allSessions = []; - _allSessionsScope = null; + _allSessionsScope = _curScope; + _clearSessionSourceTabCounts(); renderSessionListFromCache(); } } @@ -5273,6 +5376,7 @@ function _ensureActiveSessionRowPresent(rows, sourceRows){ function clearOptimisticSessionStreaming(sid){ sid=sid||(S.session&&S.session.session_id)||''; if(!sid) return; + if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid, false); if(S.session&&S.session.session_id===sid){ S.session.active_stream_id=null; S.activeStreamId=null; @@ -5550,12 +5654,10 @@ function renderSessionListFromCache(){ }=_partitionSidebarSessionRows(allMatched, activeSidForSidebar); const referenceRaw=_sessionSourceFilter==='cli'?cliReferenceRaw:webuiReferenceRaw; const sessions=_renderSidebarRowsFromRawSessions(sessionsRaw, referenceRaw); - const renderedWebuiSessionCount=_sessionSourceFilter==='webui' - ? sessions.length - : _renderSidebarRowsFromRawSessions(webuiSessionsRaw, webuiReferenceRaw).length; - const renderedCliSessionCount=_sessionSourceFilter==='cli' - ? sessions.length - : _renderSidebarRowsFromRawSessions(cliSessionsRaw, cliReferenceRaw).length; + const renderedWebuiSessionCount=_renderSidebarRowsFromRawSessions(webuiSessionsRaw, webuiReferenceRaw).length; + const renderedCliSessionCount=_renderSidebarRowsFromRawSessions(cliSessionsRaw, cliReferenceRaw).length; + const webuiSessionTabCount=_sessionSourceTabCount('webui', renderedWebuiSessionCount, renderedCliSessionCount); + const cliSessionTabCount=_sessionSourceTabCount('cli', renderedWebuiSessionCount, renderedCliSessionCount); _syncSidebarExpansionForActiveSession(sessions, activeSidForSidebar); const list=$('sessionList'); const animateRefresh=_sessionListRefreshAnimationPending; @@ -5616,7 +5718,7 @@ function renderSessionListFromCache(){ const sourceTabs=document.createElement('div'); sourceTabs.className='session-source-tabs'; for(const filter of ['webui','cli']){ - const count=filter==='cli'?renderedCliSessionCount:renderedWebuiSessionCount; + const count=filter==='cli'?cliSessionTabCount:webuiSessionTabCount; const btn=document.createElement('button'); btn.type='button'; btn.className='session-source-tab'+(_sessionSourceFilter===filter?' active':''); diff --git a/tests/test_issue4662_profile_switch_skeleton_static.py b/tests/test_issue4662_profile_switch_skeleton_static.py index c7b6f97b..90eb0a96 100644 --- a/tests/test_issue4662_profile_switch_skeleton_static.py +++ b/tests/test_issue4662_profile_switch_skeleton_static.py @@ -133,11 +133,17 @@ class TestSwitchWiring: class TestSessionsWiring: def test_skeleton_flag_cleared_on_real_render(self): - # renderSessionListFromCache clears the skeleton-active flag when it - # writes real rows (so a strand can't persist). - idx = SESSIONS.index("function renderSessionListFromCache(") - body = SESSIONS[idx: idx + 4000] - assert "_sessionListSkeletonActive=false" in body.replace(" ", "") + # The authoritative clear now happens in _applySessionListPayload() + # immediately before the real render, while renderSessionListFromCache() + # keeps the guard that blocks stale cached rows during the skeleton phase. + render_idx = SESSIONS.index("function renderSessionListFromCache(") + render_body = SESSIONS[render_idx: render_idx + 1200] + apply_idx = SESSIONS.index("function _applySessionListPayload(") + apply_body = SESSIONS[apply_idx: apply_idx + 4000] + + assert "if(_sessionListSkeletonActive)return;" in render_body.replace(" ", "") + assert "_sessionListSkeletonActive = false;" in apply_body + assert "renderSessionListFromCache();" in apply_body def test_builder_defines_groups_and_function(self): assert "const _SESSION_SKELETON_GROUPS" in SESSIONS diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py new file mode 100644 index 00000000..46b36c20 --- /dev/null +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -0,0 +1,632 @@ +"""Regression coverage for issue #4766: `/api/sessions` filters by active sidebar source.""" + +import io +import json +from pathlib import Path +import shutil +import subprocess +from urllib.parse import urlparse + +import api.profiles as profiles +import api.routes as routes +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SESSIONS_JS = ROOT / "static" / "sessions.js" +NODE = shutil.which("node") + + +class _FakeHandler: + def __init__(self): + self.status = None + self.headers = {} + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.headers[key] = value + + def end_headers(self): + pass + + def json_body(self): + return json.loads(self.wfile.getvalue().decode("utf-8")) + + +def _session_rows( + webui_count, + cli_count, + archived_webui_count=0, + archived_cli_count=0, + start=0, +): + rows = [] + for index in range(webui_count): + rows.append( + { + "session_id": f"webui-{start + index}", + "title": "WebUI Session", + "profile": "default", + "archived": index < archived_webui_count, + "message_count": 1, + "updated_at": 1000 + index, + "last_message_at": 1000 + index, + "source": "webui", + "raw_source": "webui", + "session_source": "webui", + "source_tag": "webui", + } + ) + for index in range(cli_count): + rows.append( + { + "session_id": f"cli-{start + index + 10000}", + "title": "Imported CLI session", + "profile": "default", + "archived": index < archived_cli_count, + "message_count": 1, + "updated_at": 2000 + index, + "last_message_at": 2000 + index, + "source": "cli", + "raw_source": "cli", + "session_source": "cli", + "source_tag": "cli", + } + ) + return rows + + +def _handle_sessions(url): + handler = _FakeHandler() + routes.handle_get(handler, urlparse(url)) + return handler + + +def _extract_function(source_text, function_name): + marker = f"function {function_name}(" + start = source_text.index(marker) + brace_start = source_text.index("{", start) + depth = 0 + for index in range(brace_start, len(source_text)): + char = source_text[index] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source_text[start : index + 1] + raise AssertionError(f"Could not extract {function_name}") + + +def _run_node(script): + proc = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=True) + return json.loads(proc.stdout) + + +@pytest.fixture(autouse=True) +def _clear_cache(): + routes._session_list_cache_clear() + yield + routes._session_list_cache_clear() + + +def _install_common_monkeypatches(monkeypatch, rows): + enriched = [] + row_ids = {str(row["session_id"]) for row in rows if row.get("session_id")} + monkeypatch.setattr(routes, "all_sessions", lambda diag=None: list(rows)) + monkeypatch.setattr(routes, "_reconcile_stale_stream_state_for_session_rows", lambda _rows: False) + monkeypatch.setattr(routes, "_enrich_sidebar_lineage_metadata", lambda rows: enriched.append([r["session_id"] for r in rows])) + monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None, all_profiles=False: []) + monkeypatch.setattr(routes, "agent_session_rows_existing", lambda ids, profile=None: set(row_ids & {str(sid) for sid in ids})) + monkeypatch.setattr(routes, "load_settings", lambda: {"show_cli_sessions": True}) + monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default") + return enriched + + +def test_sidebar_source_webui_excludes_cli_rows(monkeypatch): + rows = _session_rows(webui_count=30, cli_count=20) + enriched = _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui") + + body = handler.json_body() + assert handler.status == 200 + assert len(body["sessions"]) == 30 + assert all(r["session_id"].startswith("webui-") for r in body["sessions"]) + assert body["webui_session_count"] == 30 + assert body["cli_session_count"] == 20 + assert body["archived_count"] == 0 + expected = { + row["session_id"] for row in rows + if not row["archived"] and row["session_id"].startswith("webui-") + } + assert set(enriched[0]) == expected + + +def test_sidebar_source_cli_excludes_webui_rows(monkeypatch): + rows = _session_rows(webui_count=30, cli_count=20) + _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=cli") + + body = handler.json_body() + assert handler.status == 200 + assert len(body["sessions"]) == 20 + assert all(r["session_id"].startswith("cli-") for r in body["sessions"]) + assert body["webui_session_count"] == 30 + assert body["cli_session_count"] == 20 + + +def test_sidebar_source_omitted_returns_all_rows(monkeypatch): + rows = _session_rows(webui_count=30, cli_count=20) + _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions") + + body = handler.json_body() + assert handler.status == 200 + assert len(body["sessions"]) == 50 + assert len([r for r in body["sessions"] if r["session_id"].startswith("webui-")]) == 30 + assert len([r for r in body["sessions"] if r["session_id"].startswith("cli-")]) == 20 + + +def test_sidebar_source_returns_cross_bucket_counts(monkeypatch): + rows = _session_rows(webui_count=30, cli_count=20, archived_webui_count=2, archived_cli_count=3) + _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui&include_archived=1") + webui_rows = [r for r in rows if r["session_id"].startswith("webui-")] + cli_rows = [r for r in rows if r["session_id"].startswith("cli-")] + + body = handler.json_body() + assert handler.status == 200 + assert body["webui_session_count"] == len(webui_rows) + assert body["cli_session_count"] == len(cli_rows) + + +def test_sidebar_source_preserves_archived_counts(monkeypatch): + rows = _session_rows(webui_count=30, cli_count=20, archived_webui_count=2, archived_cli_count=3) + _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui&include_archived=1") + body = handler.json_body() + + assert handler.status == 200 + assert body["archived_webui_count"] == 2 + assert body["archived_cli_count"] == 3 + assert body["archived_count"] == 5 + assert len([r for r in body["sessions"] if r["archived"]]) == 2 + + +def test_sidebar_source_varies_cache_key(): + key_webui = routes._session_list_cache_key( + active_profile="default", + all_profiles=False, + show_cli_sessions=True, + show_previous_messaging_sessions=False, + show_cron_sessions=False, + include_archived=False, + sidebar_source="webui", + ) + key_cli = routes._session_list_cache_key( + active_profile="default", + all_profiles=False, + show_cli_sessions=True, + show_previous_messaging_sessions=False, + show_cron_sessions=False, + include_archived=False, + sidebar_source="cli", + ) + key_omitted = routes._session_list_cache_key( + active_profile="default", + all_profiles=False, + show_cli_sessions=True, + show_previous_messaging_sessions=False, + show_cron_sessions=False, + include_archived=False, + sidebar_source=None, + ) + + assert key_webui != key_cli + assert key_webui != key_omitted + assert key_cli != key_omitted + + +def test_frontend_sends_sidebar_source_param(): + src = SESSIONS_JS.read_text(encoding="utf-8") + + assert "function _requestedSessionSidebarSource()" in src + assert "function _sessionListQueryString()" in src + assert "qs.set('sidebar_source', _requestedSessionSidebarSource());" in src + assert "_serverWebuiSessionCount" in src + assert "_serverCliSessionCount" in src + assert "function _sessionSourceTabCount(" in src + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_session_list_query_string_respects_sidebar_source_and_flags(): + src = SESSIONS_JS.read_text(encoding="utf-8") + requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource") + query_fn = _extract_function(src, "_sessionListQueryString") + script = f""" +global.window = {{ _showCliSessions: true }}; +global._sessionSourceFilter = 'cli'; +global._showAllProfiles = true; +global._showArchived = false; +{requested_source_fn} +{query_fn} +const first = _sessionListQueryString(); +window._showCliSessions = false; +global._showArchived = true; +const second = _sessionListQueryString(); +console.log(JSON.stringify({{ first, second }})); +""" + body = _run_node(script) + + assert body["first"] == "?sidebar_source=cli&all_profiles=1" + assert body["second"] == "?sidebar_source=webui&all_profiles=1&include_archived=1" + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_session_source_switch_fetches_selected_bucket(): + src = SESSIONS_JS.read_text(encoding="utf-8") + fn_source = _extract_function(src, "_setSessionSourceFilter") + script = f""" +const renderCalls = []; +global._sessionSourceFilter = 'webui'; +global._activeProject = 'demo-project'; +global._selectedSessions = new Set(['first', 'second']); +global._sessionSelectMode = true; +global.localStorage = {{ + writes: [], + setItem(key, value) {{ + this.writes.push([key, value]); + }}, +}}; +global.renderSessionListFromCache = () => {{ + renderCalls.push('cache'); +}}; +global.renderSessionList = (opts) => {{ + renderCalls.push(opts); + return Promise.resolve(); +}}; +{fn_source} +_setSessionSourceFilter('cli'); +console.log(JSON.stringify({{ + sourceFilter: global._sessionSourceFilter, + activeProject: global._activeProject, + selectedSize: global._selectedSessions.size, + sessionSelectMode: global._sessionSelectMode, + storageWrites: global.localStorage.writes, + renderCalls, +}})); +""" + body = _run_node(script) + + assert body["sourceFilter"] == "cli" + assert body["activeProject"] is None + assert body["selectedSize"] == 0 + assert body["sessionSelectMode"] is False + assert body["storageWrites"] == [["hermes-session-source-filter", "cli"]] + assert body["renderCalls"] == ["cache", {"deferWhileInteracting": False}] + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_apply_payload_and_tab_count_helpers_cover_old_and_new_payloads(): + src = SESSIONS_JS.read_text(encoding="utf-8") + requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource") + apply_fn = _extract_function(src, "_applySessionListPayload") + count_fn = _extract_function(src, "_sessionSourceTabCount") + clear_fn = _extract_function(src, "_clearSessionSourceTabCounts") + script = f""" +global.window = {{ _showCliSessions: true }}; +global._otherProfileCount = 0; +global._archivedWebuiCount = 0; +global._archivedCliCount = 0; +global._serverWebuiSessionCount = null; +global._serverCliSessionCount = null; +global._serverTimeDelta = 0; +global._serverTz = null; +global._optimisticallyRemovedSessionIds = new Set(); +global._allSessions = []; +global._allSessionsScope = null; +global._allProjects = []; +global._sessionListLoadError = null; +global._sessionListHasLoadedOnce = false; +global._sessionListFirstRenderAnimated = true; +global._sessionListSkeletonActive = true; +global._showAllProfiles = false; +global._sessionSourceFilter = 'webui'; +global.S = {{ activeProfile: 'default' }}; +global._reconcileActiveSessionIdleStateFromList = rows => rows; +global._mergeOptimisticFirstTurnSessions = rows => rows; +global._syncSessionAttentionSoundState = () => {{}}; +global._pruneLineageReportCacheToVisibleSessions = () => {{}}; +global._markPollingCompletionUnreadTransitions = () => {{}}; +global._isSessionEffectivelyStreaming = () => false; +global.startStreamingPoll = () => {{}}; +global.stopStreamingPoll = () => {{}}; +global.ensureSessionTimeRefreshPoll = () => {{}}; +global.ensureActiveSessionExternalRefreshPoll = () => {{}}; +global.ensureSessionEventsSSE = () => {{}}; +global.animateNextSessionListRefresh = () => {{}}; +global.renderSessionListFromCache = () => {{}}; +{clear_fn} +{count_fn} +{requested_source_fn} +{apply_fn} +const sessions = [{{ session_id: 'webui-1' }}]; +_applySessionListPayload({{ sessions, other_profile_count: 0, archived_count: 0, active_profile: 'default' }}, {{ projects: [] }}); +const oldPayload = {{ + webui: _sessionSourceTabCount('webui', 7, 3), + cli: _sessionSourceTabCount('cli', 7, 3), +}}; +_clearSessionSourceTabCounts(); +_applySessionListPayload({{ + sessions, + other_profile_count: 0, + archived_count: 0, + active_profile: 'default', + webui_session_count: 11, + cli_session_count: 5, +}}, {{ projects: [] }}); +const newPayload = {{ + webui: _sessionSourceTabCount('webui', 7, 3), + cli: _sessionSourceTabCount('cli', 7, 3), +}}; +console.log(JSON.stringify({{ oldPayload, newPayload }})); +""" + body = _run_node(script) + + assert body["oldPayload"] == {"webui": 7, "cli": 3} + assert body["newPayload"] == {"webui": 11, "cli": 5} + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_source_filtered_cache_preserves_hidden_bucket_runtime_state(): + src = SESSIONS_JS.read_text(encoding="utf-8") + is_cli_fn = _extract_function(src, "_isCliSession") + remember_source_fn = _extract_function(src, "_rememberSessionListSource") + remember_streaming_fn = _extract_function(src, "_rememberRenderedStreamingState") + remember_snapshot_fn = _extract_function(src, "_rememberRenderedSessionSnapshot") + purge_fn = _extract_function(src, "_purgeStaleInflightEntries") + mark_fn = _extract_function(src, "_markPollingCompletionUnreadTransitions") + script = f""" +global._allSessions = [{{ + session_id: 'cli-1', + source_tag: 'cli', + raw_source: 'cli', + session_source: 'cli', + is_streaming: false, + message_count: 1, + last_message_at: 10, +}}]; +global._allSessionsScope = {{ sidebarSource: 'cli' }}; +global._sessionListSourceById = new Map([['webui-live', 'webui']]); +global._sessionStreamingById = new Map([['webui-live', true]]); +global._sessionListSnapshotById = new Map([['webui-live', {{ message_count: 1, last_message_at: 1 }}]]); +global._sendInProgress = false; +global._sendInProgressSid = null; +global.INFLIGHT = {{ 'webui-live': {{ lastAssistantText: 'working' }} }}; +const cleared = []; +global.clearInflightState = sid => cleared.push(sid); +global._isSessionEffectivelyStreaming = s => Boolean(s.is_streaming); +global._getSessionObservedStreaming = () => ({{}}); +global._hasPendingUserMessageSignal = () => false; +global._isSessionActivelyViewedForList = () => false; +global._markSessionCompletionUnread = () => {{}}; +global._setSessionViewedCount = () => {{}}; +global._rememberObservedStreamingSession = () => {{}}; +global._forgetObservedStreamingSession = () => {{}}; +{is_cli_fn} +{remember_source_fn} +{remember_streaming_fn} +{remember_snapshot_fn} +{purge_fn} +{mark_fn} +const cliStale = {{ + session_id: 'cli-stale', + source_tag: 'cli', + raw_source: 'cli', + session_source: 'cli', + is_streaming: false, + message_count: 2, + last_message_at: 2, +}}; +_rememberRenderedStreamingState(cliStale, true); +_rememberRenderedSessionSnapshot(cliStale); +INFLIGHT['cli-stale'] = {{ lastAssistantText: 'stale' }}; +_purgeStaleInflightEntries(); +_markPollingCompletionUnreadTransitions(global._allSessions); +console.log(JSON.stringify({{ + inflightKeys: Object.keys(INFLIGHT), + cleared, + streamingKeys: Array.from(_sessionStreamingById.keys()).sort(), + snapshotKeys: Array.from(_sessionListSnapshotById.keys()).sort(), + sourceKeys: Array.from(_sessionListSourceById.keys()).sort(), +}})); +""" + body = _run_node(script) + + assert body["inflightKeys"] == ["webui-live"] + assert body["cleared"] == ["cli-stale"] + assert "webui-live" in body["streamingKeys"] + assert "cli-stale" not in body["streamingKeys"] + assert "webui-live" in body["snapshotKeys"] + assert "cli-stale" not in body["snapshotKeys"] + assert "webui-live" in body["sourceKeys"] + assert "cli-stale" not in body["sourceKeys"] + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_sid_only_source_remembering_skips_scope_fallback(): + src = SESSIONS_JS.read_text(encoding="utf-8") + is_cli_fn = _extract_function(src, "_isCliSession") + remember_source_fn = _extract_function(src, "_rememberSessionListSource") + script = f""" +global._allSessions = []; +global._allSessionsScope = {{ sidebarSource: 'cli' }}; +global._sessionListSourceById = new Map(); +{is_cli_fn} +{remember_source_fn} +_rememberSessionListSource(null, 'detached-sid', false); +console.log(JSON.stringify({{ + hasDetached: _sessionListSourceById.has('detached-sid'), + remembered: Array.from(_sessionListSourceById.entries()), +}})); +""" + body = _run_node(script) + + assert body["hasDetached"] is False + assert body["remembered"] == [] + + +def test_session_list_response_omits_bucket_counts_when_missing(monkeypatch): + monkeypatch.setattr(routes, "_session_list_cache_overlay_runtime_rows", lambda rows: rows) + monkeypatch.setattr(routes, "_sidebar_session_response_item", lambda row: row) + + body = routes._session_list_payload_to_response( + { + "sessions": [{"session_id": "webui-1", "title": "WebUI Session"}], + "cli_count": 0, + "archived_count": 0, + "archived_webui_count": 0, + "archived_cli_count": 0, + "include_archived": False, + "all_profiles": False, + "active_profile": "default", + "other_profile_count": 0, + } + ) + + assert "webui_session_count" not in body + assert "cli_session_count" not in body + assert body["sessions"][0]["session_id"] == "webui-1" + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_scope_mismatch_error_path_respects_sidebar_source(): + src = SESSIONS_JS.read_text(encoding="utf-8") + purge_fn = _extract_function(src, "_purgeStaleInflightEntries") + clear_fn = _extract_function(src, "_clearSessionSourceTabCounts") + requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource") + query_fn = _extract_function(src, "_sessionListQueryString") + refresh_fn = _extract_function(src, "_runRenderSessionListRefresh").replace( + "function _runRenderSessionListRefresh", + "async function _runRenderSessionListRefresh", + 1, + ) + script = f""" +global.window = {{ _showCliSessions: true }}; +global._showAllProfiles = false; +global._showArchived = false; +global._sessionListHasLoadedOnce = true; +global._SESSION_LIST_BOOT_TIMEOUT_MS = 90000; +global._renderSessionListGen = 1; +global._profileSwitchListEmbargo = false; +global._pendingSessionListPayload = null; +global._allProjects = []; +global._contentSearchResults = ['stale']; +global.S = {{ activeProfile: 'default' }}; +global.$ = () => ({{ value: '' }}); +global._isSessionListUserInteracting = () => false; +global._schedulePendingSessionListApply = () => {{}}; +global._showSessionListLoadError = error => {{ + global._lastError = error.message; +}}; +const renders = []; +const cleared = []; +global.renderSessionListFromCache = () => {{ + _purgeStaleInflightEntries(); + renders.push({{ + sessions: Array.isArray(global._allSessions) ? global._allSessions.map(s => s.session_id) : null, + scope: global._allSessionsScope ? {{ ...global._allSessionsScope }} : null, + webui: global._serverWebuiSessionCount, + cli: global._serverCliSessionCount, + skeleton: global._sessionListSkeletonActive, + inflightKeys: Object.keys(global.INFLIGHT || {{}}).sort(), + }}); +}}; +global.api = () => Promise.reject(new Error('boom')); +global.clearInflightState = sid => cleared.push(sid); +{purge_fn} +{clear_fn} +{requested_source_fn} +{query_fn} +{refresh_fn} +async function runCase(requestedSource, cachedSource) {{ + global._sessionSourceFilter = requestedSource; + global._allSessions = [{{ session_id: cachedSource + '-1' }}]; + global._allSessionsScope = {{ + profile: 'default', + allProfiles: false, + sidebarSource: cachedSource, + }}; + global._sessionListSourceById = new Map([['webui-live', 'webui']]); + global.INFLIGHT = {{ 'webui-live': {{ lastAssistantText: 'working' }} }}; + cleared.length = 0; + global._serverWebuiSessionCount = 11; + global._serverCliSessionCount = 5; + global._sessionListSkeletonActive = true; + global._lastError = null; + renders.length = 0; + await _runRenderSessionListRefresh({{}}, 1); + return {{ + sessions: Array.isArray(global._allSessions) ? global._allSessions.map(s => s.session_id) : null, + scope: global._allSessionsScope ? {{ ...global._allSessionsScope }} : null, + webui: global._serverWebuiSessionCount, + cli: global._serverCliSessionCount, + skeleton: global._sessionListSkeletonActive, + error: global._lastError, + cleared: [...cleared], + inflightKeys: Object.keys(global.INFLIGHT || {{}}).sort(), + render: renders[0] || null, + }}; +}} +(async () => {{ + const mismatch = await runCase('cli', 'webui'); + const match = await runCase('webui', 'webui'); + console.log(JSON.stringify({{ mismatch, match }})); +}})().catch(error => {{ + console.error(error); + process.exit(1); +}}); +""" + body = _run_node(script) + + assert body["mismatch"]["sessions"] == [] + assert body["mismatch"]["scope"] == { + "profile": "default", + "allProfiles": False, + "sidebarSource": "cli", + } + assert body["mismatch"]["webui"] is None + assert body["mismatch"]["cli"] is None + assert body["mismatch"]["skeleton"] is False + assert body["mismatch"]["render"]["sessions"] == [] + assert body["mismatch"]["inflightKeys"] == ["webui-live"] + assert body["mismatch"]["cleared"] == [] + assert body["mismatch"]["render"]["inflightKeys"] == ["webui-live"] + assert body["match"]["sessions"] == ["webui-1"] + assert body["match"]["scope"] == { + "profile": "default", + "allProfiles": False, + "sidebarSource": "webui", + } + assert body["match"]["webui"] == 11 + assert body["match"]["cli"] == 5 + assert body["match"]["render"]["sessions"] == ["webui-1"] + + +def test_payload_row_count_regression(monkeypatch): + rows = _session_rows(webui_count=30, cli_count=20) + _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui") + body = handler.json_body() + + assert handler.status == 200 + assert len(body["sessions"]) == 30 diff --git a/tests/test_sidebar_completion_dedupe.py b/tests/test_sidebar_completion_dedupe.py index 82d26c44..436bb29e 100644 --- a/tests/test_sidebar_completion_dedupe.py +++ b/tests/test_sidebar_completion_dedupe.py @@ -31,6 +31,7 @@ let _allSessions = [ ]; let _sessionStreamingById = new Map([['old', true], ['new', true]]); let _sessionListSnapshotById = new Map([['old', {{message_count: 10}}]]); +let _sessionListSourceById = new Map([['old', 'webui'], ['new', 'webui']]); function _forgetObservedStreamingSession(_sid) {{}} function renderSessionListFromCache() {{}} {fn_src} @@ -40,7 +41,11 @@ _markSessionCompletedInList({{ message_count: 3, parent_session_id: 'old', }}, 'old'); -console.log(JSON.stringify({{rows: _allSessions, oldStreaming: _sessionStreamingById.has('old')}})); +console.log(JSON.stringify({{ + rows: _allSessions, + oldStreaming: _sessionStreamingById.has('old'), + oldSourceTracked: _sessionListSourceById.has('old'), +}})); """ result = subprocess.run(["node", "-e", script], check=True, text=True, capture_output=True) payload = json.loads(result.stdout) @@ -49,3 +54,4 @@ console.log(JSON.stringify({{rows: _allSessions, oldStreaming: _sessionStreaming assert payload["rows"][0]["title"] == "After done" assert payload["rows"][0]["message_count"] == 3 assert payload["oldStreaming"] is False + assert payload["oldSourceTracked"] is False diff --git a/tests/test_sidebar_session_partition.py b/tests/test_sidebar_session_partition.py index a1afa4f1..35cbdac5 100644 --- a/tests/test_sidebar_session_partition.py +++ b/tests/test_sidebar_session_partition.py @@ -32,11 +32,12 @@ def test_render_uses_single_pass_partition_helper(): assert "_partitionSidebarSessionRows(allMatched, activeSidForSidebar)" in render_body assert "_renderSidebarRowsFromRawSessions(sessionsRaw, referenceRaw)" in render_body - assert "? sessions.length" in render_body - assert ": _renderSidebarRowsFromRawSessions(webuiSessionsRaw, webuiReferenceRaw).length;" in render_body - assert ": _renderSidebarRowsFromRawSessions(cliSessionsRaw, cliReferenceRaw).length;" in render_body - assert "const count=filter==='cli'?renderedCliSessionCount:renderedWebuiSessionCount;" in render_body - assert "const count=filter==='cli'?cliSessionCount:webuiSessionCount;" not in render_body + assert "const renderedWebuiSessionCount=_renderSidebarRowsFromRawSessions(webuiSessionsRaw, webuiReferenceRaw).length;" in render_body + assert "const renderedCliSessionCount=_renderSidebarRowsFromRawSessions(cliSessionsRaw, cliReferenceRaw).length;" in render_body + assert "const webuiSessionTabCount=_sessionSourceTabCount('webui', renderedWebuiSessionCount, renderedCliSessionCount);" in render_body + assert "const cliSessionTabCount=_sessionSourceTabCount('cli', renderedWebuiSessionCount, renderedCliSessionCount);" in render_body + assert "const count=filter==='cli'?cliSessionTabCount:webuiSessionTabCount;" in render_body + assert "const count=filter==='cli'?renderedCliSessionCount:renderedWebuiSessionCount;" not in render_body assert "withMessages.filter(" not in render_body