stage #4769 v2 (rodboev): sidebar source-bucket pushdown, re-push
This commit is contained in:
commit
ced64ff1f3
6 changed files with 804 additions and 30 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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':'');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
632
tests/test_issue4766_sidebar_source_pushdown.py
Normal file
632
tests/test_issue4766_sidebar_source_pushdown.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue