diff --git a/static/messages.js b/static/messages.js index e24f90d1..a25a68ae 100644 --- a/static/messages.js +++ b/static/messages.js @@ -8022,6 +8022,20 @@ function startSessionStream(sid) { // the hidden-tab return (loadSession force + keepStaleUntilLoaded): the new // transcript replaces the old in a single render frame — NO clear+refetch, // so the #5177/#5189 blank-gap "jump" is not reintroduced. + // #6999: focusing a backgrounded tab also fires the visibility-recovery + // probe in sessions.js (refreshActiveSessionIfExternallyUpdated), which + // holds the shared _activeSessionExternalRefreshInFlight guard while it + // probes + force-reloads this same session. This handler honors that guard + // so the two paths never start two concurrent loadSession(force) calls + // (double full-transcript fetch + double renderMessages pass = the OOM + // pattern on long sessions). The probe side carries its own + // _loadingSessionId guard; loadSession() keeps its legitimate + // newest-wins supersede semantics untouched. + // #6999 re-gate: while the probe owns the guard, frames are COALESCED via + // _coalesceSessionUpdatedWhileRefreshHeld — the max announced count is + // latched per SID and the owner's finally runs ONE guarded follow-up when + // local state is still behind (a bare return dropped the update; production + // does not guarantee a second event). es.addEventListener('session-updated', e => { try { const d = JSON.parse(e.data || '{}'); @@ -8034,12 +8048,13 @@ function startSessionStream(sid) { : (S.session && S.session.session_id === sid); if (!isCurrent) return; if (S.activeStreamId) return; + const serverCount = Number(d.message_count); + if (typeof _coalesceSessionUpdatedWhileRefreshHeld === 'function' && _coalesceSessionUpdatedWhileRefreshHeld(sid, serverCount)) return; // Re-check against our CURRENT known count — a concurrent load may have // already caught us up between the server's emit and now. const localCount = (S.session && S.session.session_id === sid && Number.isFinite(Number(S.session.message_count))) ? Number(S.session.message_count) : (Array.isArray(S.messages) ? S.messages.length : 0); - const serverCount = Number(d.message_count); if (!Number.isFinite(serverCount) || serverCount <= localCount) return; if (typeof loadSession === 'function') { void loadSession(sid, {force: true, externalRefreshReason: 'session-updated', keepStaleUntilLoaded: true}); diff --git a/static/sessions.js b/static/sessions.js index c5d7acc8..04653272 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1703,6 +1703,12 @@ async function loadSession(sid){ // #2971: idempotent re-arm before the no-op guard revives a stream a prior // failed loadSession killed; no-ops on real switches. _rearmActiveSessionStream(); + // #6999: same-session force-reload coordination lives in the refresh paths + // (refreshActiveSessionIfExternallyUpdated guard + session-updated SSE + // handler in messages.js), NOT here: a second loadSession(sid,{force:true}) + // for the same sid is a legitimate supersede (generation bump below) that + // cross-session ordering tests rely on. Coalescing at the entry point would + // drop the superseding fetch and leave a stale first load in charge. if(currentSid===sid && !forceReload && (!_loadingSessionId || _loadingSessionId===sid)){ // Re-selecting the already-open session is a no-op for transcript/scroll, but // it is still a *visit*: clear a stale sidebar unread dot (e.g. one a @@ -3187,7 +3193,20 @@ async function _ensureMessagesLoaded(sid, opts) { // Expand render window to cover all loaded messages so the next // renderMessages() doesn't hide most of them behind a tiny window. if(typeof _messageRenderableMessageCount==='function'&&typeof _currentMessageRenderWindowSize==='function'){ - _messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(), _messageRenderableMessageCount()); + // #6999: bound the auto-expansion. This number gates + // _messageHiddenBeforeCount() (load-older / jump-to-start affordances) + // and the non-virtualized fallback render width; the virtualized DOM tail + // is independently capped at MESSAGE_RENDER_WINDOW_DEFAULT via + // _messageVirtualKeepTailCount(). Growing the window to the FULL loaded + // transcript on every force reload (tab focus, SSE catch-up) zeroes the + // hidden-before count for long sessions and widens the effective render + // window for non-virtualized paths. Keep the #3686 intent (don't collapse + // back to the 50-row default after a load) but cap the growth to a small + // multiple of the default window. + _messageRenderWindowSize=Math.max( + _currentMessageRenderWindowSize(), + Math.min(_messageRenderableMessageCount(), (typeof MESSAGE_RENDER_WINDOW_DEFAULT==='number'?MESSAGE_RENDER_WINDOW_DEFAULT:50)*4) + ); } if(S.session&&S.session.session_id===sid){ S.session.message_count=Number(data.session.message_count || msgs.length); @@ -5730,6 +5749,12 @@ let _sessionTimeRefreshVisibilityHandler = null; let _activeSessionExternalRefreshTimer = null; let _activeSessionExternalRefreshInFlight = false; let _deferredActiveSessionExternalRefreshReason = ''; +// #6999 re-gate: per-SID latch of the maximum message_count announced by a +// `session-updated` frame while the external-refresh guard was held. The +// refresh owner runs ONE guarded follow-up in its finally instead of letting +// the event die — production does not guarantee a second event, so discarding +// would leave the transcript stale until the next focus/poll. +let _pendingSessionUpdatedCounts = null; let _sessionEventsSSE = null; let _sessionEventsRefreshTimer = 0; let _sessionEventsRefreshPendingRequest = null; @@ -5822,6 +5847,51 @@ function _flushDeferredActiveSessionExternalRefresh(){ void refreshActiveSessionIfExternallyUpdated(reason); } +// #6999 re-gate: coalesce, never discard. Called by the messages.js +// `session-updated` handler when it finds the external-refresh guard held: +// instead of dropping the update (production does not guarantee a second +// event), latch the MAXIMUM announced count per SID. The refresh owner's +// finally drains it with ONE guarded follow-up. +function _latchSessionUpdatedPendingCount(sid, count){ + const n = Number(count); + if(!sid || !Number.isFinite(n)) return; + if(!_pendingSessionUpdatedCounts) _pendingSessionUpdatedCounts = {}; + const prev = _pendingSessionUpdatedCounts[sid]; + if(prev === undefined || n > prev) _pendingSessionUpdatedCounts[sid] = n; +} + +// Single-line entry used by the messages.js `session-updated` handler: +// returns true when the external-refresh probe owns the guard (the frame was +// latched for the owner's finally to drain); returns false when the handler +// should take its normal direct-load path. +function _coalesceSessionUpdatedWhileRefreshHeld(sid, count){ + if(typeof _activeSessionExternalRefreshInFlight === 'undefined' || !_activeSessionExternalRefreshInFlight) return false; + _latchSessionUpdatedPendingCount(sid, count); + return true; +} + +// Drain the per-SID latch after the external-refresh owner releases its +// guard. Runs ONE guarded follow-up only when local state is still behind +// the latched count — a same-SID load during the refresh window already +// caught us up, a switch-away moved the active session elsewhere, and +// duplicate events coalesce into this single follow-up. +function _drainSessionUpdatedPendingCount(){ + const pending = _pendingSessionUpdatedCounts; + _pendingSessionUpdatedCounts = null; + if(!pending || !S.session || !S.session.session_id) return; + const sid = S.session.session_id; + const latched = pending[sid]; + if(latched === undefined) return; + const localCount = Number(S.session.message_count || (Array.isArray(S.messages)?S.messages.length:0) || 0); + if(Number.isFinite(localCount) && localCount >= latched) return; + // The follow-up re-enters refreshActiveSessionIfExternallyUpdated, which + // re-probes server metadata and only force-reloads when the count actually + // changed — so the OOM guards (busy/stream/loading/document.hidden) all + // apply, and a metadata-read-only probe that already ran during the refresh + // window gets a second chance to observe the latched growth. + void refreshActiveSessionIfExternallyUpdated('session-updated'); +} + // Reconcile the active session against server-side metadata. Returns a status // string so callers (notably the post-stream idle reconcile) can decide how to // react: @@ -5846,6 +5916,10 @@ async function refreshActiveSessionIfExternallyUpdated(reason){ if(_activeSessionExternalRefreshInFlight) return 'skipped'; if(!S.session || !S.session.session_id) return 'skipped'; if(S.busy || S.activeStreamId) return 'skipped'; + // #6999: if a load for this exact session is already in flight, it owns the + // refresh — probing now would duplicate the fetch and the O(N) render work + // that exhausts the tab's JS heap when the tab comes back into focus. + if(_loadingSessionId === S.session.session_id) return 'skipped'; if(typeof _isMessageReaderUnpinned==='function'&&_isMessageReaderUnpinned()){ _deferActiveSessionExternalRefresh(reason||'poll'); return 'skipped'; @@ -5930,6 +6004,10 @@ async function refreshActiveSessionIfExternallyUpdated(reason){ return 'failed'; }finally{ _activeSessionExternalRefreshInFlight = false; + // #6999 re-gate: any `session-updated` frames that arrived while we owned + // the guard were latched (coalesced), not dropped — run ONE guarded + // follow-up when local state is still behind the latched count. + _drainSessionUpdatedPendingCount(); } } diff --git a/static/ui.js b/static/ui.js index f98cb2df..05bd29b0 100644 --- a/static/ui.js +++ b/static/ui.js @@ -15150,6 +15150,54 @@ function clearMessageRenderCache(){ _clearMessageVirtualHeightCache(); } +// #6999: feed a structured payload field's string form through the FNV-1a +// loop IN FULL, without materializing clipped copies or skipping the middle. +// The previous length+head+tail clip made same-length middle-only edits +// (tool arguments, attachment metadata, tool snippets, compression-anchor +// keys) produce identical signatures — a deterministic stale-cache collision +// in _sessionHtmlCache (cross-session navigation served old HTML). Hashing +// every character keeps the signature sensitive to ANY content change at a +// constant-allocation cost: strings are streamed char-by-char (no copy) and +// object fields are walked key-by-key so only scalar string forms are ever +// allocated — no integral JSON.stringify() of a whole payload, and no +// head/tail slice copies. _renderCacheKey's length+edges shortcut is only +// safe for the render-window geometry key, where equal span+edges means +// equal window; here equal signature must mean equal CONTENT. +function _addBoundedHash(add, value, depth){ + if(value==null){ add('null'); return; } + const t=typeof value; + if(t==='string'){ add(value.length); add(value); return; } + if(t==='number'||t==='boolean'){ add(t); add(value); return; } + if(t==='object'){ _hashObjectInto(add, value, (depth||0)+1); return; } + add(t); add(String(value)); +} +function _hashObjectInto(add, value, depth){ + if(value==null){ add('null'); return; } + if(depth>64){ + // Pathological depth (e.g. a cyclic structure JSON.stringify would also + // reject): serialize integrally so no field is silently dropped — the + // exact same data still yields the exact same signature. + try{ add(JSON.stringify(value)); }catch(e){ add('[unserializable]'); } + return; + } + if(Array.isArray(value)){ + add('array'); add(value.length); + for(let i=0;i{add(tc&&tc.id);add(tc&&tc.name);add(tc&&tc.type);add(JSON.stringify(tc&&tc.function||{}));}); + m.tool_calls.forEach(tc=>{ + add(tc&&tc.id);add(tc&&tc.name);add(tc&&tc.type); + add(tc&&tc.function&&tc.function.name); + // function.arguments is already a string — streamed in full, no copy. + _addBoundedHash(add, tc&&tc.function&&tc.function.arguments); + }); } if(Array.isArray(m._partial_tool_calls)){ add('partial-tool-calls');add(m._partial_tool_calls.length); m._partial_tool_calls.forEach(tc=>{add(tc&&tc.id);add(tc&&tc.name);add(tc&&tc.snippet);}); } if(_messageHasReasoningPayload(m)) add(m.reasoning||m.thinking||m._reasoning||'reasoning'); - if(Array.isArray(m.attachments)) m.attachments.forEach(a=>add(a&&typeof a==='object'?JSON.stringify(a):a)); + if(Array.isArray(m.attachments)) m.attachments.forEach(a=>_addBoundedHash(add, a)); } const toolCalls=Array.isArray(S.toolCalls)?S.toolCalls:[]; add('settled-tool-calls');add(toolCalls.length); toolCalls.forEach(tc=>{ if(!tc||typeof tc!=='object'){ add(tc); return; } - add(tc.tid);add(tc.id);add(tc.name);add(tc.done);add(tc.is_diff);add(tc.assistant_msg_idx);add(tc.snippet);add(JSON.stringify(tc.args||{})); + add(tc.tid);add(tc.id);add(tc.name);add(tc.done);add(tc.is_diff);add(tc.assistant_msg_idx); + _addBoundedHash(add, tc.snippet); + _addBoundedHash(add, tc.args||{}); }); if(S.session){ add(S.session.message_count);add(S.session.updated_at);add(S.session.compression_anchor_visible_idx); - add(JSON.stringify(S.session.compression_anchor_message_key||null)); + _addBoundedHash(add, S.session.compression_anchor_message_key||null); add(S.session.compression_anchor_summary||''); } return `${messages.length}:${toolCalls.length}:${hash.toString(16)}`; @@ -16514,6 +16569,17 @@ function renderMessages(options){ rawIdx++; } const firstRenderedRawIdx=renderVisWithIdx.length?renderVisWithIdx[0].rawIdx:Infinity; + // #6999: the turn-content maps MUST see the FULL visWithIdx, not the + // virtual render window. _assistantTurnFinalVisibleContentMap / + // _assistantTurnVisibleContentMap derive the echo-strip context for a + // rendered assistant row from ALL assistant siblings of its turn + // (ui.js:10783-10830). Windowed-out siblings are still input context even + // though their own rows are not read: cutting through an assistant run + // loses the final/visible answer used to strip reasoning echoes, and + // concatenating head+tail across an omitted user boundary would merge + // distinct turns into one run (duplicate final-answer in Worklog/Thinking, + // or later-turn prose used as an echo-strip input). Turn context must + // always be complete — never cut mid-run, never head+tail with a gap. const assistantTurnFinalVisibleContentByRawIdx=_assistantTurnFinalVisibleContentMap(visWithIdx); const assistantTurnVisibleContentByRawIdx=_assistantTurnVisibleContentMap(visWithIdx); const hasServerOlder=!!(typeof _messagesTruncated!=='undefined' && _messagesTruncated && S.messages.length>0); diff --git a/tests/test_issue2613_render_cache_signature.py b/tests/test_issue2613_render_cache_signature.py index bb812291..5580078e 100644 --- a/tests/test_issue2613_render_cache_signature.py +++ b/tests/test_issue2613_render_cache_signature.py @@ -1,7 +1,13 @@ +from __future__ import annotations + +import json +import re +import shutil +import subprocess from pathlib import Path - UI_JS = Path("static/ui.js").read_text(encoding="utf-8") +NODE = shutil.which("node") def test_session_html_cache_uses_render_signature_not_only_count(): @@ -24,3 +30,268 @@ def test_render_signature_tracks_message_content_and_settled_tool_cards(): def test_documentation_no_longer_allows_same_count_stale_html(): assert "Known limitation: cache key is session_id + message count" not in UI_JS assert "mutate message content without changing the count will serve stale HTML" not in UI_JS + + +# ── #6999: same-length MIDDLE-only mutations must change the signature ────── +# The first #6999 iteration hashed structured fields with length+head+tail +# (_addBoundedHash). Same-length edits in the MIDDLE of tool arguments, +# attachment metadata, tool snippets, or compression-anchor keys produced an +# identical signature, and _sessionHtmlCache treats signature equality as +# authority — a deterministic stale-cache collision on cross-session +# navigation. These tests execute the real _messageRenderCacheSignature() +# (plus its helpers) under Node and prove that a middle-only, same-length +# mutation of every structured field changes the signature, while the old +# clip scheme demonstrably collides on the very same data. + + +def _function_source(name: str) -> str: + """Return the full ``function name(...) {...}`` source from static/ui.js.""" + marker = f"function {name}" + start = UI_JS.find(marker) + assert start != -1, f"{name} not found in static/ui.js" + params = UI_JS.find("(", start) + assert params != -1, f"{name} has no parameter list" + depth = 0 + close = -1 + for idx in range(params, len(UI_JS)): + if UI_JS[idx] == "(": + depth += 1 + elif UI_JS[idx] == ")": + depth -= 1 + if depth == 0: + close = idx + break + assert close != -1, f"{name} parameter list did not close" + brace = UI_JS.find("{", close) + assert brace != -1, f"{name} has no body" + depth = 0 + for idx in range(brace, len(UI_JS)): + if UI_JS[idx] == "{": + depth += 1 + elif UI_JS[idx] == "}": + depth -= 1 + if depth == 0: + return UI_JS[start : idx + 1] + raise AssertionError(f"{name} body did not close") + + +_FN_DECL_RE = re.compile(r"\bfunction\s+([A-Za-z_$][\w$]*)\s*\(") + + +def _collect_functions(names: list[str]) -> str: + """Concatenate the sources of ``names`` plus every ``function X(`` dep.""" + out: dict[str, str] = {} + stack = list(names) + seen: set[str] = set() + while stack: + name = stack.pop() + if name in seen: + continue + seen.add(name) + src = _function_source(name) + out[name] = src + for dep in _FN_DECL_RE.findall(src): + if dep not in seen: + stack.append(dep) + return "\n".join(out.values()) + + +_SIGNATURE_NODE_BODY = r""" +const LONG = 'A'.repeat(5000); +// Same-length middle-only mutation of LONG: byte-for-byte identical head and +// tail, one different character at position 2500. +const MID = LONG.slice(0, 2500) + 'B' + LONG.slice(2501); +// Snippets used a 4096-char clip window: the mutation must sit outside BOTH +// the 4096 head and the 4096 tail, so the field has to exceed 8192 chars. +const LONG2 = 'C'.repeat(20000); +const MID2 = LONG2.slice(0, 10000) + 'D' + LONG2.slice(10001); +function state(mutator) { + const S = { + messages: [ + {role: 'user', content: 'u1'}, + {role: 'assistant', content: 'a1', tool_calls: [{id: 'tc-m1', type: 'function', function: {name: 'shell', arguments: LONG}}]}, + {role: 'assistant', content: 'a2', attachments: [{name: 'report.pdf', data: LONG}]}, + {role: 'user', content: 'u2'}, + {role: 'assistant', content: 'a3'}, + ], + toolCalls: [{tid: 'tc1', id: 'tc-m1', name: 'shell', done: true, is_diff: false, assistant_msg_idx: 1, snippet: LONG2, args: {cmd: 'cat', data: LONG}}], + session: {session_id: 'sid', message_count: 5, updated_at: 111, compression_anchor_visible_idx: 0, compression_anchor_message_key: {role: 'assistant', ts: 123, text: LONG, attachments: 1}, compression_anchor_summary: ''}, + }; + if (mutator) mutator(S); + return S; +} +// The pre-#6999 clip scheme: length + head + tail of the string form. +function oldBoundedHash(value, maxLen) { + const limit = Math.max(64, Number(maxLen) || 512); + let s = ''; + try { s = (value == null) ? '' : String(value); } catch (e) { s = ''; } + let h = 2166136261; + const add = (v) => { + const st = String(v == null ? '' : v); + for (let i = 0; i < st.length; i++) { h ^= st.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; } + h ^= 31; h = Math.imul(h, 16777619) >>> 0; + }; + add(s.length); + if (s.length > limit) { add(s.slice(0, limit)); add(s.slice(-limit)); } else { add(s); } + return h; +} +globalThis.S = state(null); +const base = _messageRenderCacheSignature(); +const baseAgain = _messageRenderCacheSignature(); + +// 1) middle-only edit of a message tool-call's function.arguments string. +globalThis.S = state((S) => { S.messages[1].tool_calls[0].function.arguments = MID; }); +const sigArgsMut = _messageRenderCacheSignature(); +// 2) middle-only edit inside a settled tool call's args object. +globalThis.S = state((S) => { S.toolCalls[0].args.data = MID; }); +const sigArgsObjMut = _messageRenderCacheSignature(); +// 3) middle-only edit inside an attachment object. +globalThis.S = state((S) => { S.messages[2].attachments[0].data = MID; }); +const sigAttachMut = _messageRenderCacheSignature(); +// 4) middle-only edit inside the compression-anchor message key. +globalThis.S = state((S) => { S.session.compression_anchor_message_key.text = MID; }); +const sigAnchorMut = _messageRenderCacheSignature(); +// 5) middle-only edit of a settled tool snippet (20000 chars, 4096 clip window). +globalThis.S = state((S) => { S.toolCalls[0].snippet = MID2; }); +const sigSnippetMut = _messageRenderCacheSignature(); + +console.log(JSON.stringify({ + deterministic: base === baseAgain, + differArgs: base !== sigArgsMut, + differArgsObj: base !== sigArgsObjMut, + differAttach: base !== sigAttachMut, + differAnchor: base !== sigAnchorMut, + differSnippet: base !== sigSnippetMut, + oldCollidesArgs: oldBoundedHash(LONG, 2048) === oldBoundedHash(MID, 2048), + oldCollidesArgsObj: oldBoundedHash(JSON.stringify({cmd: 'cat', data: LONG}), 2048) === oldBoundedHash(JSON.stringify({cmd: 'cat', data: MID}), 2048), + oldCollidesAttach: oldBoundedHash(JSON.stringify({name: 'report.pdf', data: LONG}), 2048) === oldBoundedHash(JSON.stringify({name: 'report.pdf', data: MID}), 2048), + oldCollidesAnchor: oldBoundedHash(JSON.stringify({role: 'assistant', ts: 123, text: LONG, attachments: 1}), 1024) === oldBoundedHash(JSON.stringify({role: 'assistant', ts: 123, text: MID, attachments: 1}), 1024), + oldCollidesSnippet: oldBoundedHash(LONG2, 4096) === oldBoundedHash(MID2, 4096), +})); +""" + + +def _run_signature_node() -> dict: + assert NODE, "node is required for the render-cache signature harness" + script = f"const window = {{}};\n{_collect_functions(['_messageRenderCacheSignature', '_addBoundedHash', '_hashObjectInto', 'msgContent', '_messageHasReasoningPayload'])}\n{_SIGNATURE_NODE_BODY}" + result = subprocess.run([NODE, "-e", script], text=True, capture_output=True, check=False) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_middle_only_mutations_change_render_signature(): + out = _run_signature_node() + assert out["deterministic"], "identical state must yield an identical signature" + # Every same-length middle-only edit must change the signature — the + # pre-#6999 length+head+tail clip collided on exactly these edits. + assert out["differArgs"], "middle edit of tool function.arguments must change the signature" + assert out["differArgsObj"], "middle edit inside tc.args must change the signature" + assert out["differAttach"], "middle edit inside an attachment must change the signature" + assert out["differAnchor"], "middle edit inside the compression-anchor key must change the signature" + assert out["differSnippet"], "middle edit of a tool snippet must change the signature" + + +def test_old_clip_scheme_collides_on_the_same_middle_edits(): + """Document the exact bug class: the old clip scheme collides here.""" + out = _run_signature_node() + assert out["oldCollidesArgs"] + assert out["oldCollidesArgsObj"] + assert out["oldCollidesAttach"] + assert out["oldCollidesAnchor"] + assert out["oldCollidesSnippet"] + + +# ── #6999 re-gate: insertion order + recursion depth ──────────────────────── +# _hashObjectInto() used to Object.keys(value).sort() while BOTH tool-detail +# render paths (buildToolCard and _transparentToolDetailHtml) render +# Object.entries(tc.args) — insertion order. Two argument objects with +# opposite insertion order therefore rendered DIFFERENT HTML while receiving +# the SAME cache signature (sandbox repro: alpha=A|beta=B vs beta=B|alpha=A +# with sameSignature=true). The fix walks keys in insertion order with an +# explicit per-key index discriminator, and threads recursion depth (child +# calls used to restart depth at 0, so the depth>64 guard never fired for +# nested/cyclic payloads). + + +def test_hash_object_preserves_insertion_order_not_sorted(): + fn = _function_source("_hashObjectInto") + assert "Object.keys(value).sort()" not in fn, ( + "sorting keys aliases opposite-insertion-order args onto one signature" + ) + assert "const keys=Object.keys(value);" in fn + assert "add(i)" in fn, "the per-key index must discriminate insertion order" + + +def test_hash_add_bounded_threads_recursion_depth(): + fn = _function_source("_addBoundedHash") + assert "function _addBoundedHash(add, value, depth)" in fn + assert "_hashObjectInto(add, value, (depth||0)+1)" in fn, ( + "child objects must increment depth, not restart at 0" + ) + + +_INSERTION_ORDER_NODE_BODY = r""" +function stateWithArgs(args) { + return { + messages: [ + {role: 'user', content: 'u1'}, + {role: 'assistant', content: 'a1'}, + ], + toolCalls: [{tid: 'tc1', id: 'tc-m1', name: 'shell', done: true, is_diff: false, assistant_msg_idx: 1, snippet: 'out', args}], + session: {session_id: 'sid', message_count: 2, updated_at: 111, compression_anchor_visible_idx: 0, compression_anchor_message_key: null, compression_anchor_summary: ''}, + }; +} +globalThis.S = stateWithArgs({alpha: 'A', beta: 'B'}); +const sigAlphaBeta = _messageRenderCacheSignature(); +globalThis.S = stateWithArgs({beta: 'B', alpha: 'A'}); +const sigBetaAlpha = _messageRenderCacheSignature(); +globalThis.S = stateWithArgs({alpha: 'A', beta: 'B'}); +const sigAlphaBetaAgain = _messageRenderCacheSignature(); +// Deeply nested payload: 100 levels of objects (far past the depth>64 guard). +let deep = {leaf: 'x'}; +for (let i = 0; i < 100; i++) deep = {child: deep}; +globalThis.S = stateWithArgs(deep); +const sigDeep = _messageRenderCacheSignature(); +globalThis.S = stateWithArgs(deep); +const sigDeepAgain = _messageRenderCacheSignature(); +// Cyclic payload: JSON.stringify would reject it; the depth guard must +// serialize it via the [unserializable] path instead of recursing forever. +const cyc = {}; cyc.self = cyc; +globalThis.S = stateWithArgs(cyc); +const sigCyclic = _messageRenderCacheSignature(); +console.log(JSON.stringify({ + differInsertionOrder: sigAlphaBeta !== sigBetaAlpha, + sameOrderDeterministic: sigAlphaBeta === sigAlphaBetaAgain, + deepDeterministic: sigDeep === sigDeepAgain, + cyclicComputes: typeof sigCyclic === 'string' && sigCyclic.length > 0, +})); +""" + + +def _run_insertion_order_node() -> dict: + assert NODE, "node is required for the render-cache signature harness" + script = ( + f"const window = {{}};\n" + f"{_collect_functions(['_messageRenderCacheSignature', '_addBoundedHash', '_hashObjectInto', 'msgContent', '_messageHasReasoningPayload'])}\n" + f"{_INSERTION_ORDER_NODE_BODY}" + ) + result = subprocess.run([NODE, "-e", script], text=True, capture_output=True, check=False) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_opposite_insertion_order_arguments_get_different_signatures(): + """alpha=A|beta=B and beta=B|alpha=A must NOT share a cache signature.""" + out = _run_insertion_order_node() + assert out["differInsertionOrder"], ( + "opposite insertion order renders different HTML but the signature aliased it" + ) + assert out["sameOrderDeterministic"], "identical order must stay deterministic" + + +def test_depth_guard_survives_deep_and_cyclic_payloads(): + """Recursion depth must increment (not restart), so the depth>64 guard + actually fires for nested/cyclic tool args instead of overflowing.""" + out = _run_insertion_order_node() + assert out["deepDeterministic"], "100-level-deep args must hash deterministically" + assert out["cyclicComputes"], "cyclic args must fall back to [unserializable], not hang" diff --git a/tests/test_issue6999_session_updated_coalesce.py b/tests/test_issue6999_session_updated_coalesce.py new file mode 100644 index 00000000..8b7be876 --- /dev/null +++ b/tests/test_issue6999_session_updated_coalesce.py @@ -0,0 +1,248 @@ +"""#6999 re-gate -- `session-updated` frames must COALESCE, not be dropped. + +The first #6999 iteration made the messages.js `session-updated` handler +return immediately when the visibility-recovery probe owned the shared +``_activeSessionExternalRefreshInFlight`` guard. That dropped the update +entirely: a real-callback schedule emitted a larger count while the refresh +owned the guard and zero loads happened; only a manually repeated event after +release caused a load. Production does not guarantee that second event. + +Fix (reviewer spec): latch the MAXIMUM pending count per SID while the guard +is held, and in the owner's ``finally`` run ONE guarded follow-up when local +state is still behind. The follow-up re-enters +``refreshActiveSessionIfExternallyUpdated`` so every OOM guard (busy, stream, +loading, hidden) still applies, and it only force-reloads when the metadata +probe actually observes the count change. + +Scenarios covered: metadata-read (probe ran before the write landed), +same-SID load (already caught up -> no second load), switch-away (latched for +the old SID -> no follow-up), duplicate events (max latched -> ONE follow-up). +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") +MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8") +NODE = shutil.which("node") + + +def _function_source(src: str, name: str) -> str: + """Return the full ``function name(...) {...}`` source from ``src``.""" + marker = f"function {name}" + start = src.find(marker) + assert start != -1, f"{name} not found" + params = src.find("(", start) + assert params != -1, f"{name} has no parameter list" + depth = 0 + close = -1 + for idx in range(params, len(src)): + if src[idx] == "(": + depth += 1 + elif src[idx] == ")": + depth -= 1 + if depth == 0: + close = idx + break + assert close != -1, f"{name} parameter list did not close" + brace = src.find("{", close) + assert brace != -1, f"{name} has no body" + depth = 0 + for idx in range(brace, len(src)): + if src[idx] == "{": + depth += 1 + elif src[idx] == "}": + depth -= 1 + if depth == 0: + return src[start : idx + 1] + raise AssertionError(f"{name} body did not close") + + +def test_pending_count_latch_and_drain_helpers_exist(): + assert "function _latchSessionUpdatedPendingCount(sid, count)" in SESSIONS_JS + assert "function _drainSessionUpdatedPendingCount()" in SESSIONS_JS + assert "let _pendingSessionUpdatedCounts = null;" in SESSIONS_JS + # The owner's finally must drain the latch (coalesce, never discard). + owner = _function_source(SESSIONS_JS, "refreshActiveSessionIfExternallyUpdated") + assert "_drainSessionUpdatedPendingCount();" in owner + assert "_activeSessionExternalRefreshInFlight = false;" in owner + # The drain must re-enter the guarded refresh (single follow-up). + drain = _function_source(SESSIONS_JS, "_drainSessionUpdatedPendingCount") + assert "refreshActiveSessionIfExternallyUpdated('session-updated')" in drain + assert "_pendingSessionUpdatedCounts = null;" in drain + assert "localCount >= latched" in drain, "caught-up local state must skip the follow-up" + + +def test_messages_handler_latches_instead_of_dropping_when_guard_held(): + start = MESSAGES_JS.index("es.addEventListener('session-updated', e => {") + end = MESSAGES_JS.index("\n });", start) + len("\n });") + handler = MESSAGES_JS[start:end] + assert "_coalesceSessionUpdatedWhileRefreshHeld(sid, serverCount)" in handler, ( + "the handler must route the frame through the coalesce helper while the guard is held" + ) + # The old bare-return must be gone from this handler. + assert "_activeSessionExternalRefreshInFlight) return;" not in handler + + +def _run_node(script: str) -> dict: + assert NODE, "node is required for the coalesce harness" + result = subprocess.run([NODE, "-e", script], text=True, capture_output=True, check=False) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +_COALESCE_NODE_BODY = r""" +const record = []; +let _pendingSessionUpdatedCounts = null; +const S = {session: {session_id: 's1', message_count: 3}, messages: []}; +global.refreshActiveSessionIfExternallyUpdated = async (reason) => { record.push(['followup', reason]); }; +global.S = S; +""" + + +def _coalesce_script(body: str) -> str: + fns = "\n\n".join( + [ + _function_source(SESSIONS_JS, "_latchSessionUpdatedPendingCount"), + _function_source(SESSIONS_JS, "_drainSessionUpdatedPendingCount"), + ] + ) + return f"{_COALESCE_NODE_BODY}\n{fns}\n{body}" + + +def test_latch_takes_max_count_and_drain_runs_one_followup(): + script = _coalesce_script( + r""" +(async () => { + // Duplicate events while the guard is held: 3, then 5, then 4. + _latchSessionUpdatedPendingCount('s1', 3); + _latchSessionUpdatedPendingCount('s1', 5); + _latchSessionUpdatedPendingCount('s1', 4); + _drainSessionUpdatedPendingCount(); + // One follow-up only (coalesced), with the max latched count. + console.log(JSON.stringify({record})); +})(); +""" + ) + out = _run_node(script) + assert out["record"] == [["followup", "session-updated"]], out["record"] + + +def test_drain_skips_when_local_state_already_caught_up(): + script = _coalesce_script( + r""" +(async () => { + // A same-SID load during the refresh window already caught us up. + S.session.message_count = 6; + _latchSessionUpdatedPendingCount('s1', 6); + _drainSessionUpdatedPendingCount(); + console.log(JSON.stringify({record})); +})(); +""" + ) + out = _run_node(script) + assert out["record"] == [], out["record"] + + +def test_drain_skips_after_switch_away(): + script = _coalesce_script( + r""" +(async () => { + // User switched to another session while the refresh held the guard: + // the latched count belongs to the OLD sid, so no follow-up may fire. + S.session.session_id = 's2'; + _latchSessionUpdatedPendingCount('s1', 9); + _drainSessionUpdatedPendingCount(); + console.log(JSON.stringify({record})); +})(); +""" + ) + out = _run_node(script) + assert out["record"] == [], out["record"] + + +def test_drain_clears_latch_and_handles_multiple_sids(): + script = _coalesce_script( + r""" +(async () => { + // Events for another SID must not leak into the current session's follow-up. + _latchSessionUpdatedPendingCount('s2', 50); + S.session.message_count = 3; + _latchSessionUpdatedPendingCount('s1', 8); + _drainSessionUpdatedPendingCount(); + const afterDrain = _pendingSessionUpdatedCounts; + console.log(JSON.stringify({record, afterDrain})); +})(); +""" + ) + out = _run_node(script) + assert out["record"] == [["followup", "session-updated"]], out["record"] + assert out["afterDrain"] is None, "the latch must be cleared after draining" + + +def test_coalesce_helper_latches_when_guard_held_and_passes_through_when_free(): + fns = "\n\n".join( + [ + _function_source(SESSIONS_JS, "_latchSessionUpdatedPendingCount"), + _function_source(SESSIONS_JS, "_coalesceSessionUpdatedWhileRefreshHeld"), + ] + ) + script = f""" +{_COALESCE_NODE_BODY} +let _activeSessionExternalRefreshInFlight = true; +{fns} +const heldResult = _coalesceSessionUpdatedWhileRefreshHeld('s1', 7); +const latchedWhileHeld = _pendingSessionUpdatedCounts && _pendingSessionUpdatedCounts['s1']; +_activeSessionExternalRefreshInFlight = false; +const freeResult = _coalesceSessionUpdatedWhileRefreshHeld('s1', 8); +const latchedWhileFree = _pendingSessionUpdatedCounts && _pendingSessionUpdatedCounts['s1']; +console.log(JSON.stringify({{heldResult, latchedWhileHeld, freeResult, latchedWhileFree}})); +""" + out = _run_node(script) + assert out["heldResult"] is True + assert out["latchedWhileHeld"] == 7, "the guard-held frame must be latched" + assert out["freeResult"] is False, "a free guard must pass through to the direct-load path" + assert out["latchedWhileFree"] == 7, "a free-guard frame must NOT latch" + + +def test_messages_handler_latches_when_guard_held_and_loads_when_free(): + start = MESSAGES_JS.index("es.addEventListener('session-updated', e => {") + # The handler body is the try/catch block before the handler's own + # closing " });" (4-space indent, at end of the addEventListener call). + end = MESSAGES_JS.index("\n });", start) + body = MESSAGES_JS[start + len("es.addEventListener('session-updated', e => {") : end] + script = f""" +const record = []; +const sid = 's1'; +const S = {{session: {{session_id: 's1', message_count: 5}}, messages: [], activeStreamId: null}}; +let _activeSessionExternalRefreshInFlight = true; +const _coalesceSessionUpdatedWhileRefreshHeld = (s, c) => {{ + if (!_activeSessionExternalRefreshInFlight) return false; + record.push(['latch', s, c]); + return true; +}}; +const _isSessionCurrentPane = () => true; +const loadSession = (sid, opts) => record.push(['load', sid, opts]); +global.S = S; +const handler = (e) => {{ +{body} +}}; +// Guard held: the update must be latched, never dropped, and NOT loaded now. +handler({{data: JSON.stringify({{session_id: 's1', message_count: 9}})}}); +const afterHeld = record.slice(); +// Guard released: the same frame must load directly (unchanged behavior). +_activeSessionExternalRefreshInFlight = false; +handler({{data: JSON.stringify({{session_id: 's1', message_count: 9}})}}); +console.log(JSON.stringify({{afterHeld, full: record}})); +""" + out = _run_node(script) + assert out["afterHeld"] == [["latch", "s1", 9]], out["afterHeld"] + assert out["full"] == [ + ["latch", "s1", 9], + ["load", "s1", {"force": True, "externalRefreshReason": "session-updated", "keepStaleUntilLoaded": True}], + ], out["full"] diff --git a/tests/test_issue6999_turn_context_virtual_gap.py b/tests/test_issue6999_turn_context_virtual_gap.py new file mode 100644 index 00000000..bc8aecb7 --- /dev/null +++ b/tests/test_issue6999_turn_context_virtual_gap.py @@ -0,0 +1,217 @@ +"""#6999 -- virtual-window turn context must stay complete. + +The first #6999 iteration fed the assistant-turn content maps +(_assistantTurnFinalVisibleContentMap / _assistantTurnVisibleContentMap) the +*render* window, ``renderVisWithIdx`` — which is +``renderHeadVisWithIdx.concat(renderTailVisWithIdx)`` around a virtualized gap +(static/ui.js:15404-15413). Those helpers derive the echo-strip context of a +rendered assistant row from ALL assistant siblings of its turn +(static/ui.js:10783-10830), so windowed-out siblings are input context even +though their own rows are not read: + +* cutting through an assistant run loses the final/visible answer used to + strip reasoning echoes -> duplicate final-answer in Worklog/Thinking; +* concatenating head+tail across an omitted user boundary merges distinct + turns into one run -> unrelated later-turn prose becomes the echo-strip + input. + +These regressions lock the correction: renderMessages() must feed the maps +the FULL ``visWithIdx`` (never the virtual window), and the maps + echo-strip +chain must behave correctly on that full input. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") +NODE = shutil.which("node") + + +def _render_messages_body() -> str: + start = UI_JS.find("function renderMessages(") + assert start != -1, "renderMessages() not found" + return UI_JS[start : start + 80000] + + +def test_render_messages_feeds_full_vis_with_idx_to_turn_maps(): + body = _render_messages_body() + assert "_assistantTurnFinalVisibleContentMap(visWithIdx)" in body, ( + "Turn context maps must see the FULL visWithIdx so an assistant run is " + "never cut by the virtual window." + ) + assert "_assistantTurnVisibleContentMap(visWithIdx)" in body, ( + "Turn visible-content map must see the FULL visWithIdx." + ) + assert "_assistantTurnFinalVisibleContentMap(renderVisWithIdx)" not in body, ( + "The virtualized render window (head+tail with a gap) must never be " + "used as turn-context input — it cuts runs and merges distinct turns." + ) + assert "_assistantTurnVisibleContentMap(renderVisWithIdx)" not in body + + +def _function_source(name: str) -> str: + """Return the full ``function name(...) {...}`` source from static/ui.js.""" + marker = f"function {name}" + start = UI_JS.find(marker) + assert start != -1, f"{name} not found in static/ui.js" + params = UI_JS.find("(", start) + assert params != -1, f"{name} has no parameter list" + depth = 0 + close = -1 + for idx in range(params, len(UI_JS)): + if UI_JS[idx] == "(": + depth += 1 + elif UI_JS[idx] == ")": + depth -= 1 + if depth == 0: + close = idx + break + assert close != -1, f"{name} parameter list did not close" + brace = UI_JS.find("{", close) + assert brace != -1, f"{name} has no body" + depth = 0 + for idx in range(brace, len(UI_JS)): + if UI_JS[idx] == "{": + depth += 1 + elif UI_JS[idx] == "}": + depth -= 1 + if depth == 0: + return UI_JS[start : idx + 1] + raise AssertionError(f"{name} body did not close") + + +_FN_DECL_RE = re.compile(r"\bfunction\s+([A-Za-z_$][\w$]*)\s*\(") + + +def _collect_functions(names: list[str]) -> str: + """Concatenate the sources of ``names`` plus every ``function X(`` dep.""" + out: dict[str, str] = {} + stack = list(names) + seen: set[str] = set() + while stack: + name = stack.pop() + if name in seen: + continue + seen.add(name) + src = _function_source(name) + out[name] = src + for dep in _FN_DECL_RE.findall(src): + if dep not in seen: + stack.append(dep) + return "\n".join(out.values()) + + +_TURN_CONTEXT_NODE_BODY = r""" +// Two turns. Turn 1 is a run of THREE assistant siblings: a thinking-only +// message that exactly echoes the turn's final answer (rawIdx 1), process +// prose (rawIdx 2), and the final answer (rawIdx 3). Turn 2 follows after a +// user boundary (rawIdx 4). +const full = [ + {m: {role: 'user', content: 'Question 1'}, rawIdx: 0}, + {m: {role: 'assistant', content: 'The answer is 42.'}, rawIdx: 1}, + {m: {role: 'assistant', content: 'I will inspect the file first.'}, rawIdx: 2}, + {m: {role: 'assistant', content: 'The answer is 42.'}, rawIdx: 3}, + {m: {role: 'user', content: 'Question 2'}, rawIdx: 4}, + {m: {role: 'assistant', content: 'Second thinking'}, rawIdx: 5}, + {m: {role: 'assistant', content: 'Second answer.'}, rawIdx: 6}, +]; +// Virtualized render window: head = rawIdx 0..2, tail = rawIdx 5..6. The gap +// (rawIdx 3..4) contains turn 1's FINAL ANSWER and the user boundary that +// separates the two turns. +const gapped = [full[0], full[1], full[2], full[5], full[6]]; + +const finalMapFull = _assistantTurnFinalVisibleContentMap(full); +const visibleMapFull = _assistantTurnVisibleContentMap(full); +const finalMapGapped = _assistantTurnFinalVisibleContentMap(gapped); +const visibleMapGapped = _assistantTurnVisibleContentMap(gapped); + +const a1FullFinal = finalMapFull.get(1) || ''; +const a2FullFinal = finalMapFull.get(2) || ''; +const a3FullFinal = finalMapFull.get(3) || ''; +const a1FullVisible = visibleMapFull.get(1) || []; +// Echo-strip for the rendered thinking-only row (rawIdx 1) using FULL turn +// context: the thinking exactly echoes the final answer -> stripped. +const strippedFull = _worklogReasoningTextFromMessage(full[1].m, 1, new Set(), '', a1FullFinal, a1FullVisible); + +// Contrast with the GAPPED window: the run is cut (final answer windowed +// out) and head+tail merge across the omitted user boundary into turn 2's +// run — rawIdx 1 would see turn 2's final answer as its strip input. +const a1GappedFinal = finalMapGapped.get(1) || ''; +const a1GappedVisible = visibleMapGapped.get(1) || []; +const strippedGapped = _worklogReasoningTextFromMessage(gapped[1].m, 1, new Set(), '', a1GappedFinal, a1GappedVisible); + +console.log(JSON.stringify({ + a1FullFinal, + a2FullFinal, + a3FullFinal, + a1FullVisible, + strippedFull, + a1GappedFinal, + a1GappedVisible, + strippedGapped, +})); +""" + + +def _run_turn_context_node() -> dict: + assert NODE, "node is required for the turn-context harness" + fns = _collect_functions( + [ + "_assistantTurnFinalVisibleContentMap", + "_assistantTurnVisibleContentMap", + "_assistantVisibleContentForReasoningCompare", + "_worklogReasoningTextFromMessage", + "_assistantReasoningPayloadText", + "_stripVisibleAssistantEchoFromThinking", + "_sanitizeThinkingDisplayText", + "_normalizeThinkingEchoCompare", + "_stripLeadingAssistantThinkingMarkup", + "_stripXmlToolCallsDisplay", + "_assistantAnchorSceneFinalAnswerText", + "_isMarkerOnlyAssistantCompressionMessage", + "_isPreservedCompressionTaskListMarkerOnlyText", + "_isPreservedCompressionTaskListMarkerText", + "_isAssistantEmptyPlaceholderContent", + "_messageHasReasoningPayload", + "msgContent", + ] + ) + script = f"const window = {{}};\n{fns}\n{_TURN_CONTEXT_NODE_BODY}" + result = subprocess.run([NODE, "-e", script], text=True, capture_output=True, check=False) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_full_turn_context_propagates_final_answer_and_strips_echo(): + out = _run_turn_context_node() + # Every assistant sibling of turn 1 — including the one whose row is + # windowed out (rawIdx 3) — must map to the run's final visible answer. + assert out["a1FullFinal"] == "The answer is 42." + assert out["a2FullFinal"] == "The answer is 42." + assert out["a3FullFinal"] == "The answer is 42." + # The rendered thinking-only sibling sees the full visible texts of its + # run: process prose AND the final answer. + assert out["a1FullVisible"] == ["I will inspect the file first.", "The answer is 42."] + # Exact echo of the final answer is stripped -> no duplicate final-answer + # in Worklog/Thinking. + assert out["strippedFull"] == "", ( + "Thinking that exactly echoes the turn's final answer must be " + "suppressed when the full turn context is available." + ) + + +def test_gapped_window_would_cut_run_and_merge_turns(): + """Document why renderMessages must NOT use the virtual window here.""" + out = _run_turn_context_node() + # Head+tail across the omitted user boundary merges turn 1 into turn 2's + # run: rawIdx 1 would receive turn 2's final answer as its strip input. + assert out["a1GappedFinal"] == "Second answer." + # The final answer of turn 1 is not in the visible texts (windowed out), + # so the echo is NOT stripped — the duplicate would reappear. + assert out["strippedGapped"] != "" diff --git a/tests/test_jump_to_answer_scroll_settle.py b/tests/test_jump_to_answer_scroll_settle.py index 49091538..be0d5a45 100644 --- a/tests/test_jump_to_answer_scroll_settle.py +++ b/tests/test_jump_to_answer_scroll_settle.py @@ -70,6 +70,7 @@ 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; @@ -156,6 +157,9 @@ def test_jump_geometry_controls_active_session_refresh_deferral( 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; @@ -260,6 +264,9 @@ def test_response_jump_owns_native_smooth_scroll_until_final_reconciliation( + _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; @@ -430,6 +437,7 @@ 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; @@ -532,6 +540,7 @@ 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; @@ -602,6 +611,7 @@ def test_streaming_frame_during_jump_owner_does_not_snap_to_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 = 0; let _programmaticScroll = false; @@ -658,6 +668,7 @@ def test_wheel_up_during_jump_owner_takes_reader_ownership_not_pinned_midtranscr 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; @@ -724,6 +735,7 @@ def test_wheel_down_during_jump_owner_also_takes_reader_ownership(): 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;