"""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 _restore_settled_session_body(): start = _MESSAGES_JS.index("async function _restoreSettledSession") end = _MESSAGES_JS.index("function _handleStreamError", start) return _MESSAGES_JS[start:end] def _apply_cancel_session_payload_body(): start = _MESSAGES_JS.index("const _applyCancelSessionPayload=(sessionPayload)=>") end = _MESSAGES_JS.index("Prefer the canonical session snapshot", start) return _MESSAGES_JS[start:end] def test_settle_refreshes_paging_before_anchor_persist(): """Anchor persist reads _oldestIdx; a bounded tail must refresh it first (#7628).""" restore = _restore_settled_session_body() offset_idx = restore.index("_oldestIdx=session._messages_offset||0") attach_idx = restore.index("_attachProjectedAnchorSceneToLastAssistant(S.messages);") assert offset_idx < attach_idx def test_cancel_refreshes_paging_before_anchor_persist(): """Cancel recovery has the same persist-before-offset trap as settle (#7628).""" cancel = _apply_cancel_session_payload_body() offset_idx = cancel.index("_oldestIdx=sessionPayload._messages_offset||0") attach_idx = cancel.index("_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);") assert offset_idx < attach_idx def test_settle_preserves_terminal_marker_against_bounded_suffix(): """Long-session bounded tails are suffixes of S.messages, not prefixes (#7628).""" restore = _restore_settled_session_body() assert "_stagedMatchesCurrentSuffix" in restore assert "(_truncatedRecovery?_stagedMatchesCurrentSuffix:_stagedMatchesCurrentPrefix)" in restore def test_settle_truncation_signal_decides_match_strategy(): """The server's truncation signal must decide prefix-vs-suffix, never an `||`: bounded settles get suffix-exclusive matching (identical repeated turns make BOTH comparisons succeed; the prefix branch then splices at the wrong offset and silently drops/duplicates rows) (#7628).""" restore = _restore_settled_session_body() assert "_truncatedRecovery=(typeof _messagesTruncated!=='undefined'&&!!_messagesTruncated)||(typeof _oldestIdx!=='undefined'&&!!(_oldestIdx>0))" in restore # The splice offset must follow the SAME truncation decision as the match # strategy — no fallthrough to prefix when both match. assert "_truncatedRecovery?_stagedSuffixStart+_stagedMessages.length:_stagedMessages.length" in restore def test_apperror_embedded_session_refreshes_paging_before_anchor_persist(): """The apperror embedded-session path has the same persist-before-offset trap (#7628).""" start = _MESSAGES_JS.index("source.addEventListener('apperror'") end = _MESSAGES_JS.index("source.addEventListener('warning'", start) block = _MESSAGES_JS[start:end] offset_idx = block.index("_oldestIdx=d.session._messages_offset||0") attach_idx = block.index("_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);") assert offset_idx < attach_idx 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"