diff --git a/api/helpers.py b/api/helpers.py index 1acfc32c..2ac46cc8 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -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') diff --git a/api/routes.py b/api/routes.py index 2b96bb54..dcfd9db4 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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() diff --git a/static/sessions.js b/static/sessions.js index 5d4812bc..8ba9dd77 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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'; diff --git a/static/workspace.js b/static/workspace.js index 86439ecc..a64e4e2c 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -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;attempt0; 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&&attemptsetTimeout(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&&attemptsetTimeout(resolve,retryDelayMs*Math.pow(2,attempt))); + continue; + } throw e; }finally{ if(timeoutId) clearTimeout(timeoutId); diff --git a/tests/test_4167_sidebar_payload_and_scope.py b/tests/test_4167_sidebar_payload_and_scope.py new file mode 100644 index 00000000..1f0bd41e --- /dev/null +++ b/tests/test_4167_sidebar_payload_and_scope.py @@ -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" diff --git a/tests/test_issue1611_session_profile_filtering.py b/tests/test_issue1611_session_profile_filtering.py index 4f0a0696..435ff065 100644 --- a/tests/test_issue1611_session_profile_filtering.py +++ b/tests/test_issue1611_session_profile_filtering.py @@ -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" ) diff --git a/tests/test_session_sidebar_resilience.py b/tests/test_session_sidebar_resilience.py new file mode 100644 index 00000000..eae5a680 --- /dev/null +++ b/tests/test_session_sidebar_resilience.py @@ -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