"""Regression for #6414: real upward wheel intent wins during a render scroll guard.""" import json import re import subprocess from pathlib import Path REPO = Path(__file__).resolve().parents[1] UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") def _function_body(name: str, source: str = UI_JS) -> str: marker = f"async function {name}" start = source.find(marker) if start < 0: marker = f"function {name}" start = source.index(marker) brace = source.index("{", start) depth = 0 for index in range(brace, len(source)): if source[index] == "{": depth += 1 elif source[index] == "}": depth -= 1 if depth == 0: return source[start : index + 1] raise AssertionError(f"{name} did not terminate") def _run_small_upward_wheel_case(programmatic_scroll_age_ms: int) -> dict[str, bool | int]: script = f""" let now = 1000; const performance = {{ now: () => now }}; const messages = {{ scrollHeight: 5000, scrollTop: 4400, clientHeight: 500, contains: (target) => target === messages, }}; const document = {{ getElementById: (id) => id === 'messages' ? messages : null }}; let _programmaticScroll = true; let _programmaticScrollSetAt = now - {programmatic_scroll_age_ms}; let _messageUserUnpinned = false; let _scrollPinned = true; let _nearBottomCount = 2; let _lastNonMessageScrollIntentMs = -Infinity; let _lastMessageScrollIntentMs = -Infinity; let _lastMessageWheelIntentMs = -Infinity; let _touchStartY = null; let _messageTouchScrollActive = false; let _messageScrollInputGeneration = 0; let writes = 0; let cancels = 0; const _autoScrollFollow = true; const window = {{ _autoScrollFollow: true }}; const PROGRAMMATIC_SCROLL_VALID_MS = 150; function _cancelBottomSettle() {{ cancels += 1; }} function _markMessageTouchScrollIntent() {{}} function _recentNonMessageScrollIntent() {{ return false; }} function _recentMessageScrollIntent() {{ return false; }} function _recentMessageTouchScrollIntent() {{ return false; }} function _recentMessageWheelIntent() {{ return now - _lastMessageWheelIntentMs < 1200; }} function _recentMessageKeyScrollIntent() {{ return false; }} function _messageBottomDistance() {{ return messages.scrollHeight - messages.scrollTop - messages.clientHeight; }} function _setMessageScrollToBottom() {{ writes += 1; }} function _settleMessageScrollToBottom() {{ writes += 1; }} {_function_body('_freshProgrammaticScrollActive')} {_function_body('_recordNonMessageScrollIntent')} {_function_body('scrollIfPinned')} _recordNonMessageScrollIntent({{ type: 'wheel', target: messages, deltaY: -5, }}); scrollIfPinned(); console.log(JSON.stringify({{ cancels, writes, programmaticScroll: _programmaticScroll, messageUserUnpinned: _messageUserUnpinned, scrollPinned: _scrollPinned, }})); """ result = subprocess.run( ["node", "-e", script], check=True, capture_output=True, text=True, ) return json.loads(result.stdout) def test_small_upward_wheel_unpins_during_fresh_programmatic_scroll_guard(): """A capture-phase wheel event must beat the scroll listener's early return. Small trackpad deltas are intentionally below the ordinary sticky-unpin threshold. They still represent a reader trying to leave the live tail when a render has armed `_programmaticScroll`; otherwise the listener returns and the next stream tick pulls the reader back down. """ result = _run_small_upward_wheel_case(programmatic_scroll_age_ms=40) assert result["messageUserUnpinned"] is True assert result["scrollPinned"] is False assert result["cancels"] == 1 assert result["writes"] == 0 def test_small_upward_wheel_does_not_unpin_after_programmatic_guard_stales(): """A stale latch must fall back to the ordinary low-delta wheel threshold.""" result = _run_small_upward_wheel_case(programmatic_scroll_age_ms=200) assert result["messageUserUnpinned"] is False assert result["scrollPinned"] is True assert result["programmaticScroll"] is False assert result["cancels"] == 0 def test_older_message_fallback_executes_real_slow_render_writer_contract(): """Exercise the production fallback, rather than retyping its ownership edge.""" script = f""" let now = 1000; const performance = {{ now: () => now }}; let storedTop = 100; let writeObservation = null; const container = {{ scrollHeight: 1000, clientHeight: 300, get scrollTop() {{ return storedTop; }}, set scrollTop(value) {{ storedTop = value; writeObservation = {{ value, at: now, fresh: _freshProgrammaticScrollActive() }}; }}, }}; const document = {{ getElementById: (id) => id === 'messages' ? container : null }}; function $(id) {{ return document.getElementById(id); }} const window = {{}}; const S = {{ session: {{ session_id: 'slow-render-session' }}, messages: [{{ role: 'assistant', content: 'tail' }}], }}; let _loadingOlder = false; let _messagesTruncated = true; let _oldestIdx = 1; let _messagesGeneration = 0; let _loadingSessionId = null; let _messageRenderWindowSize = 50; let _scrollPinned = true; let _programmaticScroll = true; let _programmaticScrollSetAt = now; const _INITIAL_MSG_LIMIT = 50; const _msgLimitMax = 100; const MESSAGE_RENDER_WINDOW_DEFAULT = 50; const PROGRAMMATIC_SCROLL_VALID_MS = 150; async function api() {{ return {{ session: {{ messages: [ {{ role: 'user', content: 'older' }}, {{ role: 'assistant', content: 'tail' }}, ], _messages_truncated: true, _messages_offset: 0, tool_calls: [], }}, }}; }} function _sameTranscriptMessage(a, b) {{ return a && b && a.role === b.role && a.content === b.content; }} function _syncToolCallsForLoadedMessages() {{}} function _messageIsRenderable() {{ return true; }} function msgContent(message) {{ return message && message.content; }} function _currentMessageRenderWindowSize() {{ return _messageRenderWindowSize; }} function renderMessages() {{ now += 200; container.scrollHeight = 1300; }} function _captureMessageViewportAnchor() {{ return null; }} function _restoreMessageViewportAnchor() {{ return false; }} function _messageVirtualPrependedHeightDelta() {{ return null; }} function requestAnimationFrame() {{ return 0; }} {_function_body('_freshProgrammaticScrollActive')} {_function_body('_loadOlderMessages', SESSIONS_JS)} await _loadOlderMessages(); if (!writeObservation) throw new Error('real fallback did not write scrollTop'); if (!writeObservation.fresh) throw new Error('real fallback write inherited a stale latch'); if (writeObservation.at !== 1200) throw new Error('fallback timestamp was not taken after slow render'); now = writeObservation.at + 151; if (_freshProgrammaticScrollActive()) throw new Error('fallback latch did not expire from its own write'); console.log(JSON.stringify({{ top: storedTop, observation: writeObservation }})); """ result = subprocess.run( ["node", "--input-type=module", "-e", script], check=True, capture_output=True, text=True, ) observed = json.loads(result.stdout) assert observed["top"] == 400 assert observed["observation"] == {"value": 400, "at": 1200, "fresh": True} def test_all_programmatic_scroll_writers_stamp_the_shared_freshness_clock(): """Keep UI and session writers from silently drifting apart.""" for source_name, source in (("ui.js", UI_JS), ("sessions.js", SESSIONS_JS)): writers = list(re.finditer(r"_programmaticScroll\s*=\s*true\s*;", source)) assert writers, f"{source_name} has no programmatic-scroll writers" for writer in writers: tail = source[writer.end() : writer.end() + 160] assert re.match( r"\s*_programmaticScrollSetAt\s*=\s*performance\.now\(\)\s*;", tail, ), f"{source_name} writer lacks a paired freshness timestamp"