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:
nesquena-hermes 2026-06-16 18:42:01 +00:00
parent 267342e9f2
commit 0b57a7101f
7 changed files with 431 additions and 21 deletions

View file

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

View file

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

View file

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

View file

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

View 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"

View file

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

View 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