From 5c3ead61a2394a134adfbd95e836ef7e394e86cb Mon Sep 17 00:00:00 2001 From: Frank Song Date: Fri, 24 Jul 2026 13:15:13 +0800 Subject: [PATCH 01/12] Restore unpinned desktop stream scroll --- static/ui.js | 7 +++++-- ..._issue5637_mobile_scroll_clamp_jumpback.py | 12 ++++++----- tests/test_issue5637_stale_anchor_guard.py | 20 +++++++++++++++++-- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/static/ui.js b/static/ui.js index eada54bb..63535edc 100644 --- a/static/ui.js +++ b/static/ui.js @@ -15245,6 +15245,10 @@ function _restoreMessageScrollSnapshotSameFrame(snapshot){ if(!restoredViaAnchor){ const maxTop=Math.max(0,el.scrollHeight-el.clientHeight); const bottom=Number(snapshot.bottom); + // Mobile/touch viewports have native overflow anchoring to hold an + // unpinned reader across a rebuild. Desktop deliberately disables that + // browser behavior, so it must continue into the explicit fallback below. + const _fbTouchHold=(typeof _isTouchLikeMessageViewport==='function' && _isTouchLikeMessageViewport(el)); // #5637: when the reader has scrolled UP into history (userUnpinned) and the // semantic anchor restore failed, do NOT snap scrollTop to the captured // ABSOLUTE snapshot.top. During streaming, the live activity-scene refresh @@ -15254,7 +15258,7 @@ function _restoreMessageScrollSnapshotSameFrame(snapshot){ // untouched lets the browser's own scroll anchoring hold the reader's // position. Pinned / near-bottom readers still get the tail-relative restore // below (that path is correct and must run). - if(snapshot.userUnpinned===true&&snapshot.pinned!==true){ + if(snapshot.userUnpinned===true&&snapshot.pinned!==true&&_fbTouchHold){ _lastScrollTop=el.scrollTop;_lastMessageClientHeight=el.clientHeight; _messageUserUnpinned=true; _scrollPinned=false; @@ -15285,7 +15289,6 @@ function _restoreMessageScrollSnapshotSameFrame(snapshot){ const _grewSinceSnap=Number.isFinite(_snapSH)&&_snapSH>0&&(el.scrollHeight-_snapSH)>4; const _fbActiveIntent=(typeof _recentMessageScrollIntent==='function' && _recentMessageScrollIntent()) || (typeof _recentMessageTouchScrollIntent==='function' && _recentMessageTouchScrollIntent()); - const _fbTouchHold=(typeof _isTouchLikeMessageViewport==='function' && _isTouchLikeMessageViewport(el)); if(_fbTouchHold && snapshot.pinned!==true && _grewSinceSnap && !_fbActiveIntent && Math.abs((Math.max(0,Math.min(target,maxTop)))-el.scrollTop)>8){ _lastScrollTop=el.scrollTop;_lastMessageClientHeight=el.clientHeight; diff --git a/tests/test_issue5637_mobile_scroll_clamp_jumpback.py b/tests/test_issue5637_mobile_scroll_clamp_jumpback.py index d69c63f9..c999d597 100644 --- a/tests/test_issue5637_mobile_scroll_clamp_jumpback.py +++ b/tests/test_issue5637_mobile_scroll_clamp_jumpback.py @@ -94,17 +94,19 @@ def test_content_visibility_scoped_to_user_rows(): ) -def test_restore_same_frame_holds_position_for_unpinned_reader(): - """The absolute-top fallback must be skipped for an unpinned reader. +def test_restore_same_frame_holds_position_for_unpinned_touch_reader(): + """The absolute-top fallback must be skipped when native anchoring can hold. When the reader is scrolled up (userUnpinned) and the anchor restore failed, _restoreMessageScrollSnapshotSameFrame must NOT write an absolute snapshot.top (it goes stale and nudges the viewport backward as scrollHeight grows, #5637). - It should hold position and return before the el.scrollTop write. + It should hold position and return before the el.scrollTop write. Desktop + intentionally falls through to the explicit fallback because it has no + native overflow-anchor layer. """ fn = _restore_same_frame_fn() - assert "snapshot.userUnpinned===true&&snapshot.pinned!==true" in fn, ( - "The fallback must guard the unpinned/anchor-failed case (#5637)." + assert "snapshot.userUnpinned===true&&snapshot.pinned!==true&&_fbTouchHold" in fn, ( + "The fallback must guard the unpinned/anchor-failed touch case (#5637)." ) # The guard must sit BEFORE the absolute-top scrollTop write and short-circuit it. guard_idx = fn.index("snapshot.userUnpinned===true&&snapshot.pinned!==true") diff --git a/tests/test_issue5637_stale_anchor_guard.py b/tests/test_issue5637_stale_anchor_guard.py index 3b83c094..e06e6c28 100644 --- a/tests/test_issue5637_stale_anchor_guard.py +++ b/tests/test_issue5637_stale_anchor_guard.py @@ -175,6 +175,7 @@ def test_realign_allows_on_desktop_no_native_anchor(): def _fallback_harness(*, snapshot_scroll_height, cur_scroll_height, snapshot_top, active_intent: bool = False, touch_like: bool = True, init_scroll_top: int = 90030, + snapshot_user_unpinned: bool = False, anchor=None, anchor_row_content_pos=None, container_top: int = 0, top_pad_now=None) -> str: """Node harness for the absolute-fallback path of _restoreMessageScrollSnapshotSameFrame. @@ -241,7 +242,7 @@ function _deferClearProgrammaticScroll(){{}} function requestAnimationFrame(cb){{ cb(); }} function setTimeout(cb){{ cb(); return 1; }} const snapshot = {{ anchor: {anchor_js}, top: {snapshot_top}, bottom: 40, - scrollHeight: {sh}, pinned: false, userUnpinned: false }}; + scrollHeight: {sh}, pinned: false, userUnpinned: {str(snapshot_user_unpinned).lower()} }}; eval(extractFunc('_desktopAnchorRealignDelta')); eval(extractFunc('_restoreMessageScrollSnapshotSameFrame')); _restoreMessageScrollSnapshotSameFrame(snapshot); @@ -312,6 +313,21 @@ def test_fallback_allows_snapshot_top_on_desktop_no_native_anchor(): assert m["messageUserUnpinned"] is False and m["scrollPinned"] is True +def test_unpinned_anchor_miss_restores_on_desktop_without_native_anchor(): + """A desktop reader who deliberately scrolled up cannot be left to native + anchoring: desktop disables it. If the semantic row is temporarily absent, + the rebuild may clamp scrollTop toward zero before this function runs, so the + fallback must restore the snapshot rather than taking the mobile no-write exit. + """ + m = json.loads(_run_node(_fallback_harness( + snapshot_scroll_height=90000, cur_scroll_height=90453, snapshot_top=89577, + active_intent=False, touch_like=False, snapshot_user_unpinned=True, + init_scroll_top=0, + ))) + assert m["writes"] == [89577] + assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False + + # ---- round-3 desktop anchor-realign (PR #5742): app's own scrollTop += delta idiom ----- # These use SCROLL-DEPENDENT rect mocks (rect.top = rowContentPos - scrollTop), so the # certified hold numbers are physically realizable in a real browser — the round-2 @@ -479,6 +495,7 @@ def test_predicate_stays_true_on_touch_when_inline_anchor_clobbered_to_none(): assert m["touchLike"] is True + def test_predicate_false_on_desktop_fine_pointer(): """Desktop (fine pointer): matchMedia('(pointer:coarse)') is false and the resting computed overflow-anchor is 'none' -> predicate reports NOT touch, so the guards do @@ -546,4 +563,3 @@ def test_predicate_true_on_android_not_ios(): platform="Linux armv8l", max_touch_points=5, ))) assert m["touchLike"] is True - From 5efc94f4b2f2c1a68f952d5e9067290b96f89d5e Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 25 Jul 2026 11:24:35 +0800 Subject: [PATCH 02/12] fix: preserve ownership of live scroll snapshots --- static/ui.js | 38 +++++++++++++++++++++- tests/test_issue5637_stale_anchor_guard.py | 23 +++++++++++-- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/static/ui.js b/static/ui.js index 63535edc..b9b8871e 100644 --- a/static/ui.js +++ b/static/ui.js @@ -961,6 +961,7 @@ function _captureMessageViewportAnchor(){ // anchor's topOffset is stale and realigning to it would yank a still reader // backward (issue #5637). scrollHeightAtCapture:container.scrollHeight, + inputGeneration:typeof _messageScrollInputGeneration==='number' ? _messageScrollInputGeneration : 0, }; } } @@ -5680,6 +5681,9 @@ let _lastMessageClientHeight=null; // #4702: track scroller height to ignore i // window after load as suppressed. let _lastNonMessageScrollIntentMs=-Infinity; let _messageUserUnpinned=false; +// A monotonic ownership token lets delayed restores distinguish reader input +// that happened after a snapshot from input that merely happened recently. +let _messageScrollInputGeneration=0; let _bottomSettleToken=0; let _settleRAF=0; let _settleRO=null; @@ -5763,6 +5767,9 @@ function _recordNonMessageScrollIntent(e){ const target=e&&e.target; if(!el||!target) return; if(!el.contains(target)){ _lastNonMessageScrollIntentMs=performance.now(); return; } + if(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY!==0)){ + _messageScrollInputGeneration++; + } // #4970: record ANY upward message-pane wheel motion as recent wheel intent, // including gentle low-delta trackpad wheels (e.g. deltaY:-5) that never reach // the decisive -30 sticky-unpin threshold below. The post-render artifact @@ -5966,7 +5973,10 @@ if(typeof window!=='undefined'){ const el=document.getElementById('messages'); if(!el) return; el.addEventListener('pointerdown',(e)=>{ - if(e.target===el&&e.offsetX>=el.clientWidth) _scrollbarDragActive=true; + if(e.target===el&&e.offsetX>=el.clientWidth){ + _scrollbarDragActive=true; + _messageScrollInputGeneration++; + } },{passive:true}); window.addEventListener('pointerup',()=>{ if(!_scrollbarDragActive) return; @@ -6012,6 +6022,7 @@ if(typeof window!=='undefined'){ // contains the focus, or the pointer is over it (keyboard scroll w/o focus). if(a===el||el.contains(a)||el.matches(':hover')){ const now=performance.now(); + _messageScrollInputGeneration++; _lastMessageKeyScrollIntentMs=now; const bottomDistance=el.scrollHeight-el.scrollTop-el.clientHeight; if(bottomDistance>120) _lastMessageScrollIntentMs=now; @@ -14985,10 +14996,24 @@ function _captureMessageScrollSnapshot(){ top:el.scrollTop, bottom, scrollHeight:el.scrollHeight, + inputGeneration:typeof _messageScrollInputGeneration==='number' ? _messageScrollInputGeneration : 0, pinned:readerAwayFromBottom?false:_shouldFollowMessagesOnDomReplace(), userUnpinned:readerAwayFromBottom?true:_messageUserUnpinned, }; } +function _messageScrollSnapshotInputChanged(snapshot){ + if(!snapshot||snapshot.pinned===true) return false; + const captured=Number(snapshot.inputGeneration); + const current=typeof _messageScrollInputGeneration==='number' ? _messageScrollInputGeneration : captured; + return Number.isFinite(captured)&&Number.isFinite(current)&¤t!==captured; +} +function _abandonMessageScrollSnapshot(){ + _lastScrollTop=$('messages')?.scrollTop||0; + _lastMessageClientHeight=$('messages')?.clientHeight||0; + _messageUserUnpinned=true; + _scrollPinned=false; + _nearBottomCount=0; +} function _restorePinnedMessageScrollSnapshot(snapshot){ const el=$('messages'); if(!el||!snapshot||snapshot.pinned!==true||snapshot.userUnpinned===true) return false; @@ -15016,6 +15041,10 @@ function _restoreMessageScrollSnapshot(snapshot){ // pinned streaming transcript upward. Semantic anchors remain for manual // unpinned reading positions below. if(_restorePinnedMessageScrollSnapshot(snapshot)) return; + if(_messageScrollSnapshotInputChanged(snapshot)){ + _abandonMessageScrollSnapshot(); + return; + } let restoredViaAnchor=(snapshot.anchor&&typeof _restoreMessageViewportAnchor==='function') ? _restoreMessageViewportAnchor(snapshot.anchor,0) : false; @@ -15234,6 +15263,13 @@ function _restoreMessageScrollSnapshotSameFrame(snapshot){ // streaming. Pinned followers must stay tail-relative here too; restoring the // semantic viewport anchor is only safe for explicitly unpinned readers. if(_restorePinnedMessageScrollSnapshot(snapshot)) return; + // A delayed rAF restore must not overwrite a position the reader changed + // after capture. Recent-intent timestamps are lossy; the generation is + // monotonic and therefore preserves snapshot ownership exactly. + if(_messageScrollSnapshotInputChanged(snapshot)){ + _abandonMessageScrollSnapshot(); + return; + } let restoredViaAnchor=(snapshot.anchor&&typeof _restoreMessageViewportAnchor==='function') ? _restoreMessageViewportAnchor(snapshot.anchor,0) : false; diff --git a/tests/test_issue5637_stale_anchor_guard.py b/tests/test_issue5637_stale_anchor_guard.py index e06e6c28..32c0055c 100644 --- a/tests/test_issue5637_stale_anchor_guard.py +++ b/tests/test_issue5637_stale_anchor_guard.py @@ -177,7 +177,8 @@ def _fallback_harness(*, snapshot_scroll_height, cur_scroll_height, snapshot_top init_scroll_top: int = 90030, snapshot_user_unpinned: bool = False, anchor=None, anchor_row_content_pos=None, container_top: int = 0, - top_pad_now=None) -> str: + top_pad_now=None, input_generation: int = 0, + delayed_input_generation=None) -> str: """Node harness for the absolute-fallback path of _restoreMessageScrollSnapshotSameFrame. SCROLL-DEPENDENT geometry (round-3 gate-cert requirement): the anchor row's @@ -203,6 +204,7 @@ def _fallback_harness(*, snapshot_scroll_height, cur_scroll_height, snapshot_top return _extract_func_script(js) + f""" let writes = []; let stTop = {init_scroll_top}; +let _messageScrollInputGeneration = {input_generation}; const CONTAINER_TOP = {container_top}; const ROW_CONTENT_POS = {row_pos_js}; const ROW_PRESENT = {"true" if row_present else "false"}; @@ -242,10 +244,13 @@ function _deferClearProgrammaticScroll(){{}} function requestAnimationFrame(cb){{ cb(); }} function setTimeout(cb){{ cb(); return 1; }} const snapshot = {{ anchor: {anchor_js}, top: {snapshot_top}, bottom: 40, - scrollHeight: {sh}, pinned: false, userUnpinned: {str(snapshot_user_unpinned).lower()} }}; + scrollHeight: {sh}, inputGeneration: {input_generation}, pinned: false, userUnpinned: {str(snapshot_user_unpinned).lower()} }}; eval(extractFunc('_desktopAnchorRealignDelta')); +eval(extractFunc('_messageScrollSnapshotInputChanged')); +eval(extractFunc('_abandonMessageScrollSnapshot')); eval(extractFunc('_restoreMessageScrollSnapshotSameFrame')); _restoreMessageScrollSnapshotSameFrame(snapshot); +{f"stTop = 89000; _messageScrollInputGeneration = {delayed_input_generation}; _restoreMessageScrollSnapshotSameFrame(snapshot);" if delayed_input_generation is not None else ""} console.log(JSON.stringify({{ wrote: writes.length, writes, messageUserUnpinned: _messageUserUnpinned, scrollPinned: _scrollPinned, finalScrollTop: Math.round(stTop) }})); """ @@ -328,6 +333,20 @@ def test_unpinned_anchor_miss_restores_on_desktop_without_native_anchor(): assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False +def test_delayed_restore_cannot_overwrite_new_desktop_input(): + """The synchronous restore owns generation 0, then the reader moves to 89000 + before the delayed rAF restore. The second call must abandon the stale snapshot + instead of writing its old 89577 target.""" + m = json.loads(_run_node(_fallback_harness( + snapshot_scroll_height=90453, cur_scroll_height=90453, snapshot_top=89577, + active_intent=False, touch_like=False, snapshot_user_unpinned=True, + init_scroll_top=89577, input_generation=0, delayed_input_generation=1, + ))) + assert m["writes"] == [89577] + assert m["finalScrollTop"] == 89000 + assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False + + # ---- round-3 desktop anchor-realign (PR #5742): app's own scrollTop += delta idiom ----- # These use SCROLL-DEPENDENT rect mocks (rect.top = rowContentPos - scrollTop), so the # certified hold numbers are physically realizable in a real browser — the round-2 From a55096b81f765437c05d5287583616380824bdd9 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 25 Jul 2026 11:30:12 +0800 Subject: [PATCH 03/12] fix: keep scroll ownership hooks harness-safe --- static/ui.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/static/ui.js b/static/ui.js index b9b8871e..621d1a4c 100644 --- a/static/ui.js +++ b/static/ui.js @@ -5768,7 +5768,7 @@ function _recordNonMessageScrollIntent(e){ if(!el||!target) return; if(!el.contains(target)){ _lastNonMessageScrollIntentMs=performance.now(); return; } if(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY!==0)){ - _messageScrollInputGeneration++; + if(typeof _messageScrollInputGeneration==='number') _messageScrollInputGeneration++; } // #4970: record ANY upward message-pane wheel motion as recent wheel intent, // including gentle low-delta trackpad wheels (e.g. deltaY:-5) that never reach @@ -5975,7 +5975,7 @@ if(typeof window!=='undefined'){ el.addEventListener('pointerdown',(e)=>{ if(e.target===el&&e.offsetX>=el.clientWidth){ _scrollbarDragActive=true; - _messageScrollInputGeneration++; + if(typeof _messageScrollInputGeneration==='number') _messageScrollInputGeneration++; } },{passive:true}); window.addEventListener('pointerup',()=>{ @@ -6022,7 +6022,7 @@ if(typeof window!=='undefined'){ // contains the focus, or the pointer is over it (keyboard scroll w/o focus). if(a===el||el.contains(a)||el.matches(':hover')){ const now=performance.now(); - _messageScrollInputGeneration++; + if(typeof _messageScrollInputGeneration==='number') _messageScrollInputGeneration++; _lastMessageKeyScrollIntentMs=now; const bottomDistance=el.scrollHeight-el.scrollTop-el.clientHeight; if(bottomDistance>120) _lastMessageScrollIntentMs=now; @@ -15041,8 +15041,8 @@ function _restoreMessageScrollSnapshot(snapshot){ // pinned streaming transcript upward. Semantic anchors remain for manual // unpinned reading positions below. if(_restorePinnedMessageScrollSnapshot(snapshot)) return; - if(_messageScrollSnapshotInputChanged(snapshot)){ - _abandonMessageScrollSnapshot(); + if(typeof _messageScrollSnapshotInputChanged==='function'&&_messageScrollSnapshotInputChanged(snapshot)){ + if(typeof _abandonMessageScrollSnapshot==='function') _abandonMessageScrollSnapshot(); return; } let restoredViaAnchor=(snapshot.anchor&&typeof _restoreMessageViewportAnchor==='function') @@ -15266,8 +15266,8 @@ function _restoreMessageScrollSnapshotSameFrame(snapshot){ // A delayed rAF restore must not overwrite a position the reader changed // after capture. Recent-intent timestamps are lossy; the generation is // monotonic and therefore preserves snapshot ownership exactly. - if(_messageScrollSnapshotInputChanged(snapshot)){ - _abandonMessageScrollSnapshot(); + if(typeof _messageScrollSnapshotInputChanged==='function'&&_messageScrollSnapshotInputChanged(snapshot)){ + if(typeof _abandonMessageScrollSnapshot==='function') _abandonMessageScrollSnapshot(); return; } let restoredViaAnchor=(snapshot.anchor&&typeof _restoreMessageViewportAnchor==='function') From 7a320bea3c7f1949d25a0eee957bef779a66dd41 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 25 Jul 2026 14:09:02 +0800 Subject: [PATCH 04/12] fix: preserve live scroll ownership on snapshot mismatch --- static/ui.js | 39 ++++++++++---- tests/test_issue5637_stale_anchor_guard.py | 59 +++++++++++++++++++--- 2 files changed, 81 insertions(+), 17 deletions(-) diff --git a/static/ui.js b/static/ui.js index 621d1a4c..6a0ed1ff 100644 --- a/static/ui.js +++ b/static/ui.js @@ -15002,17 +15002,34 @@ function _captureMessageScrollSnapshot(){ }; } function _messageScrollSnapshotInputChanged(snapshot){ - if(!snapshot||snapshot.pinned===true) return false; + if(!snapshot) return false; const captured=Number(snapshot.inputGeneration); const current=typeof _messageScrollInputGeneration==='number' ? _messageScrollInputGeneration : captured; return Number.isFinite(captured)&&Number.isFinite(current)&¤t!==captured; } function _abandonMessageScrollSnapshot(){ - _lastScrollTop=$('messages')?.scrollTop||0; - _lastMessageClientHeight=$('messages')?.clientHeight||0; - _messageUserUnpinned=true; - _scrollPinned=false; - _nearBottomCount=0; + const el=$('messages'); + if(!el){ + _messageUserUnpinned=true; + _scrollPinned=false; + _nearBottomCount=0; + return; + } + _lastScrollTop=el.scrollTop||0; + _lastMessageClientHeight=el.clientHeight||0; + // A generation mismatch abandons only the stale snapshot write. Reconcile + // ownership from the live viewport so a reader who moved down to the true + // bottom is immediately re-pinned instead of being stranded sticky-unpinned. + const bottomDistance=el.scrollHeight-el.scrollTop-el.clientHeight; + if(bottomDistance<=80){ + _messageUserUnpinned=false; + _scrollPinned=true; + _nearBottomCount=2; + }else{ + _messageUserUnpinned=true; + _scrollPinned=false; + _nearBottomCount=0; + } } function _restorePinnedMessageScrollSnapshot(snapshot){ const el=$('messages'); @@ -15040,11 +15057,11 @@ function _restoreMessageScrollSnapshot(snapshot){ // activity rebuilds can remount an older top-of-viewport anchor and yank a // pinned streaming transcript upward. Semantic anchors remain for manual // unpinned reading positions below. - if(_restorePinnedMessageScrollSnapshot(snapshot)) return; if(typeof _messageScrollSnapshotInputChanged==='function'&&_messageScrollSnapshotInputChanged(snapshot)){ if(typeof _abandonMessageScrollSnapshot==='function') _abandonMessageScrollSnapshot(); return; } + if(_restorePinnedMessageScrollSnapshot(snapshot)) return; let restoredViaAnchor=(snapshot.anchor&&typeof _restoreMessageViewportAnchor==='function') ? _restoreMessageViewportAnchor(snapshot.anchor,0) : false; @@ -15262,14 +15279,14 @@ function _restoreMessageScrollSnapshotSameFrame(snapshot){ // Same-frame live DOM updates (tool/worklog/activity rows) are the hot path for // streaming. Pinned followers must stay tail-relative here too; restoring the // semantic viewport anchor is only safe for explicitly unpinned readers. - if(_restorePinnedMessageScrollSnapshot(snapshot)) return; - // A delayed rAF restore must not overwrite a position the reader changed - // after capture. Recent-intent timestamps are lossy; the generation is - // monotonic and therefore preserves snapshot ownership exactly. if(typeof _messageScrollSnapshotInputChanged==='function'&&_messageScrollSnapshotInputChanged(snapshot)){ if(typeof _abandonMessageScrollSnapshot==='function') _abandonMessageScrollSnapshot(); return; } + if(_restorePinnedMessageScrollSnapshot(snapshot)) return; + // A delayed rAF restore must not overwrite a position the reader changed + // after capture. Recent-intent timestamps are lossy; the generation is + // monotonic and therefore preserves snapshot ownership exactly. let restoredViaAnchor=(snapshot.anchor&&typeof _restoreMessageViewportAnchor==='function') ? _restoreMessageViewportAnchor(snapshot.anchor,0) : false; diff --git a/tests/test_issue5637_stale_anchor_guard.py b/tests/test_issue5637_stale_anchor_guard.py index 32c0055c..04333319 100644 --- a/tests/test_issue5637_stale_anchor_guard.py +++ b/tests/test_issue5637_stale_anchor_guard.py @@ -176,9 +176,11 @@ def _fallback_harness(*, snapshot_scroll_height, cur_scroll_height, snapshot_top active_intent: bool = False, touch_like: bool = True, init_scroll_top: int = 90030, snapshot_user_unpinned: bool = False, + snapshot_pinned: bool = False, anchor=None, anchor_row_content_pos=None, container_top: int = 0, top_pad_now=None, input_generation: int = 0, - delayed_input_generation=None) -> str: + delayed_input_generation=None, delayed_scroll_top: int = 89000, + restore_fn: str = "_restoreMessageScrollSnapshotSameFrame") -> str: """Node harness for the absolute-fallback path of _restoreMessageScrollSnapshotSameFrame. SCROLL-DEPENDENT geometry (round-3 gate-cert requirement): the anchor row's @@ -233,7 +235,12 @@ function _recentMessageScrollIntent(){{ return {intent_js}; }} function _recentMessageTouchScrollIntent(){{ return {intent_js}; }} function _isTouchLikeMessageViewport(){{ return {touch_js}; }} // realign path fails (no anchor restore) so execution reaches the absolute fallback -function _restorePinnedMessageScrollSnapshot(){{ return false; }} +function _restorePinnedMessageScrollSnapshot(snapshot){{ + if(snapshot.pinned!==true) return false; + const pinnedTarget=el.scrollHeight-el.clientHeight-(Number(snapshot.bottom)||0); + el.scrollTop=pinnedTarget; + return true; +}} function _restoreMessageViewportAnchor(){{ return false; }} function _remountMessageViewportAnchor(){{ return false; }} let _messageUserUnpinned = false; let _scrollPinned = true; let _nearBottomCount = 5; @@ -244,13 +251,13 @@ function _deferClearProgrammaticScroll(){{}} function requestAnimationFrame(cb){{ cb(); }} function setTimeout(cb){{ cb(); return 1; }} const snapshot = {{ anchor: {anchor_js}, top: {snapshot_top}, bottom: 40, - scrollHeight: {sh}, inputGeneration: {input_generation}, pinned: false, userUnpinned: {str(snapshot_user_unpinned).lower()} }}; + scrollHeight: {sh}, inputGeneration: {input_generation}, pinned: {str(snapshot_pinned).lower()}, userUnpinned: {str(snapshot_user_unpinned).lower()} }}; eval(extractFunc('_desktopAnchorRealignDelta')); eval(extractFunc('_messageScrollSnapshotInputChanged')); eval(extractFunc('_abandonMessageScrollSnapshot')); -eval(extractFunc('_restoreMessageScrollSnapshotSameFrame')); -_restoreMessageScrollSnapshotSameFrame(snapshot); -{f"stTop = 89000; _messageScrollInputGeneration = {delayed_input_generation}; _restoreMessageScrollSnapshotSameFrame(snapshot);" if delayed_input_generation is not None else ""} +eval(extractFunc({json.dumps(restore_fn)})); +{restore_fn}(snapshot); +{f"stTop = {delayed_scroll_top}; _messageScrollInputGeneration = {delayed_input_generation}; {restore_fn}(snapshot);" if delayed_input_generation is not None else ""} console.log(JSON.stringify({{ wrote: writes.length, writes, messageUserUnpinned: _messageUserUnpinned, scrollPinned: _scrollPinned, finalScrollTop: Math.round(stTop) }})); """ @@ -347,6 +354,46 @@ def test_delayed_restore_cannot_overwrite_new_desktop_input(): assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False +@pytest.mark.parametrize("restore_fn", [ + "_restoreMessageScrollSnapshot", + "_restoreMessageScrollSnapshotSameFrame", +]) +def test_delayed_pinned_restore_cannot_overwrite_new_upward_input(restore_fn): + """A pinned-at-capture snapshot must lose ownership before its tail write. + + The reader moves upward before the delayed restore; the stale pinned snapshot + must not put them back at its captured tail-relative position. + """ + m = json.loads(_run_node(_fallback_harness( + snapshot_scroll_height=90453, cur_scroll_height=90453, snapshot_top=89577, + active_intent=False, touch_like=False, snapshot_pinned=True, + init_scroll_top=89577, input_generation=0, delayed_input_generation=1, + delayed_scroll_top=89000, + restore_fn=restore_fn, + ))) + assert m["writes"] == [89986] + assert m["finalScrollTop"] == 89000 + assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False + + +@pytest.mark.parametrize("restore_fn", [ + "_restoreMessageScrollSnapshot", + "_restoreMessageScrollSnapshotSameFrame", +]) +def test_delayed_unpinned_restore_repins_reader_who_reached_bottom(restore_fn): + """Abandoning a stale unpinned snapshot must reconcile a live bottom position.""" + m = json.loads(_run_node(_fallback_harness( + snapshot_scroll_height=90453, cur_scroll_height=90453, snapshot_top=89577, + active_intent=False, touch_like=False, snapshot_user_unpinned=True, + init_scroll_top=89577, input_generation=0, delayed_input_generation=1, + delayed_scroll_top=90026, + restore_fn=restore_fn, + ))) + assert m["writes"] == [89577] + assert m["finalScrollTop"] == 90026 + assert m["messageUserUnpinned"] is False and m["scrollPinned"] is True + + # ---- round-3 desktop anchor-realign (PR #5742): app's own scrollTop += delta idiom ----- # These use SCROLL-DEPENDENT rect mocks (rect.top = rowContentPos - scrollTop), so the # certified hold numbers are physically realizable in a real browser — the round-2 From 713418dec0ae05c9c16152f53d32f7a0c29868e0 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 25 Jul 2026 14:36:28 +0800 Subject: [PATCH 05/12] test: cover live render scroll ownership boundary --- static/ui.js | 27 ++-- tests/test_issue5637_stale_anchor_guard.py | 170 ++++++++++++++++++--- 2 files changed, 165 insertions(+), 32 deletions(-) diff --git a/static/ui.js b/static/ui.js index 6a0ed1ff..7492e5f2 100644 --- a/static/ui.js +++ b/static/ui.js @@ -12725,6 +12725,16 @@ function _prepareLiveAnchorScrollRebuildGuard(scrollSnapshot){ }, }; } +function _restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot, scrollRebuildGuard){ + if(!scrollRebuildGuard||!scrollRebuildGuard.release) return; + requestAnimationFrame(()=>{ + scrollRebuildGuard.release(); + // Only re-restore the unpinned snapshot if the reader is STILL unpinned at + // rAF time. If they re-pinned between guard-engage and this frame, the + // stale re-restore would yank them back off the bottom (Opus gate finding). + if(_messageUserUnpinned) _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); + }); +} function _resetMismatchedLiveAssistantTurnForSession(turn, sessionId){ const sid=String(sessionId||''); if(!turn||!sid||!turn.dataset) return false; @@ -12874,15 +12884,7 @@ function renderLiveAnchorActivityScene(streamId, scene, opts){ _dedupeLiveProcessedWorklogAnchors(turn); if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd(); _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); - if(scrollRebuildGuard&&scrollRebuildGuard.release){ - requestAnimationFrame(()=>{ - scrollRebuildGuard.release(); - // Only re-restore the unpinned snapshot if the reader is STILL unpinned at - // rAF time. If they re-pinned between guard-engage and this frame, the - // stale re-restore would yank them back off the bottom (Opus gate finding). - if(_messageUserUnpinned) _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); - }); - } + _restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot,scrollRebuildGuard); if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned(); return true; } @@ -12988,12 +12990,7 @@ function _renderLiveAnchorActivitySceneTransparent(streamId, scene, opts){ if(renderedRows.length) _syncTransparentEventControls(turn); if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd(); _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); - if(scrollRebuildGuard&&scrollRebuildGuard.release){ - requestAnimationFrame(()=>{ - scrollRebuildGuard.release(); - if(_messageUserUnpinned) _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); - }); - } + _restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot,scrollRebuildGuard); if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned(); return !!renderedRows.length; } diff --git a/tests/test_issue5637_stale_anchor_guard.py b/tests/test_issue5637_stale_anchor_guard.py index 04333319..d1fb120d 100644 --- a/tests/test_issue5637_stale_anchor_guard.py +++ b/tests/test_issue5637_stale_anchor_guard.py @@ -52,6 +52,153 @@ def _run_node(source: str) -> str: return result.stdout.strip() +def _extract_js_function(js: str, name: str) -> str: + """Extract a production function without runtime eval/source execution.""" + marker = f"function {name}(" + start = js.index(marker) + brace = js.index("{", start) + depth = 0 + quote = None + escaped = False + line_comment = False + block_comment = False + index = brace + while index < len(js): + char = js[index] + next_char = js[index + 1] if index + 1 < len(js) else "" + if line_comment: + if char == "\n": + line_comment = False + index += 1 + continue + if block_comment: + if char == "*" and next_char == "/": + block_comment = False + index += 2 + continue + index += 1 + continue + if quote: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == quote: + quote = None + index += 1 + continue + if char == "/" and next_char == "/": + line_comment = True + index += 2 + continue + if char == "/" and next_char == "*": + block_comment = True + index += 2 + continue + if char in "'\"`": + quote = char + index += 1 + continue + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return js[start : index + 1] + index += 1 + raise AssertionError(f"JavaScript function {name} did not close") + + +def _live_render_ownership_harness(*, move_to_bottom: bool) -> str: + """Compose real input production with the production queued live-render restore.""" + js = UI_JS_PATH.read_text(encoding="utf-8") + functions = [ + "_recordNonMessageScrollIntent", + "_captureMessageScrollSnapshot", + "_messageScrollSnapshotInputChanged", + "_abandonMessageScrollSnapshot", + "_restorePinnedMessageScrollSnapshot", + "_restoreMessageScrollSnapshotSameFrame", + "_restoreLiveAnchorScrollSnapshotAfterRebuild", + ] + production = "\n".join(_extract_js_function(js, name) for name in functions) + target_delta = 100 if move_to_bottom else -100 + final_top = 90026 if move_to_bottom else 89000 + return production + f""" +let writes=[]; +let _messageScrollInputGeneration=0; +let _messageUserUnpinned={str(move_to_bottom).lower()}; +let _scrollPinned={str(not move_to_bottom).lower()}; +let _nearBottomCount=0; +let _lastScrollTop=null, _lastMessageClientHeight=null; +let _programmaticScroll=false, _programmaticScrollSetAt=0; +let recordWrites=true; +const callbacks=[]; +const performance={{now(){{return 1;}}}}; +const el={{ + scrollHeight:90453, clientHeight:427, + get scrollTop(){{return this._top;}}, + set scrollTop(value){{if(recordWrites) writes.push(Math.round(value)); this._top=value;}}, + _top:{'89577' if move_to_bottom else '90026'}, + contains(target){{return target===this;}}, + querySelector(){{return null;}}, +}}; +const document={{getElementById(id){{return id==='messages'?el:null;}}}}; +function $(id){{return id==='messages'?el:null;}} +function _captureMessageViewportAnchor(){{return null;}} +function _shouldFollowMessagesOnDomReplace(){{return true;}} +function _deferClearProgrammaticScroll(){{}} +function _cancelBottomSettle(){{}} +function _restoreMessageViewportAnchor(){{return false;}} +function _remountMessageViewportAnchor(){{return false;}} +function _desktopAnchorRealignDelta(){{return null;}} +function _isTouchLikeMessageViewport(){{return false;}} +function _recentMessageScrollIntent(){{return false;}} +function _recentMessageTouchScrollIntent(){{return false;}} +function _restoreMessageScrollSnapshotSameFrameFallback(){{}} +function requestAnimationFrame(callback){{callbacks.push(callback);}} +const snapshot=_captureMessageScrollSnapshot(); +_restoreMessageScrollSnapshotSameFrame(snapshot); +const initialWrites=writes.slice(); +writes=[]; +recordWrites=false; +_recordNonMessageScrollIntent({{target:el,deltaY:{target_delta}}}); +el.scrollTop={final_top}; +recordWrites=true; +_restoreLiveAnchorScrollSnapshotAfterRebuild(snapshot,{{release(){{}}}}); +callbacks.shift()(); +console.log(JSON.stringify({{initialWrites,writes, finalScrollTop:el.scrollTop, + messageUserUnpinned:_messageUserUnpinned, scrollPinned:_scrollPinned, + generation:_messageScrollInputGeneration}})); +""" + + +def test_live_render_queue_preserves_real_upward_input_from_pinned_capture(): + """The real producer and queued live-render callback preserve upward input.""" + result = json.loads(_run_node(_live_render_ownership_harness(move_to_bottom=False))) + assert result == { + "initialWrites": [90026], + "writes": [], + "finalScrollTop": 89000, + "messageUserUnpinned": True, + "scrollPinned": False, + "generation": 1, + } + + +def test_live_render_queue_repins_real_downward_input_at_bottom(): + """The real producer and queued live-render callback re-pin at true bottom.""" + result = json.loads(_run_node(_live_render_ownership_harness(move_to_bottom=True))) + assert result == { + "initialWrites": [89577], + "writes": [], + "finalScrollTop": 90026, + "messageUserUnpinned": False, + "scrollPinned": True, + "generation": 1, + } + + def _extract_func_script(js: str) -> str: prelude = "const src = " + json.dumps(js) + ";\n" body = r""" @@ -179,8 +326,7 @@ def _fallback_harness(*, snapshot_scroll_height, cur_scroll_height, snapshot_top snapshot_pinned: bool = False, anchor=None, anchor_row_content_pos=None, container_top: int = 0, top_pad_now=None, input_generation: int = 0, - delayed_input_generation=None, delayed_scroll_top: int = 89000, - restore_fn: str = "_restoreMessageScrollSnapshotSameFrame") -> str: + delayed_input_generation=None, delayed_scroll_top: int = 89000) -> str: """Node harness for the absolute-fallback path of _restoreMessageScrollSnapshotSameFrame. SCROLL-DEPENDENT geometry (round-3 gate-cert requirement): the anchor row's @@ -255,9 +401,9 @@ const snapshot = {{ anchor: {anchor_js}, top: {snapshot_top}, bottom: 40, eval(extractFunc('_desktopAnchorRealignDelta')); eval(extractFunc('_messageScrollSnapshotInputChanged')); eval(extractFunc('_abandonMessageScrollSnapshot')); -eval(extractFunc({json.dumps(restore_fn)})); -{restore_fn}(snapshot); -{f"stTop = {delayed_scroll_top}; _messageScrollInputGeneration = {delayed_input_generation}; {restore_fn}(snapshot);" if delayed_input_generation is not None else ""} +eval(extractFunc('_restoreMessageScrollSnapshotSameFrame')); +_restoreMessageScrollSnapshotSameFrame(snapshot); +{f"stTop = {delayed_scroll_top}; _messageScrollInputGeneration = {delayed_input_generation}; _restoreMessageScrollSnapshotSameFrame(snapshot);" if delayed_input_generation is not None else ""} console.log(JSON.stringify({{ wrote: writes.length, writes, messageUserUnpinned: _messageUserUnpinned, scrollPinned: _scrollPinned, finalScrollTop: Math.round(stTop) }})); """ @@ -354,11 +500,7 @@ def test_delayed_restore_cannot_overwrite_new_desktop_input(): assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False -@pytest.mark.parametrize("restore_fn", [ - "_restoreMessageScrollSnapshot", - "_restoreMessageScrollSnapshotSameFrame", -]) -def test_delayed_pinned_restore_cannot_overwrite_new_upward_input(restore_fn): +def test_delayed_pinned_restore_cannot_overwrite_new_upward_input(): """A pinned-at-capture snapshot must lose ownership before its tail write. The reader moves upward before the delayed restore; the stale pinned snapshot @@ -369,25 +511,19 @@ def test_delayed_pinned_restore_cannot_overwrite_new_upward_input(restore_fn): active_intent=False, touch_like=False, snapshot_pinned=True, init_scroll_top=89577, input_generation=0, delayed_input_generation=1, delayed_scroll_top=89000, - restore_fn=restore_fn, ))) assert m["writes"] == [89986] assert m["finalScrollTop"] == 89000 assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False -@pytest.mark.parametrize("restore_fn", [ - "_restoreMessageScrollSnapshot", - "_restoreMessageScrollSnapshotSameFrame", -]) -def test_delayed_unpinned_restore_repins_reader_who_reached_bottom(restore_fn): +def test_delayed_unpinned_restore_repins_reader_who_reached_bottom(): """Abandoning a stale unpinned snapshot must reconcile a live bottom position.""" m = json.loads(_run_node(_fallback_harness( snapshot_scroll_height=90453, cur_scroll_height=90453, snapshot_top=89577, active_intent=False, touch_like=False, snapshot_user_unpinned=True, init_scroll_top=89577, input_generation=0, delayed_input_generation=1, delayed_scroll_top=90026, - restore_fn=restore_fn, ))) assert m["writes"] == [89577] assert m["finalScrollTop"] == 90026 From a6a891f07952694edd705da326d20a8c2222f5eb Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 25 Jul 2026 15:03:28 +0800 Subject: [PATCH 06/12] fix: keep live render harness fallback safe --- static/ui.js | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/static/ui.js b/static/ui.js index 7492e5f2..a477fa5a 100644 --- a/static/ui.js +++ b/static/ui.js @@ -12884,7 +12884,15 @@ function renderLiveAnchorActivityScene(streamId, scene, opts){ _dedupeLiveProcessedWorklogAnchors(turn); if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd(); _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); - _restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot,scrollRebuildGuard); + if(typeof _restoreLiveAnchorScrollSnapshotAfterRebuild==='function'){ + _restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot,scrollRebuildGuard); + }else if(scrollRebuildGuard&&scrollRebuildGuard.release){ + // Keep extracted test harnesses safe when they omit the shared helper. + requestAnimationFrame(()=>{ + scrollRebuildGuard.release(); + if(_messageUserUnpinned) _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); + }); + } if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned(); return true; } @@ -12990,7 +12998,15 @@ function _renderLiveAnchorActivitySceneTransparent(streamId, scene, opts){ if(renderedRows.length) _syncTransparentEventControls(turn); if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd(); _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); - _restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot,scrollRebuildGuard); + if(typeof _restoreLiveAnchorScrollSnapshotAfterRebuild==='function'){ + _restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot,scrollRebuildGuard); + }else if(scrollRebuildGuard&&scrollRebuildGuard.release){ + // Keep extracted test harnesses safe when they omit the shared helper. + requestAnimationFrame(()=>{ + scrollRebuildGuard.release(); + if(_messageUserUnpinned) _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); + }); + } if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned(); return !!renderedRows.length; } From cc5cf19710673804312dc9a4b1373a92f39a9b20 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 25 Jul 2026 15:36:09 +0800 Subject: [PATCH 07/12] test: exercise reachable live scroll restore boundary --- static/ui.js | 20 +--------- ...ssue5367_transparent_live_row_reconcile.py | 4 ++ tests/test_issue5637_stale_anchor_guard.py | 38 ++++++++++++++----- tests/test_issue5720_reasoning_owner.py | 1 + 4 files changed, 36 insertions(+), 27 deletions(-) diff --git a/static/ui.js b/static/ui.js index a477fa5a..7492e5f2 100644 --- a/static/ui.js +++ b/static/ui.js @@ -12884,15 +12884,7 @@ function renderLiveAnchorActivityScene(streamId, scene, opts){ _dedupeLiveProcessedWorklogAnchors(turn); if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd(); _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); - if(typeof _restoreLiveAnchorScrollSnapshotAfterRebuild==='function'){ - _restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot,scrollRebuildGuard); - }else if(scrollRebuildGuard&&scrollRebuildGuard.release){ - // Keep extracted test harnesses safe when they omit the shared helper. - requestAnimationFrame(()=>{ - scrollRebuildGuard.release(); - if(_messageUserUnpinned) _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); - }); - } + _restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot,scrollRebuildGuard); if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned(); return true; } @@ -12998,15 +12990,7 @@ function _renderLiveAnchorActivitySceneTransparent(streamId, scene, opts){ if(renderedRows.length) _syncTransparentEventControls(turn); if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd(); _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); - if(typeof _restoreLiveAnchorScrollSnapshotAfterRebuild==='function'){ - _restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot,scrollRebuildGuard); - }else if(scrollRebuildGuard&&scrollRebuildGuard.release){ - // Keep extracted test harnesses safe when they omit the shared helper. - requestAnimationFrame(()=>{ - scrollRebuildGuard.release(); - if(_messageUserUnpinned) _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); - }); - } + _restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot,scrollRebuildGuard); if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned(); return !!renderedRows.length; } diff --git a/tests/test_issue5367_transparent_live_row_reconcile.py b/tests/test_issue5367_transparent_live_row_reconcile.py index 7017d05d..04b4f16d 100644 --- a/tests/test_issue5367_transparent_live_row_reconcile.py +++ b/tests/test_issue5367_transparent_live_row_reconcile.py @@ -311,6 +311,8 @@ eval(extractFunc('_bindTransparentFadeCleanup')); eval(extractFunc('_appendTransparentFadeText')); eval(extractFunc('_refreshTransparentFadeProseRow')); eval(extractFunc('_refreshTransparentLiveRow')); +eval(extractFunc('_restoreLiveAnchorScrollSnapshotAfterRebuild')); +eval(extractFunc('_restoreLiveAnchorScrollSnapshotAfterRebuild')); eval(extractFunc('_renderLiveAnchorActivitySceneTransparent')); const firstScene = {{ @@ -1264,6 +1266,7 @@ eval(extractFunc('_transparentLiveRowInteractiveState')); eval(extractFunc('_rehydrateTransparentLiveRow')); eval(extractFunc('_refreshTransparentThinkingLiveRow')); eval(extractFunc('_refreshTransparentLiveRow')); +eval(extractFunc('_restoreLiveAnchorScrollSnapshotAfterRebuild')); eval(extractFunc('_renderLiveAnchorActivitySceneTransparent')); const firstScene = { version:'activity_scene_v1', activity_rows:[{ row_id:'row-tool', role:'tool', source_event_type:'tool_delta', toolName:'old_tool' }] }; @@ -1601,6 +1604,7 @@ eval(extractFunc('_transparentLiveRowInteractiveState')); eval(extractFunc('_rehydrateTransparentLiveRow')); eval(extractFunc('_refreshTransparentThinkingLiveRow')); eval(extractFunc('_refreshTransparentLiveRow')); +eval(extractFunc('_restoreLiveAnchorScrollSnapshotAfterRebuild')); eval(extractFunc('_renderLiveAnchorActivitySceneTransparent')); global._copyEventToClipboard = (row) => {{ const tc = row && row._tcData || {{}}; diff --git a/tests/test_issue5637_stale_anchor_guard.py b/tests/test_issue5637_stale_anchor_guard.py index d1fb120d..cff130bd 100644 --- a/tests/test_issue5637_stale_anchor_guard.py +++ b/tests/test_issue5637_stale_anchor_guard.py @@ -119,6 +119,7 @@ def _live_render_ownership_harness(*, move_to_bottom: bool) -> str: "_abandonMessageScrollSnapshot", "_restorePinnedMessageScrollSnapshot", "_restoreMessageScrollSnapshotSameFrame", + "_prepareLiveAnchorScrollRebuildGuard", "_restoreLiveAnchorScrollSnapshotAfterRebuild", ] production = "\n".join(_extract_js_function(js, name) for name in functions) @@ -144,7 +145,8 @@ const el={{ querySelector(){{return null;}}, }}; const document={{getElementById(id){{return id==='messages'?el:null;}}}}; -function $(id){{return id==='messages'?el:null;}} +const msgInner={{style:{{}},dataset:{{}}}}; +function $(id){{return id==='messages'?el:id==='msgInner'?msgInner:null;}} function _captureMessageViewportAnchor(){{return null;}} function _shouldFollowMessagesOnDomReplace(){{return true;}} function _deferClearProgrammaticScroll(){{}} @@ -158,16 +160,19 @@ function _recentMessageTouchScrollIntent(){{return false;}} function _restoreMessageScrollSnapshotSameFrameFallback(){{}} function requestAnimationFrame(callback){{callbacks.push(callback);}} const snapshot=_captureMessageScrollSnapshot(); +const capturedPinned=snapshot.pinned===true; +{f"el._top=89577; _messageUserUnpinned=true; _scrollPinned=false;" if not move_to_bottom else ""} +const scrollRebuildGuard=_prepareLiveAnchorScrollRebuildGuard(snapshot); _restoreMessageScrollSnapshotSameFrame(snapshot); const initialWrites=writes.slice(); writes=[]; +_restoreLiveAnchorScrollSnapshotAfterRebuild(snapshot,scrollRebuildGuard); recordWrites=false; _recordNonMessageScrollIntent({{target:el,deltaY:{target_delta}}}); el.scrollTop={final_top}; recordWrites=true; -_restoreLiveAnchorScrollSnapshotAfterRebuild(snapshot,{{release(){{}}}}); callbacks.shift()(); -console.log(JSON.stringify({{initialWrites,writes, finalScrollTop:el.scrollTop, +console.log(JSON.stringify({{capturedPinned,guardQueued:!!(scrollRebuildGuard&&scrollRebuildGuard.release),initialWrites,writes, finalScrollTop:el.scrollTop, messageUserUnpinned:_messageUserUnpinned, scrollPinned:_scrollPinned, generation:_messageScrollInputGeneration}})); """ @@ -177,6 +182,8 @@ def test_live_render_queue_preserves_real_upward_input_from_pinned_capture(): """The real producer and queued live-render callback preserve upward input.""" result = json.loads(_run_node(_live_render_ownership_harness(move_to_bottom=False))) assert result == { + "capturedPinned": True, + "guardQueued": True, "initialWrites": [90026], "writes": [], "finalScrollTop": 89000, @@ -190,6 +197,8 @@ def test_live_render_queue_repins_real_downward_input_at_bottom(): """The real producer and queued live-render callback re-pin at true bottom.""" result = json.loads(_run_node(_live_render_ownership_harness(move_to_bottom=True))) assert result == { + "capturedPinned": False, + "guardQueued": True, "initialWrites": [89577], "writes": [], "finalScrollTop": 90026, @@ -326,7 +335,8 @@ def _fallback_harness(*, snapshot_scroll_height, cur_scroll_height, snapshot_top snapshot_pinned: bool = False, anchor=None, anchor_row_content_pos=None, container_top: int = 0, top_pad_now=None, input_generation: int = 0, - delayed_input_generation=None, delayed_scroll_top: int = 89000) -> str: + delayed_input_generation=None, delayed_scroll_top: int = 89000, + restore_fn: str = "_restoreMessageScrollSnapshotSameFrame") -> str: """Node harness for the absolute-fallback path of _restoreMessageScrollSnapshotSameFrame. SCROLL-DEPENDENT geometry (round-3 gate-cert requirement): the anchor row's @@ -401,9 +411,9 @@ const snapshot = {{ anchor: {anchor_js}, top: {snapshot_top}, bottom: 40, eval(extractFunc('_desktopAnchorRealignDelta')); eval(extractFunc('_messageScrollSnapshotInputChanged')); eval(extractFunc('_abandonMessageScrollSnapshot')); -eval(extractFunc('_restoreMessageScrollSnapshotSameFrame')); -_restoreMessageScrollSnapshotSameFrame(snapshot); -{f"stTop = {delayed_scroll_top}; _messageScrollInputGeneration = {delayed_input_generation}; _restoreMessageScrollSnapshotSameFrame(snapshot);" if delayed_input_generation is not None else ""} +eval(extractFunc({json.dumps(restore_fn)})); +{restore_fn}(snapshot); +{f"stTop = {delayed_scroll_top}; _messageScrollInputGeneration = {delayed_input_generation}; {restore_fn}(snapshot);" if delayed_input_generation is not None else ""} console.log(JSON.stringify({{ wrote: writes.length, writes, messageUserUnpinned: _messageUserUnpinned, scrollPinned: _scrollPinned, finalScrollTop: Math.round(stTop) }})); """ @@ -500,7 +510,11 @@ def test_delayed_restore_cannot_overwrite_new_desktop_input(): assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False -def test_delayed_pinned_restore_cannot_overwrite_new_upward_input(): +@pytest.mark.parametrize("restore_fn", [ + "_restoreMessageScrollSnapshot", + "_restoreMessageScrollSnapshotSameFrame", +]) +def test_delayed_pinned_restore_cannot_overwrite_new_upward_input(restore_fn): """A pinned-at-capture snapshot must lose ownership before its tail write. The reader moves upward before the delayed restore; the stale pinned snapshot @@ -511,19 +525,25 @@ def test_delayed_pinned_restore_cannot_overwrite_new_upward_input(): active_intent=False, touch_like=False, snapshot_pinned=True, init_scroll_top=89577, input_generation=0, delayed_input_generation=1, delayed_scroll_top=89000, + restore_fn=restore_fn, ))) assert m["writes"] == [89986] assert m["finalScrollTop"] == 89000 assert m["messageUserUnpinned"] is True and m["scrollPinned"] is False -def test_delayed_unpinned_restore_repins_reader_who_reached_bottom(): +@pytest.mark.parametrize("restore_fn", [ + "_restoreMessageScrollSnapshot", + "_restoreMessageScrollSnapshotSameFrame", +]) +def test_delayed_unpinned_restore_repins_reader_who_reached_bottom(restore_fn): """Abandoning a stale unpinned snapshot must reconcile a live bottom position.""" m = json.loads(_run_node(_fallback_harness( snapshot_scroll_height=90453, cur_scroll_height=90453, snapshot_top=89577, active_intent=False, touch_like=False, snapshot_user_unpinned=True, init_scroll_top=89577, input_generation=0, delayed_input_generation=1, delayed_scroll_top=90026, + restore_fn=restore_fn, ))) assert m["writes"] == [89577] assert m["finalScrollTop"] == 90026 diff --git a/tests/test_issue5720_reasoning_owner.py b/tests/test_issue5720_reasoning_owner.py index 8a0223e5..6cc31e99 100644 --- a/tests/test_issue5720_reasoning_owner.py +++ b/tests/test_issue5720_reasoning_owner.py @@ -476,6 +476,7 @@ for(const name of [ '_liveAnchorReasoningRowForFallback','_updateLiveAnchorReasoningRowForFallback', '_anchorSceneNodeForRow','_anchorSceneWorklogGroup','_renderAnchorSceneRowsIntoWorklog', 'isLiveAnchorActivitySceneOwner','_projectLiveAnchorActivitySceneForStream', + '_restoreLiveAnchorScrollSnapshotAfterRebuild', '_renderLiveAnchorActivitySceneTransparent','renderLiveAnchorActivityScene', '_renderLiveAnchorActivitySceneForStream','appendThinking','updateThinking', ]) eval(extractFunc(uiSrc,name)); From c32b10ee8baeea947167b526afbcc286ea8e1c35 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 25 Jul 2026 15:43:45 +0800 Subject: [PATCH 08/12] test: update live scroll fixture contracts --- tests/test_issue3479_ios_stream_scroll_jump.py | 2 +- tests/test_issue5637_stale_anchor_guard.py | 2 +- tests/test_live_to_final_anchor_visible_order.py | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_issue3479_ios_stream_scroll_jump.py b/tests/test_issue3479_ios_stream_scroll_jump.py index c803abf3..1458f361 100644 --- a/tests/test_issue3479_ios_stream_scroll_jump.py +++ b/tests/test_issue3479_ios_stream_scroll_jump.py @@ -74,7 +74,7 @@ def test_live_anchor_worklog_rebuild_restores_snapshot_before_follow_settle(): dedupe_idx = body.index("_dedupeLiveProcessedWorklogAnchors(turn);") move_status_idx = body.index("_moveLiveRunStatusToTurnEnd();") restore_idx = body.index("_restoreMessageScrollSnapshotSameFrame(scrollSnapshot);") - release_idx = body.index("if(scrollRebuildGuard&&scrollRebuildGuard.release)") + release_idx = body.index("_restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot,scrollRebuildGuard);") settle_idx = body.index("if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned();") assert capture_idx < guard_idx < remove_idx < restore_detail_idx < dedupe_idx < move_status_idx < restore_idx < release_idx < settle_idx diff --git a/tests/test_issue5637_stale_anchor_guard.py b/tests/test_issue5637_stale_anchor_guard.py index cff130bd..77b54d4a 100644 --- a/tests/test_issue5637_stale_anchor_guard.py +++ b/tests/test_issue5637_stale_anchor_guard.py @@ -161,7 +161,7 @@ function _restoreMessageScrollSnapshotSameFrameFallback(){{}} function requestAnimationFrame(callback){{callbacks.push(callback);}} const snapshot=_captureMessageScrollSnapshot(); const capturedPinned=snapshot.pinned===true; -{f"el._top=89577; _messageUserUnpinned=true; _scrollPinned=false;" if not move_to_bottom else ""} +{"el._top=89577; _messageUserUnpinned=true; _scrollPinned=false;" if not move_to_bottom else ""} const scrollRebuildGuard=_prepareLiveAnchorScrollRebuildGuard(snapshot); _restoreMessageScrollSnapshotSameFrame(snapshot); const initialWrites=writes.slice(); diff --git a/tests/test_live_to_final_anchor_visible_order.py b/tests/test_live_to_final_anchor_visible_order.py index f353be22..672956a1 100644 --- a/tests/test_live_to_final_anchor_visible_order.py +++ b/tests/test_live_to_final_anchor_visible_order.py @@ -1250,6 +1250,7 @@ global._syncToolCallGroupSummary=()=>{{}}; eval(extractFunc('_anchorSceneLiveTokenFinalPrefix')); eval(extractFunc('_anchorSceneTransparentNodeForRow')); eval(extractFunc('renderLiveAnchorActivityScene')); + eval(extractFunc('_restoreLiveAnchorScrollSnapshotAfterRebuild')); eval(extractFunc('_transparentLiveRowKey')); eval(extractFunc('_transparentLiveRowsCompatible')); eval(extractFunc('_transparentLiveRowAttributePairs')); From fd1879418d819c358b6606eaf01513bc6e7ab560 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 25 Jul 2026 16:30:20 +0800 Subject: [PATCH 09/12] fix: queue pinned live scroll ownership check --- static/ui.js | 13 ++++++++++--- tests/test_issue5637_stale_anchor_guard.py | 9 ++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/static/ui.js b/static/ui.js index 7492e5f2..3b51b5a4 100644 --- a/static/ui.js +++ b/static/ui.js @@ -12726,13 +12726,20 @@ function _prepareLiveAnchorScrollRebuildGuard(scrollSnapshot){ }; } function _restoreLiveAnchorScrollSnapshotAfterRebuild(scrollSnapshot, scrollRebuildGuard){ - if(!scrollRebuildGuard||!scrollRebuildGuard.release) return; + if(!scrollSnapshot) return; + const hasHeightGuard=!!(scrollRebuildGuard&&scrollRebuildGuard.release); + // Pinned renders have no height guard to release, but still need the same + // queued ownership check: a reader can provide real input before the frame + // settles, and that input must win over the captured tail position. + if(!hasHeightGuard&&scrollSnapshot.pinned!==true) return; requestAnimationFrame(()=>{ - scrollRebuildGuard.release(); + if(hasHeightGuard) scrollRebuildGuard.release(); // Only re-restore the unpinned snapshot if the reader is STILL unpinned at // rAF time. If they re-pinned between guard-engage and this frame, the // stale re-restore would yank them back off the bottom (Opus gate finding). - if(_messageUserUnpinned) _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); + if(scrollSnapshot.pinned===true||_messageUserUnpinned){ + _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); + } }); } function _resetMismatchedLiveAssistantTurnForSession(turn, sessionId){ diff --git a/tests/test_issue5637_stale_anchor_guard.py b/tests/test_issue5637_stale_anchor_guard.py index 77b54d4a..25173676 100644 --- a/tests/test_issue5637_stale_anchor_guard.py +++ b/tests/test_issue5637_stale_anchor_guard.py @@ -135,6 +135,7 @@ let _lastScrollTop=null, _lastMessageClientHeight=null; let _programmaticScroll=false, _programmaticScrollSetAt=0; let recordWrites=true; const callbacks=[]; +let delayedSnapshotPinned=null; const performance={{now(){{return 1;}}}}; const el={{ scrollHeight:90453, clientHeight:427, @@ -158,21 +159,21 @@ function _isTouchLikeMessageViewport(){{return false;}} function _recentMessageScrollIntent(){{return false;}} function _recentMessageTouchScrollIntent(){{return false;}} function _restoreMessageScrollSnapshotSameFrameFallback(){{}} -function requestAnimationFrame(callback){{callbacks.push(callback);}} +function requestAnimationFrame(callback){{callbacks.push(()=>{{delayedSnapshotPinned=snapshot.pinned===true; callback();}});}} const snapshot=_captureMessageScrollSnapshot(); const capturedPinned=snapshot.pinned===true; -{"el._top=89577; _messageUserUnpinned=true; _scrollPinned=false;" if not move_to_bottom else ""} const scrollRebuildGuard=_prepareLiveAnchorScrollRebuildGuard(snapshot); _restoreMessageScrollSnapshotSameFrame(snapshot); const initialWrites=writes.slice(); writes=[]; _restoreLiveAnchorScrollSnapshotAfterRebuild(snapshot,scrollRebuildGuard); +const guardQueued=callbacks.length>0; recordWrites=false; _recordNonMessageScrollIntent({{target:el,deltaY:{target_delta}}}); el.scrollTop={final_top}; recordWrites=true; callbacks.shift()(); -console.log(JSON.stringify({{capturedPinned,guardQueued:!!(scrollRebuildGuard&&scrollRebuildGuard.release),initialWrites,writes, finalScrollTop:el.scrollTop, +console.log(JSON.stringify({{capturedPinned,guardQueued,delayedSnapshotPinned,initialWrites,writes, finalScrollTop:el.scrollTop, messageUserUnpinned:_messageUserUnpinned, scrollPinned:_scrollPinned, generation:_messageScrollInputGeneration}})); """ @@ -184,6 +185,7 @@ def test_live_render_queue_preserves_real_upward_input_from_pinned_capture(): assert result == { "capturedPinned": True, "guardQueued": True, + "delayedSnapshotPinned": True, "initialWrites": [90026], "writes": [], "finalScrollTop": 89000, @@ -199,6 +201,7 @@ def test_live_render_queue_repins_real_downward_input_at_bottom(): assert result == { "capturedPinned": False, "guardQueued": True, + "delayedSnapshotPinned": False, "initialWrites": [89577], "writes": [], "finalScrollTop": 90026, From c69f1a73dc23f29265ebf938dea4592237663253 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Tue, 28 Jul 2026 21:42:09 +0800 Subject: [PATCH 10/12] test: exercise renderer scroll restore ownership --- tests/test_issue5637_stale_anchor_guard.py | 46 ++++++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/tests/test_issue5637_stale_anchor_guard.py b/tests/test_issue5637_stale_anchor_guard.py index 25173676..c6d76cc8 100644 --- a/tests/test_issue5637_stale_anchor_guard.py +++ b/tests/test_issue5637_stale_anchor_guard.py @@ -113,6 +113,7 @@ def _live_render_ownership_harness(*, move_to_bottom: bool) -> str: """Compose real input production with the production queued live-render restore.""" js = UI_JS_PATH.read_text(encoding="utf-8") functions = [ + "renderLiveAnchorActivityScene", "_recordNonMessageScrollIntent", "_captureMessageScrollSnapshot", "_messageScrollSnapshotInputChanged", @@ -135,7 +136,6 @@ let _lastScrollTop=null, _lastMessageClientHeight=null; let _programmaticScroll=false, _programmaticScrollSetAt=0; let recordWrites=true; const callbacks=[]; -let delayedSnapshotPinned=null; const performance={{now(){{return 1;}}}}; const el={{ scrollHeight:90453, clientHeight:427, @@ -147,7 +147,20 @@ const el={{ }}; const document={{getElementById(id){{return id==='messages'?el:null;}}}}; const msgInner={{style:{{}},dataset:{{}}}}; -function $(id){{return id==='messages'?el:id==='msgInner'?msgInner:null;}} +const emptyState={{style:{{display:''}}}}; +const turn={{ + dataset:{{}}, + setAttribute(){{}}, + querySelectorAll(){{return [];}}, +}}; +const blocks={{querySelectorAll(){{return [];}}}}; +function $(id){{ + if(id==='messages') return el; + if(id==='msgInner') return msgInner; + if(id==='emptyState') return emptyState; + if(id==='liveAssistantTurn') return turn; + return null; +}} function _captureMessageViewportAnchor(){{return null;}} function _shouldFollowMessagesOnDomReplace(){{return true;}} function _deferClearProgrammaticScroll(){{}} @@ -159,21 +172,30 @@ function _isTouchLikeMessageViewport(){{return false;}} function _recentMessageScrollIntent(){{return false;}} function _recentMessageTouchScrollIntent(){{return false;}} function _restoreMessageScrollSnapshotSameFrameFallback(){{}} -function requestAnimationFrame(callback){{callbacks.push(()=>{{delayedSnapshotPinned=snapshot.pinned===true; callback();}});}} -const snapshot=_captureMessageScrollSnapshot(); -const capturedPinned=snapshot.pinned===true; -const scrollRebuildGuard=_prepareLiveAnchorScrollRebuildGuard(snapshot); -_restoreMessageScrollSnapshotSameFrame(snapshot); +function requestAnimationFrame(callback){{callbacks.push(callback);}} +function chatActivityMode(){{return 'compact_worklog';}} +function isSimplifiedToolCalling(){{return true;}} +const S={{session:{{session_id:'sid-1',pending_started_at:1}},activeStreamId:'stream-1'}}; +function _anchorSceneRowsForRendering(){{return [];}} +function _assistantTurnBlocks(){{return blocks;}} +function _captureWorklogDetailDisclosureState(){{return null;}} +function _anchorSceneWorklogGroup(){{return {{}};}} +function _renderAnchorSceneRowsIntoWorklog(){{return true;}} +function _restoreWorklogDetailDisclosureState(){{}} +function _startActivityElapsedTimer(){{}} +function _dedupeLiveProcessedWorklogAnchors(){{}} +function _moveLiveRunStatusToTurnEnd(){{}} +function scrollIfPinned(){{}} +const rendered=renderLiveAnchorActivityScene('stream-1',{{activity_rows:[]}},{{sessionId:'sid-1'}}); const initialWrites=writes.slice(); writes=[]; -_restoreLiveAnchorScrollSnapshotAfterRebuild(snapshot,scrollRebuildGuard); const guardQueued=callbacks.length>0; recordWrites=false; _recordNonMessageScrollIntent({{target:el,deltaY:{target_delta}}}); el.scrollTop={final_top}; recordWrites=true; callbacks.shift()(); -console.log(JSON.stringify({{capturedPinned,guardQueued,delayedSnapshotPinned,initialWrites,writes, finalScrollTop:el.scrollTop, +console.log(JSON.stringify({{rendered,guardQueued,initialWrites,writes, finalScrollTop:el.scrollTop, messageUserUnpinned:_messageUserUnpinned, scrollPinned:_scrollPinned, generation:_messageScrollInputGeneration}})); """ @@ -183,9 +205,8 @@ def test_live_render_queue_preserves_real_upward_input_from_pinned_capture(): """The real producer and queued live-render callback preserve upward input.""" result = json.loads(_run_node(_live_render_ownership_harness(move_to_bottom=False))) assert result == { - "capturedPinned": True, + "rendered": True, "guardQueued": True, - "delayedSnapshotPinned": True, "initialWrites": [90026], "writes": [], "finalScrollTop": 89000, @@ -199,9 +220,8 @@ def test_live_render_queue_repins_real_downward_input_at_bottom(): """The real producer and queued live-render callback re-pin at true bottom.""" result = json.loads(_run_node(_live_render_ownership_harness(move_to_bottom=True))) assert result == { - "capturedPinned": False, + "rendered": True, "guardQueued": True, - "delayedSnapshotPinned": False, "initialWrites": [89577], "writes": [], "finalScrollTop": 90026, From 9c1c1d8063c8cc06db0eb4d4beb2ba893dce6dc1 Mon Sep 17 00:00:00 2001 From: happy5318 Date: Sat, 1 Aug 2026 00:44:57 +0800 Subject: [PATCH 11/12] fix: stop false Compressing-context card for preflight compression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #6184 narrowed the agent-status-to-SSE bridge to real compression start notices, but the 'preflight compression:' pattern matches the Hermes agent's preflight check message ("Preflight compression: ~X tokens >= Y threshold"). This fires on every new session where the system prompt alone exceeds the threshold, showing a "Compressing context" card before any model output — a false positive. Remove 'preflight compression:' from the pattern. The preflight check is a one-time pre-model-call estimate, not a mid-conversation compression event that the user needs to see. --- api/streaming.py | 3 +-- tests/test_auto_compression_card.py | 5 ++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/api/streaming.py b/api/streaming.py index f9dc14eb..5f2645d3 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -587,8 +587,7 @@ def _is_agent_compression_start_status(kind: str, message: str) -> bool: if 'compressed' in m and 'compressing' not in m and 'compression attempt' not in m: return False return ( - 'preflight compression:' in m - or 'pre-api compression:' in m + 'pre-api compression:' in m or 'compacting context' in m or 'context too large' in m or '— compressing (' in m diff --git a/tests/test_auto_compression_card.py b/tests/test_auto_compression_card.py index a6bfa8fd..b8f46d18 100644 --- a/tests/test_auto_compression_card.py +++ b/tests/test_auto_compression_card.py @@ -473,7 +473,10 @@ def test_agent_status_callback_emits_compressing_and_warning_events(): def test_agent_compression_start_status_matches_real_emitters_only(): # Real start notices from hermes-agent emitters - assert _is_agent_compression_start_status( + # Preflight compression is excluded — it fires on every new session + # where the system prompt alone exceeds the threshold, producing a + # false "Compressing context" card before any model output. + assert not _is_agent_compression_start_status( "lifecycle", "📦 Preflight compression: ~101,000 tokens >= 96,000 threshold. This may take a moment.", ) From f52be26610266ef10ad67eaf360920f861bb01b8 Mon Sep 17 00:00:00 2001 From: happy5318 Date: Sat, 1 Aug 2026 01:47:51 +0800 Subject: [PATCH 12/12] docs: update comments to reflect preflight exclusion reasoning Clarify that preflight compression is intentionally excluded because the later authoritative 'Compacting context' marker from conversation_compression is the real signal that compression proceeded. The preflight status can fire even when compression exits before compaction (e.g. durable guard refresh, Codex-Hermes mode with no active thread). --- api/streaming.py | 7 +++++-- tests/test_auto_compression_card.py | 9 ++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/api/streaming.py b/api/streaming.py index 5f2645d3..f83a6d35 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -566,8 +566,11 @@ def _is_agent_compression_start_status(kind: str, message: str) -> bool: auto-compression. Positive markers below match the agent emitters in hermes-agent - (``turn_context`` preflight, ``conversation_loop`` pre-API / 413 / too-large, - ``conversation_compression`` compaction status). Explicitly reject skip / + (``conversation_loop`` pre-API / 413 / too-large, + ``conversation_compression`` compaction status). Preflight compression + (``turn_context``) is intentionally excluded — the later authoritative + ``Compacting context`` marker is the signal that compression actually + proceeded. Explicitly reject skip / defer notices so "Skipping preflight compression…" never surfaces as a running compress divider. """ diff --git a/tests/test_auto_compression_card.py b/tests/test_auto_compression_card.py index b8f46d18..837ff474 100644 --- a/tests/test_auto_compression_card.py +++ b/tests/test_auto_compression_card.py @@ -473,9 +473,12 @@ def test_agent_status_callback_emits_compressing_and_warning_events(): def test_agent_compression_start_status_matches_real_emitters_only(): # Real start notices from hermes-agent emitters - # Preflight compression is excluded — it fires on every new session - # where the system prompt alone exceeds the threshold, producing a - # false "Compressing context" card before any model output. + # Preflight compression is intentionally excluded — the later + # authoritative ``Compacting context`` marker from + # conversation_compression is the signal that compression + # actually proceeded, and the preflight status can fire even + # when compression exits before compaction (e.g. durable + # guard refresh, Codex-Hermes mode with no active thread). assert not _is_agent_compression_start_status( "lifecycle", "📦 Preflight compression: ~101,000 tokens >= 96,000 threshold. This may take a moment.",