Files
hermes-webui/tests/test_issue500_message_list_virtualization.py
T

1144 lines
44 KiB
Python

"""Regression coverage for issue #500 transcript virtualization."""
import json
import shutil
import subprocess
import tempfile
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent.resolve()
UI_JS_PATH = REPO_ROOT / "static" / "ui.js"
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
def _run_node(source: str) -> str:
with tempfile.NamedTemporaryFile(
"w", suffix=".cjs", encoding="utf-8", dir=REPO_ROOT, delete=False
) as script:
script.write(source)
script_path = Path(script.name)
try:
result = subprocess.run(
[NODE, str(script_path)],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=30,
)
finally:
script_path.unlink(missing_ok=True)
if result.returncode != 0:
raise RuntimeError(result.stderr)
return result.stdout.strip()
def _extract_func_script(js: str) -> str:
return f"""
const src = {js!r};
function extractFunc(name) {{
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
const start = src.search(re);
if (start < 0) throw new Error(name + ' not found');
let i = src.indexOf('{{', start);
let depth = 1; i++;
while (depth > 0 && i < src.length) {{
if (src[i] === '{{') depth++;
else if (src[i] === '}}') depth--;
i++;
}}
return src.slice(start, i);
}}
"""
def test_message_virtual_window_virtualizes_older_history_but_keeps_recent_tail():
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
eval(extractFunc('_messageVirtualWindow'));
const metrics = _messageVirtualWindow({
total: 240,
scrollTop: 120 * 70,
viewportHeight: 720,
heights: Array.from({length: 240}, (_, i) => i >= 190 ? 220 : 120),
defaultHeight: 120,
bufferPx: 240,
threshold: 80,
keepTailCount: 50,
});
console.log(JSON.stringify(metrics));
"""
metrics = json.loads(_run_node(source))
assert metrics["virtualized"] is True
assert 60 <= metrics["start"] <= 75
assert metrics["end"] <= metrics["tailStart"] == 190
assert metrics["topPad"] > 0
assert metrics["bottomPad"] > 0
def test_message_virtual_window_collapses_to_tail_only_near_bottom():
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
eval(extractFunc('_messageVirtualWindow'));
const metrics = _messageVirtualWindow({
total: 240,
scrollTop: 120 * 260,
viewportHeight: 720,
heights: Array.from({length: 240}, () => 120),
defaultHeight: 120,
bufferPx: 240,
threshold: 80,
keepTailCount: 50,
});
console.log(JSON.stringify(metrics));
"""
metrics = json.loads(_run_node(source))
assert metrics["virtualized"] is True
assert metrics["start"] == metrics["tailStart"] == 190
assert metrics["end"] == metrics["tailStart"]
assert metrics["bottomPad"] == 0
def test_render_messages_uses_virtual_window_and_spacer_measurement_path():
js = UI_JS_PATH.read_text(encoding="utf-8")
render_start = js.index("function renderMessages(options)")
render_end = js.index("function _toolDisplayName", render_start)
render_body = js[render_start:render_end]
assert "_currentMessageVirtualWindow(visWithIdx,_messageVirtualKeepTailCount())" in render_body
assert "const renderVisibleIdxs=[" in render_body
assert "_messageVirtualSpacer(virtualWindow.topPad,'before')" in render_body
assert "_messageVirtualSpacer(virtualWindow.bottomPad,'after')" in render_body
assert "_updateMessageVirtualMeasurements(renderVisWithIdx, renderVisibleIdxs, virtualWindow);" in render_body
assert "const renderableRawIdxs=new Set(visWithIdx.map(e=>e.rawIdx));" in render_body
assert "if(virtualWindow.virtualized&&renderableRawIdxs.has(aIdx)&&!renderedRawIdxs.has(aIdx)) continue;" in render_body
assert "if(hasServerOlder){" in render_body
assert "_showEarlierRenderedMessages();" not in render_body
top_spacer_idx = render_body.index("_messageVirtualSpacer(virtualWindow.topPad,'before')")
indicator_idx = render_body.index("indicator.id='loadOlderIndicator';")
assert top_spacer_idx < indicator_idx, (
"renderMessages() must place the load-older affordance after the top "
"virtual spacer so it stays visible at the top of the rendered window."
)
gap_reset_idx = render_body.index("currentAssistantTurn=null;", render_body.index("_messageVirtualSpacer(virtualWindow.bottomPad,'after')") - 220)
gap_spacer_idx = render_body.index("_messageVirtualSpacer(virtualWindow.bottomPad,'after')")
assert gap_reset_idx < gap_spacer_idx, (
"renderMessages() must reset currentAssistantTurn before inserting the "
"virtual gap spacer so assistant bubbles do not merge across the head/tail boundary."
)
def test_measurement_uses_one_primary_row_and_adjacent_activity_siblings_only():
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
eval(extractFunc('_measureMessageVirtualRow'));
const nextMessage = {
hasAttribute(name){ return name === 'data-msg-idx'; },
getBoundingClientRect(){ return {height: 999}; },
nextElementSibling: null,
};
const activityGroup = {
hasAttribute(){ return false; },
matches(selector){ return selector === '.tool-call-group,.tool-card-row,.agent-activity-thinking,.thinking-card-row'; },
getBoundingClientRect(){ return {height: 60}; },
nextElementSibling: {
hasAttribute(){ return false; },
matches(){ return false; },
getBoundingClientRect(){ return {height: 5000}; },
nextElementSibling: nextMessage,
},
};
const primary = {
classList: { contains(name){ return name === 'assistant-segment'; } },
getBoundingClientRect(){ return {height: 120}; },
nextElementSibling: activityGroup,
};
const inner = {
querySelector(selector){
if(selector === '[data-msg-idx="42"]') return primary;
return null;
},
};
console.log(JSON.stringify({
total: _measureMessageVirtualRow(inner, {rawIdx: 42}),
}));
"""
metrics = json.loads(_run_node(source))
assert metrics["total"] == 180
def test_virtual_keep_tail_count_stays_bounded_after_history_expands_render_window():
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
const MESSAGE_RENDER_WINDOW_DEFAULT = 50;
let _messageRenderWindowSize = 240;
eval(extractFunc('_currentMessageRenderWindowSize'));
eval(extractFunc('_messageVirtualKeepTailCount'));
console.log(JSON.stringify({
renderWindowSize: _currentMessageRenderWindowSize(),
keepTailCount: _messageVirtualKeepTailCount(),
}));
"""
metrics = json.loads(_run_node(source))
assert metrics["renderWindowSize"] == 240
assert metrics["keepTailCount"] == 50
def test_virtual_prepended_height_delta_uses_prefix_cache_only_when_virtualized():
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS = {
user: 120,
assistant: 160,
tool_call: 400,
default: 140,
};
eval(extractFunc('_messageVirtualDefaultHeightForRole'));
eval(extractFunc('_messageVirtualRoleForEntry'));
let virtualized = true;
let _messageVirtualHeightCache = [0, 220, 180, 120];
let _messageVirtualEstimatedRowHeight = 140;
function _getVisibleMessagesWithIdx(){
return [{rawIdx: 0}, {rawIdx: 1}, {rawIdx: 2}, {rawIdx: 3}];
}
function _messageVirtualKeepTailCount(){ return 2; }
function _currentMessageVirtualWindow(){
return {virtualized};
}
eval(extractFunc('_messageVirtualPrependedHeightDelta'));
const active = _messageVirtualPrependedHeightDelta(3);
virtualized = false;
const inactive = _messageVirtualPrependedHeightDelta(3);
console.log(JSON.stringify({active, inactive}));
"""
metrics = json.loads(_run_node(source))
assert metrics["active"] == 540
assert metrics["inactive"] is None
def test_virtual_question_jump_scroll_target_uses_visible_index_height_prefix():
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
let _messageVirtualHeightCache = [100, 120, 80, 140];
let _messageVirtualHeightCacheEntries = [];
let _messageVirtualHeightCacheLen = 4;
let _messageVirtualHeightCacheSrc = null;
let _messageVirtualEstimatedRowHeight = 110;
let _messageVirtualWindowKey = 'old';
let S = {messages: [{}, {}, {}, {}]};
function _messageIsRenderable(){ return true; }
eval(extractFunc('_messageVirtualHeightEntryMatches'));
eval(extractFunc('_syncMessageVirtualHeightCache'));
eval(extractFunc('_messageVisibleIndexForRawIdx'));
eval(extractFunc('_messageVirtualScrollTopForVisibleIdx'));
const visWithIdx = [
{rawIdx: 10, m: S.messages[0]},
{rawIdx: 12, m: S.messages[1]},
{rawIdx: 14, m: S.messages[2]},
{rawIdx: 16, m: S.messages[3]},
];
_messageVirtualHeightCacheEntries = visWithIdx;
_messageVirtualHeightCacheSrc = S.messages;
const visibleIdx = _messageVisibleIndexForRawIdx(14, visWithIdx);
const scrollTop = _messageVirtualScrollTopForVisibleIdx(visWithIdx, visibleIdx, {clientHeight: 200});
console.log(JSON.stringify({visibleIdx, scrollTop}));
"""
metrics = json.loads(_run_node(source))
assert metrics["visibleIdx"] == 2
assert metrics["scrollTop"] == 150
def test_height_cache_preserves_measured_prefix_across_append_only_growth():
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140;
let _messageVirtualHeightCache = [180, 220];
let _messageVirtualHeightCacheEntries = [];
let _messageVirtualHeightCacheLen = 2;
let _messageVirtualHeightCacheSrc = null;
let _messageVirtualEstimatedRowHeight = 200;
let _messageVirtualWindowKey = 'stale-key';
function _clearMessageVirtualHeightCache() {
_messageVirtualHeightCache = [];
_messageVirtualHeightCacheEntries = [];
_messageVirtualHeightCacheLen = 0;
_messageVirtualHeightCacheSrc = null;
_messageVirtualEstimatedRowHeight = MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT;
_messageVirtualWindowKey = '';
}
eval(extractFunc('_messageVirtualHeightEntryMatches'));
eval(extractFunc('_messageVirtualHeightPrefixEntryMatches'));
eval(extractFunc('_syncMessageVirtualHeightCache'));
const first = {id: 'first'};
const second = {id: 'second'};
let S = {messages: [first, second]};
_messageVirtualHeightCacheEntries = [
{rawIdx: 0, m: first},
{rawIdx: 1, m: second},
];
_messageVirtualHeightCacheSrc = S.messages;
S = {messages: [first, second, {id: 'third'}]};
_syncMessageVirtualHeightCache([
{rawIdx: 0, m: first},
{rawIdx: 1, m: second},
{rawIdx: 2, m: S.messages[2]},
]);
console.log(JSON.stringify({
cache: _messageVirtualHeightCache,
estimated: _messageVirtualEstimatedRowHeight,
windowKey: _messageVirtualWindowKey,
}));
"""
metrics = json.loads(_run_node(source))
assert metrics["cache"][:2] == [180, 220]
assert len(metrics["cache"]) == 3
assert metrics["estimated"] == 200
assert metrics["windowKey"] == ""
def test_height_cache_preserves_measured_suffix_across_prepended_history():
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140;
let _messageVirtualHeightCache = [180, 220];
let _messageVirtualHeightCacheEntries = [];
let _messageVirtualHeightCacheLen = 2;
let _messageVirtualHeightCacheSrc = null;
let _messageVirtualEstimatedRowHeight = 200;
let _messageVirtualWindowKey = 'stale-key';
function _clearMessageVirtualHeightCache() {
_messageVirtualHeightCache = [];
_messageVirtualHeightCacheEntries = [];
_messageVirtualHeightCacheLen = 0;
_messageVirtualHeightCacheSrc = null;
_messageVirtualEstimatedRowHeight = MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT;
_messageVirtualWindowKey = '';
}
eval(extractFunc('_messageVirtualHeightEntryMatches'));
eval(extractFunc('_messageVirtualHeightPrefixEntryMatches'));
eval(extractFunc('_syncMessageVirtualHeightCache'));
const first = {id: 'first'};
const second = {id: 'second'};
let S = {messages: [first, second]};
_messageVirtualHeightCacheEntries = [
{rawIdx: 0, m: first},
{rawIdx: 1, m: second},
];
_messageVirtualHeightCacheSrc = S.messages;
const olderA = {id: 'older-a'};
const olderB = {id: 'older-b'};
S = {messages: [olderA, olderB, first, second]};
_syncMessageVirtualHeightCache([
{rawIdx: 0, m: olderA},
{rawIdx: 1, m: olderB},
{rawIdx: 2, m: first},
{rawIdx: 3, m: second},
]);
console.log(JSON.stringify({
cache: _messageVirtualHeightCache,
estimated: _messageVirtualEstimatedRowHeight,
windowKey: _messageVirtualWindowKey,
}));
"""
metrics = json.loads(_run_node(source))
assert metrics["cache"][2:] == [180, 220]
assert len(metrics["cache"]) == 4
assert metrics["estimated"] == 200
assert metrics["windowKey"] == ""
def test_measurement_refresh_budget_is_keyed_to_window_shape_not_pad_height():
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
eval(extractFunc('_messageVirtualMeasurementCycleKeyFor'));
console.log(JSON.stringify({
a: _messageVirtualMeasurementCycleKeyFor({virtualized: true, start: 10, end: 20, topPad: 1000, bottomPad: 2000, tailStart: 190}),
b: _messageVirtualMeasurementCycleKeyFor({virtualized: true, start: 10, end: 20, topPad: 1001, bottomPad: 1999, tailStart: 190}),
}));
"""
metrics = json.loads(_run_node(source))
assert metrics["a"] == metrics["b"]
def test_tool_rows_do_not_carry_message_measurement_hook():
js = UI_JS_PATH.read_text(encoding="utf-8")
build_start = js.index("function buildToolCard(tc){")
build_end = js.index("function _colorDiffLines", build_start)
build_body = js[build_start:build_end]
assert "row.dataset.msgIdx" not in build_body
assert "querySelectorAll(`[data-msg-idx=" not in js
def test_viewport_intersection_helper_detects_visible_rendered_rows_only():
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
let rows = [];
const container = {
getBoundingClientRect(){ return {top: 100, bottom: 300}; },
querySelectorAll(selector){
if(selector === '[data-msg-idx]') return rows;
return [];
},
};
function $(id){ return id === 'messages' ? container : null; }
eval(extractFunc('_messageViewportIntersectsRenderedRow'));
rows = [
{ getBoundingClientRect(){ return {top: 10, bottom: 90}; } },
{ getBoundingClientRect(){ return {top: 320, bottom: 360}; } },
];
const blank = _messageViewportIntersectsRenderedRow();
rows = [
{ getBoundingClientRect(){ return {top: 120, bottom: 180}; } },
];
const visible = _messageViewportIntersectsRenderedRow();
console.log(JSON.stringify({blank, visible}));
"""
metrics = json.loads(_run_node(source))
assert metrics["blank"] is False
assert metrics["visible"] is True
def test_render_messages_has_one_shot_virtual_blank_viewport_fallback():
js = UI_JS_PATH.read_text(encoding="utf-8")
render_start = js.index("function renderMessages(options)")
render_end = js.index("function _toolDisplayName", render_start)
render_body = js[render_start:render_end]
assert "const virtualFallback=!!(options&&options._virtualFallback);" in render_body
assert "const virtualWindow=virtualFallback" in render_body
assert "if(_maybeRecoverVirtualizedBlankViewport(options, preserveScroll, virtualWindow)) return;" in render_body
assert "if(_sessionHtmlCacheSid&&S.session&&S.session.session_id===_sessionHtmlCacheSid){" in js
assert "_sessionHtmlCache.delete(_sessionHtmlCacheSid);" in js
assert "renderMessages({preserveScroll:true,_virtualFallback:true});" in js
def test_virtual_blank_viewport_recovery_evicts_stale_cache_before_fallback():
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
let deletes = [];
let renderCalls = [];
const _sessionHtmlCache = {
delete(sid){ deletes.push(sid); }
};
let _sessionHtmlCacheSid = 'sid-123';
const S = { session: { session_id: 'sid-123' } };
function _messageViewportIntersectsRenderedRow(){ return false; }
function renderMessages(options){ renderCalls.push(options); }
eval(extractFunc('_maybeRecoverVirtualizedBlankViewport'));
const recovered = _maybeRecoverVirtualizedBlankViewport({preserveScroll:false, someFlag:true}, true, {virtualized:true});
console.log(JSON.stringify({recovered, deletes, renderCalls}));
"""
metrics = json.loads(_run_node(source))
assert metrics["recovered"] is True
assert metrics["deletes"] == ["sid-123"]
assert metrics["renderCalls"] == [{"preserveScroll": True, "_virtualFallback": True}]
def test_same_frame_restore_nudges_virtual_window_when_anchor_row_is_missing():
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + r"""
const ROW_HEIGHT = 120;
const TOTAL = 60;
// Rows 10-59 are mounted (tail window); rows 0-9 are virtualized out.
// Anchor points to rawIdx=5, which is in the virtualized zone.
let mountedStart = 10;
let scrollTopValue = 0;
let renderCalls = [];
let scrollTopHistory = [];
let _messageVirtualWindowKey = 'stale-key';
let _programmaticScroll = false;
let _lastScrollTop = 0;
let _messageUserUnpinned = true;
let _scrollPinned = false;
let _nearBottomCount = 0;
let _messageViewportAnchorRemounting = false;
let _messageVirtualHeightCache = Array.from({length: TOTAL}, () => ROW_HEIGHT);
let _messageVirtualHeightCacheEntries = [];
let _messageVirtualHeightCacheLen = TOTAL;
let _messageVirtualHeightCacheSrc = null;
let _messageVirtualEstimatedRowHeight = ROW_HEIGHT;
function _clearMessageVirtualHeightCache(){}
function _syncMessageVirtualHeightCache(){}
const container = {
get scrollTop(){ return scrollTopValue; },
set scrollTop(v){ scrollTopHistory.push(v); scrollTopValue = v; },
get scrollHeight(){ return TOTAL * ROW_HEIGHT; },
get clientHeight(){ return 600; },
getBoundingClientRect(){ return {top: 0, bottom: 600}; },
querySelector(selector){
const m = selector && selector.match(/\[data-msg-idx="(\d+)"\]/);
if(!m) return null;
const idx = Number(m[1]);
if(idx < mountedStart || idx >= TOTAL) return null;
const top = (idx - mountedStart) * ROW_HEIGHT;
return { getBoundingClientRect(){ return {top, bottom: top + ROW_HEIGHT}; } };
},
};
function $(id){ return id === 'messages' ? container : null; }
function _getVisibleMessagesWithIdx(){
return Array.from({length: TOTAL}, (_, i) => ({rawIdx: i}));
}
function renderMessages(opts){
renderCalls.push(JSON.parse(JSON.stringify(opts)));
mountedStart = 0;
}
function _restoreMessageViewportAnchor(anchor, delta){
const idx = Number(anchor.rawIdx) + Number(delta||0);
const row = container.querySelector(`[data-msg-idx="${idx}"]`);
if(!row) return false;
_programmaticScroll = true;
return true;
}
function requestAnimationFrame(fn){ fn(); }
eval(extractFunc('_messageVisibleIndexForRawIdx'));
eval(extractFunc('_messageSessionIndexBase'));
eval(extractFunc('_messageSessionIndexForRawIdx'));
eval(extractFunc('_messageRawIdxForSessionIndex'));
eval(extractFunc('_messageVirtualScrollTopForVisibleIdx'));
eval(extractFunc('_remountMessageViewportAnchor'));
eval(extractFunc('_restorePinnedMessageScrollSnapshot'));
eval(extractFunc('_restoreMessageScrollSnapshotSameFrame'));
const snapshot = {
anchor: {rawIdx: 5, topOffset: 50},
top: 100,
bottom: 6600,
scrollHeight: 7200,
pinned: false,
userUnpinned: true,
};
_restoreMessageScrollSnapshotSameFrame(snapshot);
console.log(JSON.stringify({renderCalls, scrollTopHistory}));
"""
metrics = json.loads(_run_node(source))
assert len(metrics["renderCalls"]) == 1, (
"_restoreMessageScrollSnapshotSameFrame must call renderMessages to mount the virtualized-out anchor row"
)
assert metrics["renderCalls"][0].get("preserveScroll") is True, (
"re-render must use preserveScroll:true to avoid scrolling to bottom"
)
assert len(metrics["scrollTopHistory"]) >= 1, (
"scrollTop must be adjusted before re-render to place anchor row in the virtual window"
)
# rawIdx=5, visIdx=5: offset=5*120=600, viewport=600, scrollTop=round(600-600*0.35)=390
assert metrics["scrollTopHistory"][0] == 390
def test_virtualize_transcript_opt_out_forces_full_render_window():
"""#4325: when window._virtualizeTranscript===false, _currentMessageVirtualWindow
must return a non-virtualized full window even for a long (>threshold) transcript,
so the whole transcript renders. When true/undefined it virtualizes as before."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS={
user:120,
assistant:160,
tool_call:400,
default:140,
};
function _messageVirtualDefaultHeightForRole(role){
return MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS[
role&&Object.prototype.hasOwnProperty.call(MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS,role)?role:'default'
];
}
const MESSAGE_VIRTUAL_THRESHOLD_ROWS = 80;
const MESSAGE_VIRTUAL_BUFFER_PX = 900;
let _messageVirtualHeightCache = [];
let _messageVirtualEstimatedRowHeight = 140;
function _syncMessageVirtualHeightCache(){ /* no-op for the test */ }
function $(id){ return {scrollTop: 5000, clientHeight: 720}; }
function _messageVirtualRoleForEntry(){ return 'default'; }
const window = {};
eval(extractFunc('_messageVirtualWindow'));
eval(extractFunc('_currentMessageVirtualWindow'));
// 200 visible messages — well over the 80 threshold
const visWithIdx = Array.from({length: 200}, (_, i) => ({rawIdx: i}));
// OFF: opt-out → full render
window._virtualizeTranscript = false;
const off = _currentMessageVirtualWindow(visWithIdx, 50);
// ON (default): virtualizes
window._virtualizeTranscript = true;
const on = _currentMessageVirtualWindow(visWithIdx, 50);
// UNDEFINED: also virtualizes (opt-out only when explicitly false)
delete window._virtualizeTranscript;
const undef = _currentMessageVirtualWindow(visWithIdx, 50);
console.log(JSON.stringify({off, on, undef}));
"""
metrics = json.loads(_run_node(source))
# OFF → full, non-virtualized window covering every row
assert metrics["off"]["virtualized"] is False
assert metrics["off"]["start"] == 0
assert metrics["off"]["end"] == 200
assert metrics["off"]["topPad"] == 0
assert metrics["off"]["bottomPad"] == 0
# ON → virtualized (only a window of the 200 rows)
assert metrics["on"]["virtualized"] is True
assert metrics["on"]["end"] - metrics["on"]["start"] < 200
# UNDEFINED → still virtualizes (opt-out is explicit-false only)
assert metrics["undef"]["virtualized"] is True
def test_virtualize_transcript_gate_present_in_current_window_fn():
"""The opt-out gate must live in _currentMessageVirtualWindow (the single
chokepoint), guarding on window._virtualizeTranscript===false."""
js = UI_JS_PATH.read_text(encoding="utf-8")
start = js.index("function _currentMessageVirtualWindow(")
body = js[start:start + 900]
assert "_virtualizeTranscript===false" in body, (
"opt-out gate must check window._virtualizeTranscript===false in "
"_currentMessageVirtualWindow"
)
assert "virtualized:false" in body, "gate must return a non-virtualized window when opted out"
def test_message_virtual_default_height_for_role_returns_correct_heights():
"""Verify per-role default heights are configured."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS={
user:120,
assistant:160,
tool_call:400,
default:140,
};
eval(extractFunc('_messageVirtualDefaultHeightForRole'));
console.log(JSON.stringify({
tool_call: _messageVirtualDefaultHeightForRole('tool_call'),
user: _messageVirtualDefaultHeightForRole('user'),
assistant: _messageVirtualDefaultHeightForRole('assistant'),
unknown: _messageVirtualDefaultHeightForRole('unknown'),
default: _messageVirtualDefaultHeightForRole('default'),
}));
"""
metrics = json.loads(_run_node(source))
assert metrics["tool_call"] == 400
assert metrics["user"] == 120
assert metrics["assistant"] == 160
assert metrics["unknown"] == 140
assert metrics["default"] == 140
def test_message_virtual_role_for_entry_classifies_tool_calls():
"""Verify role classifier detects tool_calls, tool_use content, and _partial_tool_calls."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
eval(extractFunc('_messageVirtualRoleForEntry'));
console.log(JSON.stringify({
userRole: _messageVirtualRoleForEntry({m: {role: 'user'}}),
assistantNoTools: _messageVirtualRoleForEntry({m: {role: 'assistant'}}),
assistantWithToolCalls: _messageVirtualRoleForEntry({m: {role: 'assistant', tool_calls: [{id: '1'}]}}),
assistantWithToolUse: _messageVirtualRoleForEntry({m: {role: 'assistant', content: [{type: 'tool_use', id: '1'}]}}),
assistantWithPartialToolCalls: _messageVirtualRoleForEntry({m: {role: 'assistant', _partial_tool_calls: [{id: '1'}]}}),
noEntry: _messageVirtualRoleForEntry(null),
noMessage: _messageVirtualRoleForEntry({m: null}),
}));
"""
metrics = json.loads(_run_node(source))
assert metrics["userRole"] == "user"
assert metrics["assistantNoTools"] == "assistant"
assert metrics["assistantWithToolCalls"] == "tool_call"
assert metrics["assistantWithToolUse"] == "tool_call"
assert metrics["assistantWithPartialToolCalls"] == "tool_call"
assert metrics["noEntry"] == "default"
assert metrics["noMessage"] == "default"
def test_message_virtual_window_with_role_for_idx_uses_role_defaults():
"""Verify _messageVirtualWindow uses role-specific heights when roleForIdx is provided."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS={
user:120,
assistant:160,
tool_call:400,
default:140,
};
const MESSAGE_VIRTUAL_THRESHOLD_ROWS = 80;
const MESSAGE_VIRTUAL_BUFFER_PX = 900;
eval(extractFunc('_messageVirtualDefaultHeightForRole'));
eval(extractFunc('_messageVirtualWindow'));
const visWithIdx = [
{m: {role: 'user'}},
{m: {role: 'assistant', tool_calls: [{id: '1'}]}},
{m: {role: 'assistant'}},
];
const metrics = _messageVirtualWindow({
total: 3,
scrollTop: 0,
viewportHeight: 600,
heights: [0, 0, 0],
defaultHeight: 140,
roleForIdx: (idx) => {
const entry = visWithIdx[idx];
if(!entry || !entry.m) return 'default';
if(entry.m.role === 'user') return 'user';
if(entry.m.role === 'assistant'){
if(Array.isArray(entry.m.tool_calls) && entry.m.tool_calls.length > 0) return 'tool_call';
return 'assistant';
}
return 'default';
},
bufferPx: 0,
threshold: 2,
keepTailCount: 0,
});
console.log(JSON.stringify({
virtualized: metrics.virtualized,
start: metrics.start,
end: metrics.end,
}));
"""
metrics = json.loads(_run_node(source))
# With 3 rows (120 + 400 + 160 = 680px) and viewport 600px, should not virtualize (below threshold)
# So this checks that the role-based defaults are being computed
assert metrics["end"] - metrics["start"] > 0
def test_message_virtual_window_cached_heights_override_role_defaults():
"""Verify cached heights take precedence over role-specific defaults."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS={
user:120,
assistant:160,
tool_call:400,
default:140,
};
const MESSAGE_VIRTUAL_THRESHOLD_ROWS = 80;
const MESSAGE_VIRTUAL_BUFFER_PX = 900;
eval(extractFunc('_messageVirtualDefaultHeightForRole'));
eval(extractFunc('_messageVirtualWindow'));
const visWithIdx = [
{m: {role: 'user'}}, // normally 120
{m: {role: 'assistant', tool_calls: [{id: '1'}]}}, // normally 400
];
// Test case 1: with cached heights 250+300=550px < 600px viewport, no virtualization needed
const metricsSmall = _messageVirtualWindow({
total: 2,
scrollTop: 0,
viewportHeight: 600,
heights: [250, 300], // Cached heights override roles
defaultHeight: 140,
roleForIdx: (idx) => {
const entry = visWithIdx[idx];
if(!entry || !entry.m) return 'default';
if(entry.m.role === 'user') return 'user';
if(entry.m.role === 'assistant'){
if(Array.isArray(entry.m.tool_calls) && entry.m.tool_calls.length > 0) return 'tool_call';
return 'assistant';
}
return 'default';
},
bufferPx: 0,
threshold: 80,
keepTailCount: 0,
});
// Test case 2: with many rows and cached heights, should use cached heights not role defaults
const largeList = Array.from({length: 100}, (_, i) => ({m: {role: i % 2 ? 'user' : 'assistant'}}));
const cachedHeights = Array.from({length: 100}, (_, i) => 200); // All 200px when cached
const metricsLarge = _messageVirtualWindow({
total: 100,
scrollTop: 0,
viewportHeight: 600,
heights: cachedHeights,
defaultHeight: 140,
roleForIdx: (idx) => {
if(largeList[idx]?.m?.role === 'user') return 'user';
return 'assistant';
},
bufferPx: 0,
threshold: 80,
keepTailCount: 0,
});
console.log(JSON.stringify({
smallVirtualized: metricsSmall.virtualized,
largeVirtualized: metricsLarge.virtualized,
largeHasWindow: metricsLarge.end > metricsLarge.start,
}));
"""
metrics = json.loads(_run_node(source))
# With 2 rows below threshold, should not virtualize
assert metrics["smallVirtualized"] is False
# With 100 rows above threshold, should virtualize and use cached heights
assert metrics["largeVirtualized"] is True
assert metrics["largeHasWindow"] is True
def test_offset_helpers_use_per_role_defaults_for_uncached_rows():
"""Verify _messageVirtualScrollTopForVisibleIdx and _messageVirtualPrependedHeightDelta
use per-role default heights (not the flat 140px estimate) for uncached rows,
and that these agree with _messageVirtualWindow's own accounting."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS = {
user: 120,
assistant: 160,
tool_call: 400,
default: 140,
};
eval(extractFunc('_messageVirtualDefaultHeightForRole'));
eval(extractFunc('_messageVirtualRoleForEntry'));
// Three entries: user (120), tool_call (400), assistant (160) — all uncached (height=0)
const visWithIdx = [
{rawIdx: 0, m: {role: 'user'}},
{rawIdx: 1, m: {role: 'assistant', tool_calls: [{id: 'x'}]}},
{rawIdx: 2, m: {role: 'assistant'}},
];
// --- _messageVirtualScrollTopForVisibleIdx ---
let _messageVirtualHeightCache = [0, 0, 0];
let _messageVirtualHeightCacheEntries = [];
let _messageVirtualHeightCacheLen = 3;
let _messageVirtualHeightCacheSrc = null;
let _messageVirtualEstimatedRowHeight = 140;
let _messageVirtualWindowKey = '';
let S = {messages: visWithIdx.map(e => e.m)};
function _messageIsRenderable(){ return true; }
eval(extractFunc('_messageVirtualHeightEntryMatches'));
eval(extractFunc('_syncMessageVirtualHeightCache'));
eval(extractFunc('_messageVirtualScrollTopForVisibleIdx'));
// scrollTop to visibleIdx=2 must sum heights of idx 0 (120) and idx 1 (400) = 520
_messageVirtualHeightCacheEntries = visWithIdx;
_messageVirtualHeightCacheSrc = S.messages;
const scrollTop = _messageVirtualScrollTopForVisibleIdx(visWithIdx, 2, null);
// --- _messageVirtualPrependedHeightDelta ---
let virtualized2 = true;
let _messageVirtualHeightCache2 = [0, 0, 0];
let _messageVirtualEstimatedRowHeight2 = 140;
function _getVisibleMessagesWithIdx(){ return visWithIdx; }
function _messageVirtualKeepTailCount(){ return 0; }
function _currentMessageVirtualWindow(){ return {virtualized: virtualized2}; }
// Patch cache var used by _messageVirtualPrependedHeightDelta
eval(extractFunc('_messageVirtualPrependedHeightDelta').replace(
/_messageVirtualHeightCache/g, '_messageVirtualHeightCache2'
).replace(
/_messageVirtualEstimatedRowHeight/g, '_messageVirtualEstimatedRowHeight2'
));
// Sum of first 3 uncached entries: user(120) + tool_call(400) + assistant(160) = 680
const delta = _messageVirtualPrependedHeightDelta(3);
// --- _messageVirtualWindow agreement ---
const MESSAGE_VIRTUAL_THRESHOLD_ROWS = 2;
const MESSAGE_VIRTUAL_BUFFER_PX = 0;
eval(extractFunc('_messageVirtualWindow'));
const win = _messageVirtualWindow({
total: 3,
scrollTop: 0,
viewportHeight: 600,
heights: [0, 0, 0],
defaultHeight: 140,
roleForIdx: idx => _messageVirtualRoleForEntry(visWithIdx[idx]),
bufferPx: 0,
threshold: 2,
keepTailCount: 0,
});
// topPad is sum of rows before win.start; with start=0 it is 0, but
// the window must have consumed the same per-role heights when computing
// row positions, so verify the window spans all rows correctly
const windowCoversAll = win.start === 0 && win.end === 3;
console.log(JSON.stringify({scrollTop, delta, windowCoversAll}));
"""
metrics = json.loads(_run_node(source))
# scrollTop to idx=2 = sum of row 0 (120) + row 1 (400) = 520, no viewport offset (null container)
assert metrics["scrollTop"] == 520, (
f"expected 520 (120+400) but got {metrics['scrollTop']}; "
"offset helper must use per-role defaults, not flat 140px"
)
# prepended delta for 3 uncached rows = 120 + 400 + 160 = 680
assert metrics["delta"] == 680, (
f"expected 680 (120+400+160) but got {metrics['delta']}; "
"prepend helper must use per-role defaults, not flat 140px"
)
# windowing function must also cover the same three rows
assert metrics["windowCoversAll"] is True
def test_compensate_scroll_for_measurement_delta_no_anchor_does_not_throw():
"""When _captureMessageViewportAnchor returns null, the compensation helper
should not throw and should not mutate scrollTop."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
let renderCalls = [];
let scrollTopWasMutated = false;
let scrollTopValue = 100;
const container = {
get scrollTop(){ return scrollTopValue; },
set scrollTop(v){ scrollTopWasMutated = true; scrollTopValue = v; },
getBoundingClientRect(){ return {top: 0}; },
querySelector(){ return null; },
classList: { add(){}, remove(){} },
};
function $(id){ return id === 'messages' ? container : null; }
function _captureMessageViewportAnchor(){ return null; }
let _programmaticScroll = false;
let _lastScrollTop = 0;
function _scheduleMessageVirtualizedRender(){ renderCalls.push(true); }
function requestAnimationFrame(cb){ cb(); }
eval(extractFunc('_compensateScrollForMeasurementDelta'));
_compensateScrollForMeasurementDelta(()=>{ _scheduleMessageVirtualizedRender(true); });
console.log(JSON.stringify({
renderCalled: renderCalls.length > 0,
scrollTopMutated: scrollTopWasMutated,
}));
"""
metrics = json.loads(_run_node(source))
assert metrics["renderCalled"] is True
assert metrics["scrollTopMutated"] is False
def test_compensate_scroll_for_measurement_delta_shifts_scroll_when_anchor_moves():
"""When the anchor row shifts position due to measurement changes,
scrollTop should be adjusted by the delta."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
let scrollTopValue = 200;
let scrollHistory = [];
const container = {
get scrollTop(){ return scrollTopValue; },
set scrollTop(v){ scrollHistory.push(v); scrollTopValue = v; },
getBoundingClientRect(){ return {top: 50, bottom: 650}; },
classList: { add(){}, remove(){} },
querySelector(selector){
if(selector === '[data-msg-idx="42"]'){
// After render, the row moved from relative offset 100 to 150 (50px shift)
return {
getBoundingClientRect(){ return {top: 100}; }
};
}
return null;
},
};
function $(id){ return id === 'messages' ? container : null; }
function _captureMessageViewportAnchor(){
// Anchor was at topOffset 100 before the render
return {rawIdx: 42, topOffset: 100};
}
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
let _programmaticScrollResetTimer = 0;
let _lastScrollTop = 0;
const performance = { now(){ return 1000; } };
function clearTimeout(){}
function setTimeout(cb, ms){ cb(); return 1; }
function _deferClearProgrammaticScroll(ms){clearTimeout(_programmaticScrollResetTimer);_programmaticScrollResetTimer=setTimeout(()=>{_programmaticScroll=false;},ms||80);}
let renderCalls = [];
function _scheduleMessageVirtualizedRender(){ renderCalls.push(true); }
function requestAnimationFrame(cb){ cb(); }
eval(extractFunc('_compensateScrollForMeasurementDelta'));
_compensateScrollForMeasurementDelta(()=>{ _scheduleMessageVirtualizedRender(true); });
console.log(JSON.stringify({
scrollHistory: scrollHistory,
renderCalled: renderCalls.length > 0,
}));
"""
metrics = json.loads(_run_node(source))
assert metrics["renderCalled"] is True
# actualOffset = 100 - 50 = 50, delta = 50 - 100 = -50 (row moved up 50px)
# scrollTop should adjust: 200 + (-50) = 150
assert metrics["scrollHistory"] == [150], (
"scrollTop should shift by -50px to keep anchor in place"
)
def test_compensate_scroll_for_measurement_delta_skips_small_delta():
"""When delta < 2px, no compensation is applied (tolerance)."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
let scrollTopValue = 200;
let scrollHistory = [];
const container = {
get scrollTop(){ return scrollTopValue; },
set scrollTop(v){ scrollHistory.push(v); scrollTopValue = v; },
getBoundingClientRect(){ return {top: 50, bottom: 650}; },
classList: { add(){}, remove(){} },
querySelector(selector){
if(selector === '[data-msg-idx="42"]'){
// Row moved by only 1px, within tolerance
return {
getBoundingClientRect(){ return {top: 251}; }
};
}
return null;
},
};
function $(id){ return id === 'messages' ? container : null; }
function _captureMessageViewportAnchor(){
return {rawIdx: 42, topOffset: 200};
}
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
let _programmaticScrollResetTimer = 0;
let _lastScrollTop = 0;
const performance = { now(){ return 1000; } };
function clearTimeout(){}
function setTimeout(cb, ms){ cb(); return 1; }
function _deferClearProgrammaticScroll(ms){clearTimeout(_programmaticScrollResetTimer);_programmaticScrollResetTimer=setTimeout(()=>{_programmaticScroll=false;},ms||80);}
let renderCalls = [];
function _scheduleMessageVirtualizedRender(){ renderCalls.push(true); }
function requestAnimationFrame(cb){ cb(); }
eval(extractFunc('_compensateScrollForMeasurementDelta'));
_compensateScrollForMeasurementDelta(()=>{ _scheduleMessageVirtualizedRender(true); });
console.log(JSON.stringify({
scrollHistory: scrollHistory,
renderCalled: renderCalls.length > 0,
}));
"""
metrics = json.loads(_run_node(source))
assert metrics["renderCalled"] is True
assert metrics["scrollHistory"] == [], (
"scrollTop should not change for delta < 2px"
)
def test_compensate_scroll_for_measurement_delta_sets_programmatic_scroll_flag():
"""_programmaticScroll should be set during compensation and cleared after rAF+setTimeout."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
let scrollTopValue = 200;
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
let _programmaticScrollResetTimer = 0;
let _lastScrollTop = 0;
let scrollSetCount = 0;
const performance = { now(){ return 1000; } };
function clearTimeout(){}
const container = {
get scrollTop(){ return scrollTopValue; },
set scrollTop(v){
scrollSetCount++;
scrollTopValue = v;
},
getBoundingClientRect(){ return {top: 50, bottom: 650}; },
classList: { add(){}, remove(){} },
querySelector(selector){
if(selector === '[data-msg-idx="42"]'){
return {
getBoundingClientRect(){ return {top: 300}; }
};
}
return null;
},
};
function $(id){ return id === 'messages' ? container : null; }
function _captureMessageViewportAnchor(){
return {rawIdx: 42, topOffset: 200};
}
let renderCalls = [];
function _scheduleMessageVirtualizedRender(){ renderCalls.push(true); }
function _deferClearProgrammaticScroll(ms){clearTimeout(_programmaticScrollResetTimer);_programmaticScrollResetTimer=setTimeout(()=>{_programmaticScroll=false;},ms||80);}
function requestAnimationFrame(cb){ cb(); }
function setTimeout(cb, delay){ cb(); return 1; }
eval(extractFunc('_compensateScrollForMeasurementDelta'));
_compensateScrollForMeasurementDelta(()=>{ _scheduleMessageVirtualizedRender(true); });
console.log(JSON.stringify({
programmaticScrollWasTrue: _programmaticScroll === true || scrollSetCount > 0,
programmaticScrollNowFalse: _programmaticScroll === false,
scrollWasSet: scrollSetCount > 0,
}));
"""
metrics = json.loads(_run_node(source))
# _programmaticScroll should have been set to true when scrollTop was adjusted
assert metrics["scrollWasSet"] is True, (
"scrollTop should be set when delta > 2px"
)
# _programmaticScroll should have been cleared after the setTimeout callback
assert metrics["programmaticScrollNowFalse"] is True, (
"_programmaticScroll should be false after setTimeout completes"
)
def test_virtualized_render_uses_compensation_helper():
"""_scheduleMessageVirtualizedRender must wrap renderMessages with _compensateScrollForMeasurementDelta."""
js = UI_JS_PATH.read_text(encoding="utf-8")
start = js.index("function _scheduleMessageVirtualizedRender(")
end = js.index("\n// ──", start)
body = js[start:end]
assert "_compensateScrollForMeasurementDelta" in body, (
"_scheduleMessageVirtualizedRender must call "
"_compensateScrollForMeasurementDelta to compensate scroll after measurement-driven rerenders"
)
assert "renderMessages(" in body, (
"the compensation helper should wrap the actual renderMessages call"
)
def test_scroll_listener_guards_programmatic_scroll_before_marking_active():
"""The _programmaticScroll guard must appear before _markMessageVirtualScrollActive
in the scroll listener so that programmatic scrolls (e.g. from
_compensateScrollForMeasurementDelta) do not arm the 150ms settle timer."""
js = UI_JS_PATH.read_text(encoding="utf-8")
listener_start = js.index("el.addEventListener('scroll',()=>{")
listener_end = js.index("});", listener_start)
listener_body = js[listener_start:listener_end]
guard_pos = listener_body.index("if(_programmaticScroll) return;")
mark_pos = listener_body.index("_markMessageVirtualScrollActive();")
assert guard_pos < mark_pos, (
"Scroll listener must check _programmaticScroll before calling "
"_markMessageVirtualScrollActive so programmatic scrolls do not arm "
"the 150ms settle timer and chain measurement delays"
)
def test_clear_height_cache_resets_scroll_settle_globals():
"""_clearMessageVirtualHeightCache must zero out the three scroll-settle globals
so that a deferred measurement from a previous session cannot fire against the
new session's DOM after a session switch."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
let timerCleared = false;
let _messageVirtualScrollActive = true;
let _messageVirtualScrollSettleTimer = 99;
let _messageVirtualDeferredMeasurement = {some: 'stale-metrics'};
let _messageVirtualHeightCache = [100, 200];
let _messageVirtualHeightCacheEntries = [{rawIdx: 0}];
let _messageVirtualHeightCacheLen = 2;
let _messageVirtualHeightCacheSrc = {};
let _messageVirtualEstimatedRowHeight = 200;
let _messageVirtualWindowKey = 'old-key';
let _messageVirtualMeasurementCycleKey = 'old-cycle';
let _messageVirtualMeasurementRetryCount = 3;
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHT = 140;
// #4367 introduced per-role default heights; the combined _clearMessageVirtualHeightCache
// resets the estimate via _messageVirtualDefaultHeightForRole('default'), so the harness
// must provide the role map + that helper.
const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS = {user:120, assistant:160, tool_call:400, default:140};
function clearTimeout(id){ timerCleared = (id === 99); }
eval(extractFunc('_messageVirtualDefaultHeightForRole'));
eval(extractFunc('_clearMessageVirtualHeightCache'));
_clearMessageVirtualHeightCache();
console.log(JSON.stringify({
scrollActive: _messageVirtualScrollActive,
settleTimer: _messageVirtualScrollSettleTimer,
deferred: _messageVirtualDeferredMeasurement,
timerCleared: timerCleared,
}));
"""
metrics = json.loads(_run_node(source))
assert metrics["scrollActive"] is False, (
"_clearMessageVirtualHeightCache must reset _messageVirtualScrollActive to false"
)
assert metrics["settleTimer"] == 0, (
"_clearMessageVirtualHeightCache must reset _messageVirtualScrollSettleTimer to 0"
)
assert metrics["deferred"] is None, (
"_clearMessageVirtualHeightCache must clear _messageVirtualDeferredMeasurement to null"
)
assert metrics["timerCleared"] is True, (
"_clearMessageVirtualHeightCache must call clearTimeout on the pending settle timer"
)