Two data-integrity blockers from round-3 review (nesquena-hermes 2026-09-19): 1. [CORE] A successful /compress returns the full transcript, but _applyManualCompressionResult kept the bounded preflight's paging signals (_messagesTruncated/_oldestIdx). Render now restores them before renderMessages(), same ordering as settle/cancel paths. 2. [SILENT] _restoreSettledSession preservation used (prefix || suffix), so repeated identical turns (colliding _messageIdentityKey) made BOTH comparisons succeed and the prefix branch spliced at the wrong offset - silently dropping rows and duplicating others. The server truncation signal now decides the strategy: truncated => suffix-exclusive, untruncated => prefix. Tests: node behavioral driver covering /compress paging restore, repeated-turn settle (no drop/dup, authoritative offset), and gap-free msg_before paging after bounded recovery.
229 lines
9.5 KiB
Python
229 lines
9.5 KiB
Python
"""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=!!_messagesTruncated||!!(_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" |