stage #4769 v2 (rodboev): sidebar source-bucket pushdown, re-push

This commit is contained in:
nesquena-hermes 2026-06-23 12:07:34 +00:00
commit ced64ff1f3
6 changed files with 804 additions and 30 deletions

View file

@ -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,

View file

@ -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':'');

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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