Files
hermes-webui/tests/test_jump_to_answer_scroll_settle.py
37c59baf68 fix(ui): prevent OOM on tab focus for long sessions (#7006)
* fix(ui): prevent OOM on tab focus for long sessions

Coordinate visibility-recovery and session-updated refresh paths so they
never start concurrent full-transcript loads, window the O(N) pre-render
scans to the virtual render window, and bound the render-window expansion.

Closes #6999

* fix(ui): full turn context + collision-free cache hash (#7006 review rework)

Addresses the maintainer's CHANGES_REQUESTED on PR #7006:

- Turn-context maps (_assistantTurnFinalVisibleContentMap /
  _assistantTurnVisibleContentMap) receive the FULL visWithIdx again.
  The virtualized render window (head+tail around a gap) cut through
  assistant runs and merged distinct turns across an omitted user
  boundary, which could duplicate the final answer in Worklog/Thinking
  or feed later-turn prose into the reasoning echo-strip. Turn context
  is input, not output: it must always be complete.

- _addBoundedHash now streams the FULL string form through the FNV-1a
  loop (no length+head+tail clip, no slice copies), and object fields
  (attachments, tc.args, compression-anchor keys) are walked key-by-key
  so no integral JSON.stringify() is allocated. Same-length middle-only
  edits of tool args, attachment metadata, tool snippets, or
  compression-anchor keys no longer collide in _sessionHtmlCache.

Regression tests:
- tests/test_issue6999_turn_context_virtual_gap.py: multi-assistant turn
  with a virtualized gap proves full-context echo-strip suppresses the
  exact final-answer echo, and documents the gapped head+tail failure.
- tests/test_issue2613_render_cache_signature.py: Node harness executing
  the real _messageRenderCacheSignature() proves same-length middle-only
  mutations change the signature for every structured field while the
  old clip scheme collides on the same data.

Closes #6999

* fix(ui): coalesce session-updated under refresh guard; insertion-order cache hash

Re-gate round 2 for #6999:
- session-updated frames arriving while the external-refresh guard is held
  are latched per SID (max announced count) and drained by the refresh
  owner's finally with ONE guarded follow-up, instead of being dropped
  (production does not guarantee a second event).
- _hashObjectInto walks keys in insertion order (matching Object.entries
  render projection) with an explicit per-key index discriminator, so
  opposite-insertion-order argument objects no longer share a cache
  signature; recursion depth is now threaded (children increment, not
  restart).

* test(jump-scroll): stub _loadingSessionId + _drainSessionUpdatedPendingCount in extraction harness

#7006 adds a reference to the pre-existing module var _loadingSessionId and a new
_drainSessionUpdatedPendingCount() helper inside refreshActiveSessionIfExternallyUpdated.
The jump-to-answer scroll-settle tests extract that function into a node harness, which
didn't declare them -> ReferenceError (19 parametrized failures). Add the missing stubs
to the harness preambles (no-op drain, since these tests exercise scroll ownership, not
the coalesce path -- real drain behavior is covered by test_issue6999_session_updated_coalesce.py).
Test-harness-only; production static/ untouched.

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>

---------

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
2026-08-14 19:42:15 -07:00

794 lines
29 KiB
Python

"""Regression coverage for the first jump-to-answer click after session load."""
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"
SESSIONS_JS_PATH = REPO_ROOT / "static" / "sessions.js"
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
def _run_node(source: str) -> str:
assert NODE is not None
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('(?:async\\\\s+)?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 _extract_message_scroll_listener_script(js: str) -> str:
marker = "el.addEventListener('scroll',()=>{"
marker_idx = js.index(marker)
start = js.rfind("(function(){", 0, marker_idx)
end = js.index("})();", marker_idx) + len("})();")
assert start >= 0
return f"const messageScrollListenerIife = {js[start:end]!r};\n"
def test_first_jump_to_answer_cancels_pending_load_time_bottom_settle():
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + r"""
let _scrollPinned = true;
let _loadingSessionId = null;
let _messageUserUnpinned = false;
let _nearBottomCount = 2;
let _messageJumpScrollGeneration = 0;
let _messageJumpScrollOwner = null;
let _messageJumpScrollSettleTimer = 0;
let _bottomSettleToken = 0;
let _settleRAF = 0;
let _settleRO = null;
let _settleTimer = 0;
let _settleFinalTimer = 0;
let targetScrolls = 0;
let snappedBackToBottom = false;
const container = {
scrollHeight: 900,
clientHeight: 500,
scrollTop: 400,
getBoundingClientRect(){ return {top: 100, height: 500}; },
querySelectorAll(){ return [assistantSegment]; },
};
const assistantSegment = {
getClientRects(){ return [{}]; },
getBoundingClientRect(){ return {top: -300, height: 40}; },
scrollIntoView(){ targetScrolls += 1; container.scrollTop = 0; },
};
function $(id){ return id === 'messages' ? container : null; }
function _userMessageDomId(rawIdx){ return 'msg-user-' + rawIdx; }
function _highlightQuestionRow(){}
function _getVisibleMessagesWithIdx(){ throw new Error('visible target should use fast path'); }
function cancelAnimationFrame(){}
eval(extractFunc('_cancelBottomSettle'));
eval(extractFunc('_messageJumpSessionId'));
eval(extractFunc('_scheduleMessageJumpScrollReconcile'));
eval(extractFunc('_beginMessageJumpScroll'));
eval(extractFunc('_finishMessageJumpScroll'));
eval(extractFunc('_cancelMessageJumpScroll'));
eval(extractFunc('jumpToTurnQuestion'));
(async () => {
_settleFinalTimer = setTimeout(() => {
if (_scrollPinned && !_messageUserUnpinned) snappedBackToBottom = true;
}, 15);
await jumpToTurnQuestion(4, 5);
await new Promise(resolve => setTimeout(resolve, 340));
console.log(JSON.stringify({
targetScrolls,
snappedBackToBottom,
scrollPinned: _scrollPinned,
messageUserUnpinned: _messageUserUnpinned,
bottomSettleToken: _bottomSettleToken,
}));
})().catch(error => {
console.error(error);
process.exit(1);
});
"""
result = json.loads(_run_node(source))
assert result == {
"targetScrolls": 1,
"snappedBackToBottom": False,
"scrollPinned": False,
"messageUserUnpinned": True,
"bottomSettleToken": 1,
}
@pytest.mark.parametrize(
("scroll_range", "expect_reader_owned"),
[
(0, False),
(79, False),
(81, True),
(400, True),
],
)
def test_jump_geometry_controls_active_session_refresh_deferral(
scroll_range: int, expect_reader_owned: bool
):
ui_js = UI_JS_PATH.read_text(encoding="utf-8")
sessions_js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(ui_js + "\n" + sessions_js) + rf"""
let _scrollPinned = true;
let _loadingSessionId = null;
let _pendingSessionUpdatedCounts = null;
function _drainSessionUpdatedPendingCount(){{}}
let _messageUserUnpinned = false;
let _nearBottomCount = 2;
let _messageJumpScrollGeneration = 0;
let _messageJumpScrollOwner = null;
let _messageJumpScrollSettleTimer = 0;
let _bottomSettleToken = 0;
let _settleRAF = 0;
let _settleRO = null;
let _settleTimer = 0;
let _settleFinalTimer = 0;
let _activeSessionExternalRefreshInFlight = false;
let _deferredActiveSessionExternalRefreshReason = '';
let apiCalls = 0;
const scrollRange = {scroll_range};
const container = {{
scrollHeight: 500 + scrollRange,
clientHeight: 500,
scrollTop: scrollRange,
getBoundingClientRect(){{ return {{top: 100, height: 500}}; }},
querySelectorAll(){{ return [assistantSegment]; }},
}};
const assistantSegment = {{
getClientRects(){{ return [{{}}]; }},
getBoundingClientRect(){{ return {{top: 100 - scrollRange, height: 40}}; }},
scrollIntoView(){{ container.scrollTop = 0; }},
}};
const document = {{hidden: false, getElementById(){{ return null; }}}};
const window = {{}};
const S = {{
session: {{session_id: 'active', message_count: 1, last_message_at: 1}},
messages: [{{role: 'user'}}],
busy: false,
activeStreamId: null,
}};
function $(id){{ return id === 'messages' ? container : null; }}
function _userMessageDomId(rawIdx){{ return 'msg-user-' + rawIdx; }}
function _highlightQuestionRow(){{}}
function _getVisibleMessagesWithIdx(){{ throw new Error('visible target should use fast path'); }}
function cancelAnimationFrame(){{}}
function _isMessageReaderUnpinned(){{ return _messageUserUnpinned; }}
function _deferActiveSessionExternalRefresh(reason){{
_deferredActiveSessionExternalRefreshReason = reason || 'poll';
}}
function _isExternalSession(){{ return false; }}
async function api(){{
apiCalls += 1;
return {{session: {{message_count: 1, last_message_at: 1}}}};
}}
eval(extractFunc('_cancelBottomSettle'));
eval(extractFunc('_messageJumpSessionId'));
eval(extractFunc('_scheduleMessageJumpScrollReconcile'));
eval(extractFunc('_beginMessageJumpScroll'));
eval(extractFunc('_finishMessageJumpScroll'));
eval(extractFunc('_cancelMessageJumpScroll'));
eval(extractFunc('jumpToTurnQuestion'));
eval(extractFunc('refreshActiveSessionIfExternallyUpdated'));
(async () => {{
await jumpToTurnQuestion(4, 5);
await new Promise(resolve => setTimeout(resolve, 340));
const refreshResult = await refreshActiveSessionIfExternallyUpdated('idle-reconcile', {{ignoreStreamJustFinished:true}});
console.log(JSON.stringify({{
scrollRange,
scrollTop: container.scrollTop,
scrollPinned: _scrollPinned,
messageUserUnpinned: _messageUserUnpinned,
refreshResult,
deferredReason: _deferredActiveSessionExternalRefreshReason,
apiCalls,
}}));
}})().catch(error => {{
console.error(error);
process.exit(1);
}});
"""
result = json.loads(_run_node(source))
assert result["scrollRange"] == scroll_range
assert result["scrollTop"] == 0
assert result["scrollPinned"] is (not expect_reader_owned)
assert result["messageUserUnpinned"] is expect_reader_owned
assert result["refreshResult"] == ("skipped" if expect_reader_owned else "unchanged")
assert result["deferredReason"] == ("idle-reconcile" if expect_reader_owned else "")
assert result["apiCalls"] == (0 if expect_reader_owned else 1)
@pytest.mark.parametrize("jump_path", ["assistant", "user", "virtualized"])
@pytest.mark.parametrize(
("scroll_range", "expect_reader_owned"),
[(0, False), (79, False), (80, False), (81, True), (400, True)],
)
def test_response_jump_owns_native_smooth_scroll_until_final_reconciliation(
jump_path: str, scroll_range: int, expect_reader_owned: bool
):
ui_js = UI_JS_PATH.read_text(encoding="utf-8")
sessions_js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = (
_extract_func_script(ui_js + "\n" + sessions_js)
+ _extract_message_scroll_listener_script(ui_js)
+ rf"""
let _scrollPinned = true;
let _loadingSessionId = null;
let _pendingSessionUpdatedCounts = null;
function _drainSessionUpdatedPendingCount(){{}}
let _messageUserUnpinned = false;
let _nearBottomCount = 2;
let _messageJumpScrollGeneration = 0;
let _messageJumpScrollOwner = null;
let _messageJumpScrollSettleTimer = 0;
let _lastScrollTop = {scroll_range};
let _lastMessageClientHeight = 500;
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
let _programmaticScrollResetTimer = 0;
let _bottomSettleToken = 0;
let _settleRAF = 0;
let _settleRO = null;
let _settleTimer = 0;
let _settleFinalTimer = 0;
let _activeSessionExternalRefreshInFlight = false;
let _deferredActiveSessionExternalRefreshReason = '';
let _scrollbarDragActive = false;
let _newMessageCueVisible = false;
let _messageVirtualWindowKey = '';
let _messageRenderWindowSize = 0;
let _messagesTruncated = false;
let rendered = {str(jump_path != "virtualized").lower()};
let cueShown = null;
let apiCalls = 0;
const scrollListeners = [];
const jumpPath = {jump_path!r};
const scrollRange = {scroll_range};
const dispatchScroll = () => scrollListeners.forEach(listener => listener());
let scrollTopValue = scrollRange;
const container = {{
scrollHeight: 500 + scrollRange,
clientHeight: 500,
clientWidth: 320,
contains(){{ return false; }},
matches(){{ return false; }},
addEventListener(type, listener){{ if(type === 'scroll') scrollListeners.push(listener); }},
querySelectorAll(selector){{
if(selector.includes('data-msg-idx') && rendered && jumpPath !== 'user') return [assistantSegment];
return [];
}},
getBoundingClientRect(){{ return {{top: 100, bottom: 600, height: 500}}; }},
get scrollTop(){{ return scrollTopValue; }},
set scrollTop(value){{ scrollTopValue = value; dispatchScroll(); }},
}};
function animateToTop(){{
if(scrollRange === 0) return;
setTimeout(() => {{ scrollTopValue = Math.ceil(scrollRange / 2); dispatchScroll(); }}, 180);
setTimeout(() => {{ scrollTopValue = 0; dispatchScroll(); }}, 230);
}}
const assistantSegment = {{
getClientRects(){{ return [{{}}]; }},
getBoundingClientRect(){{ return {{top: 100 - scrollTopValue, height: 40}}; }},
scrollIntoView(){{ animateToTop(); }},
}};
const userRow = {{
getClientRects(){{ return [{{}}]; }},
getBoundingClientRect(){{ return {{top: 100 - scrollTopValue, height: 40}}; }},
scrollIntoView(){{ animateToTop(); }},
classList: {{remove(){{}}, add(){{}}}},
offsetWidth: 1,
}};
const document = {{
hidden: false,
visibilityState: 'visible',
activeElement: null,
getElementById(id){{
if(id === 'messages') return container;
if(id === 'msg-user-4' && jumpPath === 'user') return userRow;
return null;
}},
addEventListener(){{}},
}};
const window = {{addEventListener(){{}}, setTimeout}};
function requestAnimationFrame(callback){{ return setTimeout(callback, 0); }}
function cancelAnimationFrame(handle){{ clearTimeout(handle); }}
const S = {{
session: {{session_id: 'active', message_count: 1, last_message_at: 1}},
messages: [{{role: 'user'}}],
busy: false,
activeStreamId: null,
}};
function $(id){{ return id === 'messages' ? container : null; }}
function _userMessageDomId(rawIdx){{ return 'msg-user-' + rawIdx; }}
function _getVisibleMessagesWithIdx(){{ return [{{rawIdx: 4}}]; }}
function _messageVisibleIndexForRawIdx(){{ return 0; }}
function _messageVirtualScrollTopForVisibleIdx(){{ return 0; }}
function _messageHiddenBeforeCount(){{ return 0; }}
function _currentMessageRenderWindowSize(){{ return 1; }}
function _messageRenderableMessageCount(){{ return 1; }}
function renderMessages(){{ rendered = true; }}
function _deferClearProgrammaticScroll(ms){{
clearTimeout(_programmaticScrollResetTimer);
_programmaticScrollResetTimer = setTimeout(() => {{ _programmaticScroll = false; }}, ms || 80);
}}
function _scheduleMessageVirtualizedRender(){{}}
function _markMessageVirtualScrollActive(){{}}
function _recentMessageRenderArtifactWindow(){{ return false; }}
function _recentMessageTouchScrollIntent(){{ return false; }}
function _recentNonMessageScrollIntent(){{ return false; }}
function _recentMessageWheelIntent(){{ return false; }}
function _recentMessageKeyScrollIntent(){{ return false; }}
function _clearNewMessageScrollCue(){{}}
function _syncScrollToBottomCue(show){{ cueShown = show; }}
function _updateSessionStartJumpButton(){{}}
function _isSessionEndlessScrollEnabled(){{ return false; }}
function _isMessageReaderUnpinned(){{ return _messageUserUnpinned; }}
function _deferActiveSessionExternalRefresh(reason){{
_deferredActiveSessionExternalRefreshReason = reason || 'poll';
}}
function _isExternalSession(){{ return false; }}
async function api(){{
apiCalls += 1;
return {{session: {{message_count: 1, last_message_at: 1}}}};
}}
eval(extractFunc('_cancelBottomSettle'));
eval(extractFunc('_messageJumpSessionId'));
eval(extractFunc('_scheduleMessageJumpScrollReconcile'));
eval(extractFunc('_beginMessageJumpScroll'));
eval(extractFunc('_finishMessageJumpScroll'));
eval(extractFunc('_cancelMessageJumpScroll'));
eval(extractFunc('_highlightQuestionRow'));
eval(extractFunc('jumpToTurnQuestion'));
eval(extractFunc('refreshActiveSessionIfExternallyUpdated'));
eval(messageScrollListenerIife);
(async () => {{
await jumpToTurnQuestion(4, jumpPath === 'user' ? -1 : 5);
await new Promise(resolve => setTimeout(resolve, 560));
const refreshResult = await refreshActiveSessionIfExternallyUpdated(
'idle-reconcile', {{ignoreStreamJustFinished:true}}
);
console.log(JSON.stringify({{
jumpPath,
scrollRange,
bottomDistance: container.scrollHeight - container.scrollTop - container.clientHeight,
scrollPinned: _scrollPinned,
messageUserUnpinned: _messageUserUnpinned,
nearBottomCount: _nearBottomCount,
cueShown,
refreshResult,
deferredReason: _deferredActiveSessionExternalRefreshReason,
apiCalls,
}}));
}})().catch(error => {{
console.error(error);
process.exit(1);
}});
"""
)
result = json.loads(_run_node(source))
assert result["jumpPath"] == jump_path
assert result["bottomDistance"] == scroll_range
assert result["scrollPinned"] is (not expect_reader_owned)
assert result["messageUserUnpinned"] is expect_reader_owned
assert result["nearBottomCount"] == (0 if expect_reader_owned else 2)
assert result["cueShown"] is expect_reader_owned
assert result["refreshResult"] == ("skipped" if expect_reader_owned else "unchanged")
assert result["deferredReason"] == ("idle-reconcile" if expect_reader_owned else "")
assert result["apiCalls"] == (0 if expect_reader_owned else 1)
def test_jump_owner_replacement_and_session_change_cancel_stale_generation():
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + r"""
let _scrollPinned = true;
let _loadingSessionId = null;
let _messageUserUnpinned = false;
let _nearBottomCount = 2;
let _lastScrollTop = 400;
let _lastMessageClientHeight = 500;
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
let _messageJumpScrollGeneration = 0;
let _messageJumpScrollOwner = null;
let _messageJumpScrollSettleTimer = 0;
let _newMessageCueVisible = false;
let currentSid = 'active';
const S = {session:{session_id:'active'}};
const container = {scrollHeight:900,clientHeight:500,scrollTop:0};
function _syncScrollToBottomCue(){}
eval(extractFunc('_messageJumpSessionId'));
eval(extractFunc('_scheduleMessageJumpScrollReconcile'));
eval(extractFunc('_beginMessageJumpScroll'));
eval(extractFunc('_finishMessageJumpScroll'));
eval(extractFunc('_cancelMessageJumpScroll'));
const first = _beginMessageJumpScroll(container);
const second = _beginMessageJumpScroll(container);
_finishMessageJumpScroll(first);
const staleIgnored = _messageJumpScrollOwner && _messageJumpScrollOwner.generation === second;
const preservedFromFirst = _messageJumpScrollOwner.preserved.scrollPinned === true
&& _messageJumpScrollOwner.preserved.messageUserUnpinned === false
&& _messageJumpScrollOwner.preserved.nearBottomCount === 2;
S.session.session_id = 'other';
_finishMessageJumpScroll(second);
console.log(JSON.stringify({
staleIgnored,
preservedFromFirst,
ownerCleared: _messageJumpScrollOwner === null,
programmaticCleared: _programmaticScroll === false,
scrollPinned: _scrollPinned,
messageUserUnpinned: _messageUserUnpinned,
nearBottomCount: _nearBottomCount,
}));
"""
result = json.loads(_run_node(source))
assert result == {
"staleIgnored": True,
"preservedFromFirst": True,
"ownerCleared": True,
"programmaticCleared": True,
"scrollPinned": True,
"messageUserUnpinned": False,
"nearBottomCount": 2,
}
def test_near_tail_jump_preserves_preexisting_reader_unpin():
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + r"""
let _scrollPinned = false;
let _messageUserUnpinned = true;
let _nearBottomCount = 0;
let _lastScrollTop = 79;
let _lastMessageClientHeight = 500;
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
let _messageJumpScrollGeneration = 0;
let _messageJumpScrollOwner = null;
let _messageJumpScrollSettleTimer = 0;
let _newMessageCueVisible = false;
let currentSid = 'active';
const S = {session:{session_id:'active'}};
const container = {scrollHeight:579,clientHeight:500,scrollTop:0};
let cueShown = null;
function _syncScrollToBottomCue(show){ cueShown = show; }
eval(extractFunc('_messageJumpSessionId'));
eval(extractFunc('_scheduleMessageJumpScrollReconcile'));
eval(extractFunc('_beginMessageJumpScroll'));
eval(extractFunc('_finishMessageJumpScroll'));
eval(extractFunc('_cancelMessageJumpScroll'));
const generation = _beginMessageJumpScroll(container);
_finishMessageJumpScroll(generation);
console.log(JSON.stringify({
scrollPinned: _scrollPinned,
messageUserUnpinned: _messageUserUnpinned,
nearBottomCount: _nearBottomCount,
cueShown,
}));
"""
result = json.loads(_run_node(source))
assert result == {
"scrollPinned": False,
"messageUserUnpinned": True,
"nearBottomCount": 0,
"cueShown": False,
}
def test_manual_wheel_input_cancels_active_jump_owner():
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + r"""
let _scrollPinned = true;
let _loadingSessionId = null;
let _messageUserUnpinned = false;
let _nearBottomCount = 2;
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
const PROGRAMMATIC_SCROLL_VALID_MS = 150;
let _messageJumpScrollGeneration = 0;
let _messageJumpScrollOwner = null;
let _messageJumpScrollSettleTimer = 0;
let _bottomSettleToken = 0;
let _settleRAF = 0;
let _settleRO = null;
let _settleTimer = 0;
let _settleFinalTimer = 0;
let _lastNonMessageScrollIntentMs = -Infinity;
let _lastMessageScrollIntentMs = -Infinity;
let _lastMessageWheelIntentMs = -Infinity;
let _newMessageCueVisible = false;
let currentSid = 'active';
const S = {session:{session_id:'active'}};
const child = {};
const container = {
scrollHeight:900,
clientHeight:500,
scrollTop:0,
contains(node){ return node === child; },
};
const document = {getElementById(id){ return id === 'messages' ? container : null; }};
function cancelAnimationFrame(){}
eval(extractFunc('_messageJumpSessionId'));
eval(extractFunc('_scheduleMessageJumpScrollReconcile'));
eval(extractFunc('_beginMessageJumpScroll'));
eval(extractFunc('_finishMessageJumpScroll'));
eval(extractFunc('_cancelMessageJumpScroll'));
eval(extractFunc('_cancelBottomSettle'));
eval(extractFunc('_freshProgrammaticScrollActive'));
eval(extractFunc('_recordNonMessageScrollIntent'));
_beginMessageJumpScroll(container);
_recordNonMessageScrollIntent({target:child,type:'wheel',deltaY:-5});
console.log(JSON.stringify({
ownerCleared: _messageJumpScrollOwner === null,
programmaticCleared: _programmaticScroll === false,
bottomSettleToken: _bottomSettleToken,
scrollPinned: _scrollPinned,
messageUserUnpinned: _messageUserUnpinned,
intentRecorded: Number.isFinite(_lastMessageScrollIntentMs),
}));
"""
result = json.loads(_run_node(source))
assert result == {
"ownerCleared": True,
"programmaticCleared": True,
"bottomSettleToken": 1,
# Current master treats even a low-delta upward wheel as reader takeover
# when it interrupts an active programmatic scroll (#6414).
"scrollPinned": False,
"messageUserUnpinned": True,
"intentRecorded": True,
}
def test_streaming_frame_during_jump_owner_does_not_snap_to_bottom():
"""#6621 (Fable finding ii): a streaming render frame that fires INSIDE the
jump-owner window must not let scrollIfPinned() reclaim the bottom. Reproduces
the exact live-token snap the fix prevents."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + r"""
let _scrollPinned = true;
let _loadingSessionId = null;
let _messageUserUnpinned = false;
let _nearBottomCount = 0;
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
let _messageJumpScrollGeneration = 0;
let _messageJumpScrollOwner = null;
let _messageJumpScrollSettleTimer = 0;
let _lastScrollTop = null;
let _lastMessageClientHeight = 0;
let _newMessageCueVisible = false;
let bottomWrites = 0;
const performance = { now: () => Date.now() };
const TARGET_TOP = 707;
const container = { scrollHeight: 3510, clientHeight: 745, scrollTop: 2765, contains: () => true };
const window = { _autoScrollFollow: true };
function _messageJumpSessionId(){ return 'S1'; }
function clearTimeout(){} function setTimeout(){ return 0; }
function _scheduleMessageJumpScrollReconcile(){}
function _syncScrollToBottomCue(){} function _updateSessionStartJumpButton(){}
function _flushDeferredActiveSessionExternalRefresh(){}
function _recentNonMessageScrollIntent(){ return false; }
function _recentMessageScrollIntent(){ return false; }
function _recentMessageTouchScrollIntent(){ return false; }
function _recentMessageWheelIntent(){ return false; }
function _recentMessageKeyScrollIntent(){ return false; }
function _messageBottomDistance(){ return container.scrollHeight - container.scrollTop - container.clientHeight; }
function _setMessageScrollToBottom(){ bottomWrites++; container.scrollTop = container.scrollHeight - container.clientHeight; }
function _settleMessageScrollToBottom(){ if (_scrollPinned){ bottomWrites++; container.scrollTop = container.scrollHeight - container.clientHeight; } }
eval(extractFunc('_beginMessageJumpScroll'));
eval(extractFunc('_finishMessageJumpScroll'));
eval(extractFunc('_cancelMessageJumpScroll'));
eval(extractFunc('scrollIfPinned'));
_beginMessageJumpScroll(container);
container.scrollTop = TARGET_TOP;
const before = container.scrollTop;
scrollIfPinned();
scrollIfPinned();
const after = container.scrollTop;
console.log(JSON.stringify({ before, after, bottomWrites }));
"""
result = json.loads(_run_node(source))
assert result == {"before": 707, "after": 707, "bottomWrites": 0}, (
"A streaming frame inside the jump-owner window must not snap the reader "
"back to the bottom (#6621)."
)
def test_wheel_up_during_jump_owner_takes_reader_ownership_not_pinned_midtranscript():
"""#6621 (Fable robustness): a gentle upward wheel during the jump-owner window
(after the programmatic latch stales) must leave the reader explicitly unpinned
at their position, NOT pinned mid-transcript where the next frame would yank them."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + r"""
let _scrollPinned = true;
let _loadingSessionId = null;
let _messageUserUnpinned = false;
let _nearBottomCount = 2;
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
let _messageJumpScrollGeneration = 0;
let _messageJumpScrollOwner = null;
let _messageJumpScrollSettleTimer = 0;
let _lastScrollTop = null;
let _lastMessageClientHeight = 0;
let _newMessageCueVisible = false;
let _messageScrollInputGeneration = 0;
let _lastMessageWheelIntentMs = -Infinity;
let _lastMessageScrollIntentMs = -Infinity;
let _lastNonMessageScrollIntentMs = -Infinity;
let _touchStartY = null;
const performance = { now: () => 1000 };
const PROGRAMMATIC_SCROLL_VALID_MS = 150;
const container = { scrollHeight: 3510, clientHeight: 745, scrollTop: 707, contains: (t) => t === container };
const messages = container;
const document = { getElementById: (id) => (id === 'messages' ? messages : null) };
const window = { _autoScrollFollow: true };
function _messageJumpSessionId(){ return 'S1'; }
function _cancelBottomSettle(){ _cancelMessageJumpScroll(); }
function clearTimeout(){} function setTimeout(){ return 0; }
function _scheduleMessageJumpScrollReconcile(){}
function _syncScrollToBottomCue(){} function _updateSessionStartJumpButton(){}
function _flushDeferredActiveSessionExternalRefresh(){}
function _markMessageTouchScrollIntent(){}
function _freshProgrammaticScrollActive(){
if (!_programmaticScroll) return false;
const a = performance.now() - _programmaticScrollSetAt;
if (!Number.isFinite(a) || a < 0 || a > PROGRAMMATIC_SCROLL_VALID_MS){ _programmaticScroll = false; return false; }
return true;
}
eval(extractFunc('_beginMessageJumpScroll'));
eval(extractFunc('_finishMessageJumpScroll'));
eval(extractFunc('_cancelMessageJumpScroll'));
eval(extractFunc('_recordNonMessageScrollIntent'));
_beginMessageJumpScroll(container);
container.scrollTop = 707;
_programmaticScrollSetAt = performance.now() - 200; // latch stale
_recordNonMessageScrollIntent({ type: 'wheel', target: messages, deltaY: -5 });
console.log(JSON.stringify({
ownerCleared: _messageJumpScrollOwner === null,
messageUserUnpinned: _messageUserUnpinned,
scrollPinned: _scrollPinned,
scrollTop: container.scrollTop,
}));
"""
result = json.loads(_run_node(source))
assert result == {
"ownerCleared": True,
"messageUserUnpinned": True,
"scrollPinned": False,
"scrollTop": 707,
}, "A wheel-up during the jump owner window must hand ownership to the reader unpinned, not leave them pinned mid-transcript (#6621)."
def test_wheel_down_during_jump_owner_also_takes_reader_ownership():
"""#6621 (Fable finding S3): a wheel-DOWN (or touch scroll) during the owner
window must ALSO hand ownership to the reader unpinned — not restore the
pinned snapshot and let the next streaming token yank to the bottom."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + r"""
let _scrollPinned = true;
let _loadingSessionId = null;
let _messageUserUnpinned = false;
let _nearBottomCount = 2;
let _programmaticScroll = false;
let _programmaticScrollSetAt = 0;
let _messageJumpScrollGeneration = 0;
let _messageJumpScrollOwner = null;
let _messageJumpScrollSettleTimer = 0;
let _lastScrollTop = null;
let _lastMessageClientHeight = 0;
let _newMessageCueVisible = false;
let _messageScrollInputGeneration = 0;
let _lastMessageWheelIntentMs = -Infinity;
let _lastMessageScrollIntentMs = -Infinity;
let _lastNonMessageScrollIntentMs = -Infinity;
let _touchStartY = null;
const performance = { now: () => 1000 };
const PROGRAMMATIC_SCROLL_VALID_MS = 150;
const container = { scrollHeight: 3510, clientHeight: 745, scrollTop: 707, contains: (t) => t === container };
const messages = container;
const document = { getElementById: (id) => (id === 'messages' ? messages : null) };
const window = { _autoScrollFollow: true };
function _messageJumpSessionId(){ return 'S1'; }
function _cancelBottomSettle(){ _cancelMessageJumpScroll(); }
function clearTimeout(){} function setTimeout(){ return 0; }
function _scheduleMessageJumpScrollReconcile(){}
function _syncScrollToBottomCue(){} function _updateSessionStartJumpButton(){}
function _flushDeferredActiveSessionExternalRefresh(){}
function _markMessageTouchScrollIntent(){}
function _freshProgrammaticScrollActive(){
if (!_programmaticScroll) return false;
const a = performance.now() - _programmaticScrollSetAt;
if (!Number.isFinite(a) || a < 0 || a > PROGRAMMATIC_SCROLL_VALID_MS){ _programmaticScroll = false; return false; }
return true;
}
eval(extractFunc('_beginMessageJumpScroll'));
eval(extractFunc('_finishMessageJumpScroll'));
eval(extractFunc('_cancelMessageJumpScroll'));
eval(extractFunc('_recordNonMessageScrollIntent'));
_beginMessageJumpScroll(container);
container.scrollTop = 707;
_programmaticScrollSetAt = performance.now() - 200; // latch stale
_recordNonMessageScrollIntent({ type: 'wheel', target: messages, deltaY: 5 }); // DOWNWARD
console.log(JSON.stringify({
ownerCleared: _messageJumpScrollOwner === null,
messageUserUnpinned: _messageUserUnpinned,
scrollPinned: _scrollPinned,
}));
"""
result = json.loads(_run_node(source))
assert result == {
"ownerCleared": True,
"messageUserUnpinned": True,
"scrollPinned": False,
}, "A wheel-down during the jump owner window must also unpin the reader (#6621 S3)."