fix: keep session sidebar visible on slow loads (#4167)
Sidebar vanished during resilient/slow session loads. Preserve sidebar fields on resilient loads + add an opt-in api() retry so a slow /api/sessions shows a retryable error state instead of an empty sidebar. Maintainer fixes for the 3 review-blocking SILENT regressions: - Restore read_only / is_read_only / gateway_routing to the sidebar response allowlist, so read-only (imported CLI + Claude Code) sessions keep their rename/action-menu/swipe suppression and the gateway model label renders. (gateway_routing_history intentionally NOT sent — the label reader prefers the latest gateway_routing, and the full history would bloat each row.) - Scope-tag the session cache (active profile + all-profiles flag). When a /api/sessions refresh fails right after a profile switch, the catch path now clears the stale rows and shows the error state instead of re-rendering the PRIOR profile's sessions. Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
This commit is contained in:
parent
267342e9f2
commit
0b57a7101f
7 changed files with 431 additions and 21 deletions
|
|
@ -161,13 +161,26 @@ def _safe_write(handler, body: bytes) -> None:
|
|||
)
|
||||
|
||||
|
||||
def j(handler, payload, status: int=200, extra_headers: dict=None) -> None:
|
||||
def _json_response_body(payload, *, pretty: bool = True) -> bytes:
|
||||
"""Serialize API JSON responses.
|
||||
|
||||
Sidebar/session endpoints can return thousands of rows on large installs.
|
||||
Pretty-printing large list responses inflates both CPU and wire bytes. Keep
|
||||
the public helper default stable for existing tests/callers; hot paths can
|
||||
opt into compact JSON with ``pretty=False``.
|
||||
"""
|
||||
if pretty:
|
||||
return _json.dumps(payload, ensure_ascii=False, indent=2).encode('utf-8')
|
||||
return _json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
|
||||
|
||||
|
||||
def j(handler, payload, status: int=200, extra_headers: dict=None, *, pretty: bool = True) -> None:
|
||||
"""Send a JSON response.
|
||||
|
||||
*extra_headers*: optional dict of additional headers to include
|
||||
(e.g., {'Set-Cookie': '...'}). Headers are sent before end_headers().
|
||||
"""
|
||||
body = _json.dumps(payload, ensure_ascii=False, indent=2).encode('utf-8')
|
||||
body = _json_response_body(payload, pretty=pretty)
|
||||
handler.send_response(status)
|
||||
handler.send_header('Content-Type', 'application/json; charset=utf-8')
|
||||
|
||||
|
|
|
|||
|
|
@ -1739,10 +1739,7 @@ def _session_list_payload_to_response(payload: dict) -> dict:
|
|||
safe_merged = []
|
||||
runtime_rows = _session_list_cache_overlay_runtime_rows(payload.get("sessions", []) or [])
|
||||
for s in runtime_rows:
|
||||
item = dict(s) if isinstance(s, dict) else {}
|
||||
if isinstance(item.get("title"), str):
|
||||
item["title"] = _redact_text(item["title"])
|
||||
item["attention"] = _session_attention_summary(str(item.get("session_id") or ""))
|
||||
item = _sidebar_session_response_item(s) if isinstance(s, dict) else {}
|
||||
safe_merged.append(item)
|
||||
return {
|
||||
"sessions": safe_merged,
|
||||
|
|
@ -4943,6 +4940,91 @@ def _session_attention_summary(session_id: str) -> dict | None:
|
|||
return None
|
||||
|
||||
|
||||
_SIDEBAR_SESSION_RESPONSE_FIELDS = {
|
||||
"session_id",
|
||||
"title",
|
||||
"display_title",
|
||||
"_state_db_title",
|
||||
"workspace",
|
||||
"model",
|
||||
"model_provider",
|
||||
"message_count",
|
||||
"user_message_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"last_message_at",
|
||||
"pinned",
|
||||
"archived",
|
||||
"project_id",
|
||||
"profile",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"estimated_cost",
|
||||
"cache_read_tokens",
|
||||
"cache_write_tokens",
|
||||
"cache_hit_percent",
|
||||
"personality",
|
||||
"context_length",
|
||||
"config_context_length",
|
||||
"window_usage_percent",
|
||||
"source_tag",
|
||||
"raw_source",
|
||||
"session_source",
|
||||
"source_label",
|
||||
"is_cli_session",
|
||||
"is_messaging_session",
|
||||
"is_streaming",
|
||||
"active_stream_id",
|
||||
"has_pending_user_message",
|
||||
"pending_started_at",
|
||||
"default_hidden",
|
||||
"worktree_path",
|
||||
"worktree_branch",
|
||||
"parent_session_id",
|
||||
"parent_title",
|
||||
"parent_source",
|
||||
"relationship_type",
|
||||
"pre_compression_snapshot",
|
||||
"_lineage_root_id",
|
||||
"_lineage_tip_id",
|
||||
"_compression_segment_count",
|
||||
"_lineage_collapsed_count",
|
||||
"_parent_lineage_root_id",
|
||||
"_cross_surface_child_session",
|
||||
"match_type",
|
||||
"match_preview",
|
||||
# Preserved so the sidebar can suppress rename / action-menu / swipe on
|
||||
# read-only (imported CLI + Claude Code) sessions, and render the detailed
|
||||
# gateway model label. Dropping these silently regressed both surfaces.
|
||||
# Only the latest `gateway_routing` is included (the sidebar label reader
|
||||
# prefers it); the unbounded `gateway_routing_history` is intentionally NOT
|
||||
# sent in the list payload to avoid per-row bloat.
|
||||
"read_only",
|
||||
"is_read_only",
|
||||
"gateway_routing",
|
||||
}
|
||||
|
||||
|
||||
def _sidebar_session_response_item(session: dict, *, redact_enabled: bool | None = None) -> dict:
|
||||
"""Return the bounded /api/sessions row shape used by the sidebar.
|
||||
|
||||
Full session/detail fields such as messages, tool calls, compression
|
||||
summaries, context-engine state, gateway routing history, drafts, and
|
||||
pending user text are intentionally excluded from the list endpoint. Large
|
||||
installs should not ship tens of KB of per-row detail just to render a
|
||||
conversation title.
|
||||
"""
|
||||
item = {
|
||||
key: value
|
||||
for key, value in dict(session).items()
|
||||
if key in _SIDEBAR_SESSION_RESPONSE_FIELDS
|
||||
}
|
||||
if isinstance(item.get("title"), str):
|
||||
item["title"] = _redact_text(item["title"], _enabled=redact_enabled)
|
||||
item["attention"] = _session_attention_summary(str(item.get("session_id") or ""))
|
||||
return item
|
||||
|
||||
|
||||
# ── Login page locale strings ─────────────────────────────────────────────────
|
||||
# Add entries here to support more languages on the login page.
|
||||
# The key must match the 'language' setting value (from static/i18n.js LOCALES).
|
||||
|
|
@ -7326,7 +7408,7 @@ def handle_get(handler, parsed) -> bool:
|
|||
diag=diag,
|
||||
)
|
||||
diag.stage("response_write")
|
||||
return j(handler, _session_list_payload_to_response(payload))
|
||||
return j(handler, _session_list_payload_to_response(payload), pretty=False)
|
||||
finally:
|
||||
diag.finish()
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ function _isRestorableNewChatDraftSession(session, requireDraft=false) {
|
|||
if (!session || !session.session_id) return false;
|
||||
const messageCount = Number(session.message_count || 0);
|
||||
if (messageCount !== 0) return false;
|
||||
if (session.active_stream_id || session.pending_user_message || session.worktree_path) return false;
|
||||
if (session.active_stream_id || session.pending_user_message || session.worktree_path || session.has_pending_user_message) return false;
|
||||
const title = session.title || 'Untitled';
|
||||
if (title !== 'Untitled' && title !== 'New Chat') return false;
|
||||
const activeProfile = S.activeProfile || 'default';
|
||||
|
|
@ -179,6 +179,9 @@ let _sessionListPointerActive = false;
|
|||
let _sessionListLastScrollAt = 0;
|
||||
let _pendingSessionListPayload = null;
|
||||
let _pendingSessionListApplyTimer = 0;
|
||||
let _sessionListLoadError = null;
|
||||
let _sessionListHasLoadedOnce = false;
|
||||
const _SESSION_LIST_BOOT_TIMEOUT_MS = 90000;
|
||||
const SESSION_LIST_INTERACTION_IDLE_MS = 700;
|
||||
const SESSION_SWIPE_DURATION_MS = 500;
|
||||
const SESSION_SWIPE_REFLOW_LEAD_MS = 220;
|
||||
|
|
@ -368,8 +371,12 @@ function _isSessionEffectivelyStreaming(s) {
|
|||
return Boolean(s && (s.is_streaming || _isSessionLocallyStreaming(s)));
|
||||
}
|
||||
|
||||
function _hasPendingUserMessageSignal(s) {
|
||||
return Boolean(s && (s.pending_user_message || s.has_pending_user_message));
|
||||
}
|
||||
|
||||
function _isServerIdleSessionRow(s) {
|
||||
return Boolean(s && s.session_id && !s.is_streaming && !s.active_stream_id && !s.pending_user_message);
|
||||
return Boolean(s && s.session_id && !s.is_streaming && !s.active_stream_id && !s.pending_user_message && !s.has_pending_user_message);
|
||||
}
|
||||
|
||||
function _reconcileActiveSessionIdleStateFromList(serverRows) {
|
||||
|
|
@ -2549,6 +2556,7 @@ async function _ensureAllMessagesLoaded() {
|
|||
}
|
||||
|
||||
let _allSessions = []; // cached for search filter
|
||||
let _allSessionsScope = null; // {profile, allProfiles} the cache was loaded under (#4167)
|
||||
let _sessionAttentionSoundPrimed = false;
|
||||
const _sessionAttentionSoundState = new Map();
|
||||
let _renamingSid = null; // session_id currently being renamed (blocks list re-renders)
|
||||
|
|
@ -3395,11 +3403,12 @@ function animateNextSessionListRefresh(options={}){
|
|||
function _isOptimisticFirstTurnSessionRow(s){
|
||||
if(!s||!s.session_id||s.archived) return false;
|
||||
const messageCount=Number(s.message_count||0);
|
||||
if(messageCount<=0&&!s.pending_user_message) return false;
|
||||
if(messageCount<=0&&!(s.pending_user_message||s.has_pending_user_message)) return false;
|
||||
return Boolean(
|
||||
s.is_streaming||
|
||||
s.active_stream_id||
|
||||
s.pending_user_message||
|
||||
s.has_pending_user_message||
|
||||
s.pending_started_at||
|
||||
_isSessionLocallyStreaming(s)||
|
||||
_sessionStreamingById.get(s.session_id)===true
|
||||
|
|
@ -3412,7 +3421,7 @@ function _shouldKeepLocalOnlyOptimisticSessionRow(local){
|
|||
if(typeof _sendInProgress!=='undefined'&&_sendInProgress&&sid===_sendInProgressSid) return true;
|
||||
const activeSid=S&&S.session&&S.session.session_id;
|
||||
const isActive=Boolean(activeSid&&activeSid===sid);
|
||||
const hasRuntimeConfirmation=Boolean(local.active_stream_id||local.pending_user_message||local.pending_started_at);
|
||||
const hasRuntimeConfirmation=Boolean(local.active_stream_id||local.pending_user_message||local.has_pending_user_message||local.pending_started_at);
|
||||
if(isActive&&S.busy&&hasRuntimeConfirmation) return true;
|
||||
const localTs=Number(local.last_message_at||local.updated_at||0);
|
||||
const ageMs=localTs>0?Date.now()-(localTs*1000):Infinity;
|
||||
|
|
@ -3555,9 +3564,21 @@ function _applySessionListPayload(sessData, projData){
|
|||
: (sessData.sessions||[]);
|
||||
_reconcileActiveSessionIdleStateFromList(serverSessions);
|
||||
_allSessions = _mergeOptimisticFirstTurnSessions(serverSessions);
|
||||
// Tag the cache with the scope it was loaded under (active profile +
|
||||
// all-profiles flag). If a later /api/sessions fails right after a profile
|
||||
// switch, the catch path checks this so it won't re-render the PRIOR
|
||||
// profile's rows as if they were current (#4167 review item 3).
|
||||
_allSessionsScope = {
|
||||
profile: (typeof sessData.active_profile === 'string' && sessData.active_profile)
|
||||
? sessData.active_profile
|
||||
: (S.activeProfile || 'default'),
|
||||
allProfiles: !!_showAllProfiles,
|
||||
};
|
||||
_syncSessionAttentionSoundState(_allSessions);
|
||||
_pruneLineageReportCacheToVisibleSessions(_allSessions);
|
||||
_allProjects = projData.projects||[];
|
||||
_sessionListLoadError = null;
|
||||
_sessionListHasLoadedOnce = true;
|
||||
_markPollingCompletionUnreadTransitions(_allSessions);
|
||||
const isStreaming = _allSessions.some(s => Boolean(s && s.is_streaming));
|
||||
if (isStreaming) {
|
||||
|
|
@ -3584,16 +3605,41 @@ function _mergeRenderSessionListOptions(prev, next){
|
|||
return merged;
|
||||
}
|
||||
|
||||
function _showSessionListLoadError(error){
|
||||
console.warn('renderSessionList',error);
|
||||
const isTimeout=Boolean(error&&(error.timeout===true||error.name==='TimeoutError'));
|
||||
_sessionListLoadError={
|
||||
message:isTimeout
|
||||
? 'Session list is taking longer than expected.'
|
||||
: 'Could not load conversations.',
|
||||
detail:isTimeout
|
||||
? 'The backend may still be scanning a very large session history.'
|
||||
: String(error&&error.message?error.message:''),
|
||||
};
|
||||
}
|
||||
|
||||
async function _runRenderSessionListRefresh(opts, _gen){
|
||||
const deferWhileInteracting=Boolean(opts&&opts.deferWhileInteracting);
|
||||
if(!deferWhileInteracting) _pendingSessionListPayload=null;
|
||||
try{
|
||||
if(!($('sessionSearch').value||'').trim()) _contentSearchResults = [];
|
||||
const allProfilesQS = _showAllProfiles ? '?all_profiles=1' : '';
|
||||
const [sessData, projData] = await Promise.all([
|
||||
api('/api/sessions' + allProfilesQS,{timeoutToast:false}),
|
||||
api('/api/projects' + allProfilesQS,{timeoutToast:false}),
|
||||
]);
|
||||
const sessionRequestOpts={
|
||||
timeoutToast:false,
|
||||
timeoutMs:_sessionListHasLoadedOnce?30000:_SESSION_LIST_BOOT_TIMEOUT_MS,
|
||||
retries:1,
|
||||
retryTimeouts:true,
|
||||
retryStatuses:[502,503,504],
|
||||
};
|
||||
const sessData = _sessionListHasLoadedOnce
|
||||
? await api('/api/sessions' + allProfilesQS,{timeoutToast:false})
|
||||
: await api('/api/sessions' + allProfilesQS,sessionRequestOpts);
|
||||
let projData={projects:_allProjects||[]};
|
||||
try{
|
||||
projData = await api('/api/projects' + allProfilesQS,{timeoutToast:false});
|
||||
}catch(projectError){
|
||||
console.warn('renderProjectsList',projectError);
|
||||
}
|
||||
// Discard stale response — a newer renderSessionList() call superseded us.
|
||||
if (_gen !== _renderSessionListGen) return;
|
||||
if(deferWhileInteracting&&_isSessionListUserInteracting()){
|
||||
|
|
@ -3602,7 +3648,29 @@ async function _runRenderSessionListRefresh(opts, _gen){
|
|||
return;
|
||||
}
|
||||
_applySessionListPayload(sessData,projData);
|
||||
}catch(e){console.warn('renderSessionList',e);}
|
||||
}catch(e){
|
||||
if (_gen !== _renderSessionListGen) return;
|
||||
_showSessionListLoadError(e);
|
||||
// Only fall back to the cached rows if they were loaded under the SAME
|
||||
// scope we're requesting now. After a profile switch the cache holds the
|
||||
// PRIOR profile's sessions; re-rendering them would falsely show another
|
||||
// profile's conversations, so render the error state with no rows instead
|
||||
// (#4167 review item 3).
|
||||
const _curScope = {
|
||||
profile: S.activeProfile || 'default',
|
||||
allProfiles: !!_showAllProfiles,
|
||||
};
|
||||
const _scopeMatches = _allSessionsScope
|
||||
&& _allSessionsScope.profile === _curScope.profile
|
||||
&& _allSessionsScope.allProfiles === _curScope.allProfiles;
|
||||
if (_scopeMatches) {
|
||||
renderSessionListFromCache();
|
||||
} else {
|
||||
_allSessions = [];
|
||||
_allSessionsScope = null;
|
||||
renderSessionListFromCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function _drainRenderSessionListQueue(initialRequest){
|
||||
|
|
@ -5027,6 +5095,7 @@ function _sidebarRowHasVisibleMessages(s, activeSidForSidebar){
|
|||
_isSessionEffectivelyStreaming(s) ||
|
||||
!!s.active_stream_id ||
|
||||
!!s.pending_user_message ||
|
||||
!!s.has_pending_user_message ||
|
||||
(activeSidForSidebar&&s.session_id===activeSidForSidebar) ||
|
||||
(S.session&&s.session_id===S.session.session_id&&(S.session.message_count||0)>0);
|
||||
}
|
||||
|
|
@ -5149,6 +5218,25 @@ function renderSessionListFromCache(){
|
|||
list.appendChild(batchBar);
|
||||
if(_sessionSelectMode&&_selectedSessions.size>0){batchBar.style.display='flex';_renderBatchActionBar();}
|
||||
else{batchBar.style.display='none';}
|
||||
if(_sessionListLoadError){
|
||||
const note=document.createElement('div');
|
||||
note.className='session-list-error session-empty-note';
|
||||
const title=document.createElement('div');
|
||||
title.textContent=_sessionListLoadError.message||'Could not load conversations.';
|
||||
note.appendChild(title);
|
||||
if(_sessionListLoadError.detail){
|
||||
const detail=document.createElement('div');
|
||||
detail.className='session-list-error-detail';
|
||||
detail.textContent=_sessionListLoadError.detail;
|
||||
note.appendChild(detail);
|
||||
}
|
||||
const retry=document.createElement('button');
|
||||
retry.type='button';
|
||||
retry.textContent='Retry';
|
||||
retry.onclick=(e)=>{e.stopPropagation();_sessionListLoadError=null;void renderSessionList({deferWhileInteracting:false});};
|
||||
note.appendChild(retry);
|
||||
list.appendChild(note);
|
||||
}
|
||||
if(window._showCliSessions || cliSessionCount>0){
|
||||
const sourceTabs=document.createElement('div');
|
||||
sourceTabs.className='session-source-tabs';
|
||||
|
|
|
|||
|
|
@ -4,10 +4,14 @@ async function api(path,opts={}){
|
|||
const url=new URL(rel,document.baseURI||location.href);
|
||||
const timeoutMs=Object.prototype.hasOwnProperty.call(opts,'timeoutMs')?opts.timeoutMs:30000;
|
||||
const timeoutToast=opts.timeoutToast!==false;
|
||||
const maxAttempts=Object.prototype.hasOwnProperty.call(opts,'retries')?Math.max(0,Number(opts.retries)||0)+1:3;
|
||||
const retryTimeouts=opts.retryTimeouts===true;
|
||||
const retryStatuses=Array.isArray(opts.retryStatuses)?opts.retryStatuses.map(Number).filter(Number.isFinite):[];
|
||||
const retryDelayMs=Object.prototype.hasOwnProperty.call(opts,'retryDelayMs')?Math.max(0,Number(opts.retryDelayMs)||0):350;
|
||||
// Retry up to 2 times on network errors (e.g. stale keep-alive after long idle).
|
||||
// Server errors (4xx/5xx) and client-side timeouts are NOT retried.
|
||||
// Callers may opt into retrying timeouts / transient server statuses for idempotent GETs.
|
||||
let lastErr;
|
||||
for(let attempt=0;attempt<3;attempt++){
|
||||
for(let attempt=0;attempt<maxAttempts;attempt++){
|
||||
let controller=null;
|
||||
let timeoutId=null;
|
||||
let didTimeout=false;
|
||||
|
|
@ -17,6 +21,10 @@ async function api(path,opts={}){
|
|||
const fetchOpts={...opts};
|
||||
delete fetchOpts.timeoutMs;
|
||||
delete fetchOpts.timeoutToast;
|
||||
delete fetchOpts.retries;
|
||||
delete fetchOpts.retryTimeouts;
|
||||
delete fetchOpts.retryStatuses;
|
||||
delete fetchOpts.retryDelayMs;
|
||||
|
||||
const useTimeout=Number.isFinite(Number(timeoutMs))&&Number(timeoutMs)>0;
|
||||
if(useTimeout&&typeof AbortController!=='undefined'){
|
||||
|
|
@ -69,6 +77,10 @@ async function api(path,opts={}){
|
|||
lastErr=e;
|
||||
const isTimeout=didTimeout||(e&&(e.timeout===true||e.name==='TimeoutError'));
|
||||
if(isTimeout){
|
||||
if(retryTimeouts&&attempt<2&&attempt<maxAttempts-1){
|
||||
if(retryDelayMs) await new Promise(resolve=>setTimeout(resolve,retryDelayMs*Math.pow(2,attempt)));
|
||||
continue;
|
||||
}
|
||||
const err=(e&&e.name==='TimeoutError')?e:new Error('Request timed out. Please try again.');
|
||||
err.name='TimeoutError';
|
||||
err.timeout=true;
|
||||
|
|
@ -78,7 +90,10 @@ async function api(path,opts={}){
|
|||
// Only retry on network errors (TypeError from fetch), not on HTTP errors
|
||||
// that were already thrown above. Re-throw 401 redirects immediately.
|
||||
if(e.message&&/401/.test(e.message)) throw e;
|
||||
if(attempt<2 && e instanceof TypeError) continue;
|
||||
if(attempt<2&&attempt<maxAttempts-1 && (e instanceof TypeError || retryStatuses.includes(Number(e.status)))){
|
||||
if(retryDelayMs) await new Promise(resolve=>setTimeout(resolve,retryDelayMs*Math.pow(2,attempt)));
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}finally{
|
||||
if(timeoutId) clearTimeout(timeoutId);
|
||||
|
|
|
|||
62
tests/test_4167_sidebar_payload_and_scope.py
Normal file
62
tests/test_4167_sidebar_payload_and_scope.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Regression tests for #4167 review items — sidebar payload allowlist must
|
||||
preserve read-only + gateway-routing fields, and the failed-refresh path must
|
||||
not re-render a prior profile's sessions after a profile switch.
|
||||
|
||||
Item 1/2: _SIDEBAR_SESSION_RESPONSE_FIELDS must include read_only / is_read_only
|
||||
(so the sidebar suppresses rename / action-menu / swipe on read-only sessions)
|
||||
and gateway_routing (so the model label renders). Dropping these silently
|
||||
regressed both surfaces.
|
||||
|
||||
Item 3: renderSessionList()'s catch path must scope-tag the cache and clear it
|
||||
when the requested profile scope differs, instead of unconditionally
|
||||
re-rendering the cached (prior-profile) rows.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def test_sidebar_allowlist_preserves_read_only_and_gateway_routing():
|
||||
import api.routes as routes
|
||||
|
||||
allow = routes._SIDEBAR_SESSION_RESPONSE_FIELDS
|
||||
assert "read_only" in allow, "read_only dropped -> read-only sessions render writable"
|
||||
assert "is_read_only" in allow, "is_read_only dropped -> read-only detection misses"
|
||||
assert "gateway_routing" in allow, "gateway_routing dropped -> sidebar model label degrades"
|
||||
|
||||
|
||||
def test_sidebar_response_item_preserves_read_only_flag():
|
||||
"""_sidebar_session_response_item() must carry read_only through."""
|
||||
import api.routes as routes
|
||||
|
||||
fn = getattr(routes, "_sidebar_session_response_item", None)
|
||||
if fn is None:
|
||||
# Helper name differs across versions — fall back to allowlist assertion.
|
||||
assert "read_only" in routes._SIDEBAR_SESSION_RESPONSE_FIELDS
|
||||
return
|
||||
row = fn({
|
||||
"session_id": "s1",
|
||||
"title": "t",
|
||||
"read_only": True,
|
||||
"gateway_routing": {"provider": "anthropic", "model": "claude"},
|
||||
"not_allowed_field": "x",
|
||||
})
|
||||
assert row.get("read_only") is True
|
||||
assert row.get("gateway_routing") == {"provider": "anthropic", "model": "claude"}
|
||||
assert "not_allowed_field" not in row
|
||||
|
||||
|
||||
def test_failed_refresh_clears_cache_on_profile_scope_change():
|
||||
"""The catch path in renderSessionList must clear stale rows when the
|
||||
requested scope differs from the cached scope (no cross-profile leak)."""
|
||||
src = (_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
# The cache must be scope-tagged.
|
||||
assert "_allSessionsScope" in src, "cache is not scope-tagged"
|
||||
# The catch path must compare current scope to the cached scope and clear
|
||||
# _allSessions when they differ rather than always rendering from cache.
|
||||
assert re.search(r"_scopeMatches", src), "catch path does not gate fallback on scope match"
|
||||
assert re.search(r"_allSessions\s*=\s*\[\]", src), "catch path does not clear stale rows on scope mismatch"
|
||||
|
|
@ -144,10 +144,10 @@ def test_static_sessions_js_uses_all_profiles_query_when_toggle_on():
|
|||
assert "_showAllProfiles ? '?all_profiles=1' : ''" in src, (
|
||||
"Expected fetch path to flip on the toggle state"
|
||||
)
|
||||
assert "api('/api/sessions' + allProfilesQS,{timeoutToast:false})" in src, (
|
||||
assert "api('/api/sessions' + allProfilesQS" in src, (
|
||||
"Expected /api/sessions fetch to use the variant query"
|
||||
)
|
||||
assert "api('/api/projects' + allProfilesQS,{timeoutToast:false})" in src, (
|
||||
assert "api('/api/projects' + allProfilesQS" in src, (
|
||||
"Expected /api/projects fetch to use the variant query"
|
||||
)
|
||||
|
||||
|
|
|
|||
150
tests/test_session_sidebar_resilience.py
Normal file
150
tests/test_session_sidebar_resilience.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"""Regression tests for large-session sidebar resilience.
|
||||
|
||||
The sidebar must fail visibly when the sessions API times out, must not let
|
||||
optional project metadata blank the conversations list, and must not return
|
||||
bulky session-detail fields in /api/sessions rows.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
|
||||
|
||||
def _sessions_js() -> str:
|
||||
return (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _workspace_js() -> str:
|
||||
return (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_session_list_refresh_has_visible_failure_state_instead_of_console_only():
|
||||
src = _sessions_js()
|
||||
block_start = src.find("async function _runRenderSessionListRefresh")
|
||||
assert block_start > 0
|
||||
block_end = src.find("async function _drainRenderSessionListQueue", block_start)
|
||||
assert block_end > block_start
|
||||
block = src[block_start:block_end]
|
||||
|
||||
assert "console.warn('renderSessionList',e);" not in block
|
||||
assert "_showSessionListLoadError" in block
|
||||
assert "renderSessionListFromCache" in block
|
||||
assert "session-list-error" in src
|
||||
assert "Retry" in src
|
||||
|
||||
|
||||
def test_sessions_and_projects_load_independently_so_projects_failure_cannot_blank_sidebar():
|
||||
src = _sessions_js()
|
||||
block_start = src.find("async function _runRenderSessionListRefresh")
|
||||
assert block_start > 0
|
||||
block_end = src.find("async function _drainRenderSessionListQueue", block_start)
|
||||
assert block_end > block_start
|
||||
block = src[block_start:block_end]
|
||||
|
||||
assert "Promise.all" not in block
|
||||
assert "api('/api/sessions' + allProfilesQS" in block
|
||||
assert "try{\n projData = await api('/api/projects'" in block
|
||||
assert "console.warn('renderProjectsList'," in block
|
||||
assert "_applySessionListPayload(sessData,projData)" in block
|
||||
|
||||
|
||||
def test_sessions_api_uses_longer_timeout_and_timeout_retry_for_boot_refresh():
|
||||
sessions_src = _sessions_js()
|
||||
workspace_src = _workspace_js()
|
||||
|
||||
assert "timeoutMs:_sessionListHasLoadedOnce?30000:_SESSION_LIST_BOOT_TIMEOUT_MS" in sessions_src
|
||||
assert "retryTimeouts:true" in sessions_src
|
||||
assert "retryStatuses:[502,503,504]" in sessions_src
|
||||
assert "retryTimeouts" in workspace_src
|
||||
assert "retryStatuses" in workspace_src
|
||||
|
||||
|
||||
def test_sessions_sidebar_response_item_drops_bulky_detail_fields(monkeypatch):
|
||||
from api import routes
|
||||
|
||||
monkeypatch.setattr(routes, "_session_attention_summary", lambda sid: {"kind": "none"})
|
||||
row = {
|
||||
"session_id": "sid-heavy",
|
||||
"title": "Visible title",
|
||||
"display_title": "State DB title",
|
||||
"_state_db_title": "State DB title",
|
||||
"updated_at": 10,
|
||||
"last_message_at": 11,
|
||||
"message_count": 123,
|
||||
"user_message_count": 61,
|
||||
"has_pending_user_message": True,
|
||||
"worktree_path": "/tmp/worktree",
|
||||
"worktree_branch": "feature/sidebar",
|
||||
"compression_anchor_summary": "X" * 50000,
|
||||
"compression_anchor_details": {"huge": True},
|
||||
"context_engine_state": {"expensive": True},
|
||||
"gateway_routing_history": [{"hop": 1}],
|
||||
"composer_draft": "draft body",
|
||||
"pending_user_message": "private pending text",
|
||||
"tool_calls": [{"id": "call"}],
|
||||
"messages": [{"role": "user", "content": "not for sidebar"}],
|
||||
}
|
||||
|
||||
item = routes._sidebar_session_response_item(row, redact_enabled=False)
|
||||
|
||||
assert item["session_id"] == "sid-heavy"
|
||||
assert item["title"] == "Visible title"
|
||||
assert item["display_title"] == "State DB title"
|
||||
assert item["_state_db_title"] == "State DB title"
|
||||
assert item["message_count"] == 123
|
||||
assert item["has_pending_user_message"] is True
|
||||
assert item["worktree_path"] == "/tmp/worktree"
|
||||
assert item["worktree_branch"] == "feature/sidebar"
|
||||
assert item["attention"] == {"kind": "none"}
|
||||
for key in (
|
||||
"compression_anchor_summary",
|
||||
"compression_anchor_details",
|
||||
"context_engine_state",
|
||||
"gateway_routing_history",
|
||||
"composer_draft",
|
||||
"pending_user_message",
|
||||
"tool_calls",
|
||||
"messages",
|
||||
):
|
||||
assert key not in item
|
||||
|
||||
|
||||
def test_sidebar_allowlist_preserves_fields_consumed_by_frontend():
|
||||
from api import routes
|
||||
|
||||
required = {
|
||||
"display_title",
|
||||
"_state_db_title",
|
||||
"has_pending_user_message",
|
||||
"worktree_branch",
|
||||
}
|
||||
|
||||
assert required <= routes._SIDEBAR_SESSION_RESPONSE_FIELDS
|
||||
assert "pending_user_message" not in routes._SIDEBAR_SESSION_RESPONSE_FIELDS
|
||||
|
||||
|
||||
def test_session_list_error_path_uses_same_generation_guard_as_success_path():
|
||||
src = _sessions_js()
|
||||
block_start = src.find("async function _runRenderSessionListRefresh")
|
||||
assert block_start > 0
|
||||
block_end = src.find("async function _drainRenderSessionListQueue", block_start)
|
||||
assert block_end > block_start
|
||||
block = src[block_start:block_end]
|
||||
catch_start = block.find("}catch(e){")
|
||||
assert catch_start > 0
|
||||
catch_block = block[catch_start:]
|
||||
|
||||
assert "if (_gen !== _renderSessionListGen) return;" in catch_block
|
||||
assert catch_block.index("if (_gen !== _renderSessionListGen) return;") < catch_block.index(
|
||||
"_showSessionListLoadError(e);"
|
||||
)
|
||||
|
||||
|
||||
def test_json_helper_can_emit_compact_json_for_large_list_endpoints():
|
||||
from api.helpers import _json_response_body
|
||||
|
||||
body = _json_response_body({"a": 1, "nested": {"b": 2}}, pretty=False).decode("utf-8")
|
||||
|
||||
assert body == '{"a":1,"nested":{"b":2}}'
|
||||
assert "\n" not in body
|
||||
Loading…
Reference in a new issue