Files
hermes-webui/tests/test_session_default_window.py
T
happy5318 f33ac82ca1 fix(session): guard truncation flags before read in settle strategy (CI)
test_issue6867 extracts _restoreSettledSession into a bare harness where
the module-level _messagesTruncated/_oldestIdx lets are absent; a bare
read threw ReferenceError and the whole recovery path died before it
could restore. Use typeof guards like the surrounding paging restores.
The semantics are unchanged in the real page (both are always declared).
2026-09-19 13:35:13 +08:00

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=(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"