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>
This commit is contained in:
7 files changed
+914
-7
No files matched your search
+16
-1
@@ -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});
|
||||
|
||||
+79
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+70
-4
@@ -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<value.length;i++){ add(i); _addBoundedHash(add, value[i], depth); }
|
||||
return;
|
||||
}
|
||||
add('object');
|
||||
// #6999 re-gate: walk keys in INSERTION ORDER (never sorted) so the cache
|
||||
// signature equals the rendered projection — the tool-detail render paths
|
||||
// use Object.entries(tc.args), which preserves insertion order. Sorting here
|
||||
// gave opposite-insertion-order argument objects the same signature while
|
||||
// they render DIFFERENT HTML. The explicit per-key index is the
|
||||
// insertion-order discriminator: {alpha:A, beta:B} and {beta:B, alpha:A}
|
||||
// now hash differently.
|
||||
const keys=Object.keys(value);
|
||||
add(keys.length);
|
||||
for(let i=0;i<keys.length;i++){ add(keys[i]); add(i); _addBoundedHash(add, value[keys[i]], depth); }
|
||||
}
|
||||
|
||||
function _messageRenderCacheSignature(){
|
||||
let hash=2166136261;
|
||||
function add(value){
|
||||
@@ -15176,24 +15224,31 @@ function _messageRenderCacheSignature(){
|
||||
}
|
||||
if(Array.isArray(m.tool_calls)){
|
||||
add('message-tool-calls');add(m.tool_calls.length);
|
||||
m.tool_calls.forEach(tc=>{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);
|
||||
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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: '<think>The answer is 42.</think>'}, 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: '<think>Second thinking</think>'}, 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"] != ""
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user