From 9a5a3eb6d50ec01a9bb1ea073c4a0e6dc44e1b0d Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 05:56:00 -0400 Subject: [PATCH] fix(#4766): filter /api/sessions by active source bucket --- api/routes.py | 24 ++ static/sessions.js | 28 +- .../test_issue4766_sidebar_source_pushdown.py | 240 ++++++++++++++++++ 3 files changed, 285 insertions(+), 7 deletions(-) create mode 100644 tests/test_issue4766_sidebar_source_pushdown.py diff --git a/api/routes.py b/api/routes.py index 25796f0b..c6c4e404 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, @@ -2134,6 +2151,8 @@ def _session_list_payload_to_response(payload: dict) -> dict: "archived_count": int(payload.get("archived_count", 0)), "archived_webui_count": int(payload.get("archived_webui_count", 0)), "archived_cli_count": int(payload.get("archived_cli_count", 0)), + "webui_session_count": int(payload.get("webui_session_count", 0)), + "cli_session_count": int(payload.get("cli_session_count", 0)), "include_archived": bool(payload.get("include_archived", False)), "all_profiles": bool(payload.get("all_profiles", False)), "active_profile": payload.get("active_profile"), @@ -9733,6 +9752,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 +9763,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 +9779,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..5863de55 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2673,6 +2673,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; @@ -3746,6 +3748,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) @@ -3827,6 +3837,8 @@ async function _runRenderSessionListRefresh(opts, _gen){ try{ if(!($('sessionSearch').value||'').trim()) _contentSearchResults = []; const qs = new URLSearchParams(); + const requestSidebarSource = window._showCliSessions ? _sessionSourceFilter : 'webui'; + qs.set('sidebar_source', requestSidebarSource); if(_showAllProfiles) qs.set('all_profiles','1'); if(_showArchived) qs.set('include_archived','1'); const sessionListQS = qs.toString() ? `?${qs.toString()}` : ''; @@ -5550,12 +5562,14 @@ 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=Number.isFinite(_serverWebuiSessionCount) + ? _serverWebuiSessionCount + : renderedWebuiSessionCount; + const cliSessionTabCount=Number.isFinite(_serverCliSessionCount) + ? _serverCliSessionCount + : renderedCliSessionCount; _syncSidebarExpansionForActiveSession(sessions, activeSidForSidebar); const list=$('sessionList'); const animateRefresh=_sessionListRefreshAnimationPending; @@ -5616,7 +5630,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_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py new file mode 100644 index 00000000..3331baaf --- /dev/null +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -0,0 +1,240 @@ +"""Regression coverage for issue #4766: `/api/sessions` filters by active sidebar source.""" + +import io +import json +from pathlib import Path +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" + + +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 + + +@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 "const requestSidebarSource = window._showCliSessions ? _sessionSourceFilter : 'webui';" in src + assert "qs.set('sidebar_source', requestSidebarSource);" in src + assert "_serverWebuiSessionCount" in src + assert "_serverCliSessionCount" in src + assert "Number.isFinite(_serverWebuiSessionCount)" in src + assert "Number.isFinite(_serverCliSessionCount)" in src + assert "_sessionSourceLabel(filter,count)" in src + + +def test_frontend_avoids_cli_bucket_request_when_cli_hidden(): + src = SESSIONS_JS.read_text(encoding="utf-8") + + assert "window._showCliSessions ? _sessionSourceFilter : 'webui'" in src + + +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