From 40ccd863ba895676dd24bc2aee0f0916ad1f63cb Mon Sep 17 00:00:00 2001 From: allenliang2022 Date: Sun, 28 Jun 2026 00:39:28 +0800 Subject: [PATCH 1/4] fix(chat): prevent stream-end worklog collapse jump for pinned readers A new post-#4970 scroll-jump class surfaced after Release YH: when a streamed assistant turn with a large live worklog/tool trace settles, the settled compact worklog collapses hundreds of pixels at STREAM_DONE. The reader is still pinned and scroll state is correct, but the net scrollHeight shrink makes the browser clamp scrollTop down by the same amount, which is visible as a large backward jump. Fix by keeping the just-settled activity worklog open for pinned followers (_scrollPinned && !_messageUserUnpinned), so the live->settled DOM swap is height-stable. Unpinned readers still get compact settled worklogs and keep their viewport normally. Also make _anchorSceneWorklogGroup respect opts.collapsed; previously it hard-coded collapsed: !live so the caller could not request an open settled group. Regression locks assert the pinned-follow helper, that settled rendering passes collapsed:!keepSettledWorklogOpen, and that _anchorSceneWorklogGroup honors an explicit opts.collapsed. Verified locally with a Playwright repro: before the fix a large tool-worklog answer produced scrollHeight -367/-422px and scrollTop -367/-422px at STREAM_DONE; after the fix nShrinks=0 and nBack=0. Local focused scroll locks pass: 22 passed. --- static/ui.js | 23 +++++++- ...issue4970_stream_done_shrink_regression.py | 53 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 tests/test_issue4970_stream_done_shrink_regression.py diff --git a/static/ui.js b/static/ui.js index 5429667994b..fedf06dd086 100644 --- a/static/ui.js +++ b/static/ui.js @@ -10100,7 +10100,10 @@ function _anchorSceneWorklogGroup(blocks, opts){ let group=blocks.querySelector(`.tool-worklog-group[data-anchor-scene-owner="1"][data-tool-worklog-key="${CSS.escape(activityKey)}"]`); if(!group){ group=ensureActivityGroup(blocks,{ - collapsed:!live, + // Respect callers that need the settled activity group open. Round 6: + // pinned followers keep the just-settled worklog open so STREAM_DONE does + // not collapse hundreds of px of live worklog and visibly clamp the pane. + collapsed:(opts&&opts.collapsed!==undefined)?opts.collapsed:!live, live, activityKey, beforeAnchor:!!(opts&&opts.beforeAnchor), @@ -10365,6 +10368,21 @@ function _renderSettledAnchorSceneTransparentForMessage(message, segment, rawIdx } return wrote; } +function _shouldKeepSettledWorklogOpenForPinnedFollow(){ + // Round 6 scroll-jump guard: while the reader is pinned at the live tail, + // collapsing the live worklog into a compact settled summary can shrink the + // transcript by hundreds of px at STREAM_DONE. The browser clamps scrollTop to + // the new max, which looks like a large backward jump even though pinned state + // is correct. Keep the just-settled worklog open for pinned followers so the + // live->settled DOM swap is height-stable; unpinned readers still get compact + // settled worklogs and preserve their viewport normally. + // Use the sticky pin state as the authority. During live DOM rebuilds the raw + // bottom distance can transiently exceed a threshold even for a pinned follower + // (the assistant body/worklog grows before follow writes land), so a near-bottom + // check here would incorrectly collapse the settled worklog and reintroduce the + // STREAM_DONE shrink jump. + return !!(_scrollPinned && !_messageUserUnpinned); +} function _renderSettledAnchorSceneForMessage(message, segment, rawIdx){ if(!message||!message._anchor_activity_scene||!segment) return false; if(typeof isTransparentStream==='function'&&isTransparentStream()){ @@ -10373,6 +10391,7 @@ function _renderSettledAnchorSceneForMessage(message, segment, rawIdx){ if(typeof isCompactWorklogMode==='function'&&!isCompactWorklogMode()) return false; const blocks=_assistantTurnBlocks(segment.closest('.assistant-turn')); if(!blocks) return false; + const keepSettledWorklogOpen=_shouldKeepSettledWorklogOpenForPinnedFollow(); const scene=message._anchor_activity_scene; const rows=_anchorSceneRowsForRendering(scene,{settled:true}); if(!rows.length) return false; @@ -10392,7 +10411,7 @@ function _renderSettledAnchorSceneForMessage(message, segment, rawIdx){ } const group=_anchorSceneWorklogGroup(blocks,{ live:false, - collapsed:true, + collapsed:!keepSettledWorklogOpen, beforeAnchor:true, anchor:segment, activityKey, diff --git a/tests/test_issue4970_stream_done_shrink_regression.py b/tests/test_issue4970_stream_done_shrink_regression.py new file mode 100644 index 00000000000..09d463c907e --- /dev/null +++ b/tests/test_issue4970_stream_done_shrink_regression.py @@ -0,0 +1,53 @@ +"""Regression locks for #4970 round 6: stream-end worklog collapse shrink jump.""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") + + +def _function_body(src: str, name: str) -> str: + marker = f"function {name}" + start = src.index(marker) + brace = src.index("{", start) + depth = 0 + for idx in range(brace, len(src)): + ch = src[idx] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return src[brace + 1 : idx] + raise AssertionError(f"function {name} body not found") + + +def test_pinned_follow_keeps_settled_worklog_open_to_avoid_stream_done_shrink(): + """A pinned reader must not see a huge upward clamp when live worklog settles. + + The live worklog can be hundreds of px tall. If the settled worklog is forced + collapsed at STREAM_DONE, scrollHeight shrinks and the browser clamps + scrollTop, which looks like a large backward jump even though pinned state is + correct. Pinned followers keep the settled worklog open for this turn; users + who scrolled away still get compact/collapsed settled worklogs. + """ + assert "function _shouldKeepSettledWorklogOpenForPinnedFollow" in UI_JS + helper = _function_body(UI_JS, "_shouldKeepSettledWorklogOpenForPinnedFollow") + assert "_scrollPinned" in helper + assert "!_messageUserUnpinned" in helper + assert "bottom distance can transiently exceed" in helper + + render_fn = _function_body(UI_JS, "_renderSettledAnchorSceneForMessage") + assert "const keepSettledWorklogOpen=_shouldKeepSettledWorklogOpenForPinnedFollow();" in render_fn + assert "collapsed:!keepSettledWorklogOpen" in render_fn + + group_fn = _function_body(UI_JS, "_anchorSceneWorklogGroup") + assert "opts&&opts.collapsed!==undefined" in group_fn + assert "collapsed:(opts&&opts.collapsed!==undefined)?opts.collapsed:!live" in group_fn + + +def test_unpinned_reader_still_gets_compact_settled_worklog(): + helper = _function_body(UI_JS, "_shouldKeepSettledWorklogOpenForPinnedFollow") + assert "!_messageUserUnpinned" in helper, ( + "The keep-open exception must be limited to pinned followers; unpinned " + "readers should not get their viewport or settled worklog state changed." + ) From e1eb6fd2a88c01b9e4b19f466b2229471d59ca5c Mon Sep 17 00:00:00 2001 From: allenliang2022 Date: Sun, 28 Jun 2026 10:51:34 +0800 Subject: [PATCH 2/4] test(chat): document keep-open precedence over live collapse state Follow-up to maintainer review on #5058: make the pinned-follower helper comment explicit that avoiding the visible STREAM_DONE shrink jump intentionally takes precedence over a transient user-collapsed live worklog while the reader remains pinned. Add a source lock so that intent stays documented. Focused scroll locks remain green (22 passed), node --check clean. --- static/ui.js | 4 +++- tests/test_issue4970_stream_done_shrink_regression.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/static/ui.js b/static/ui.js index fedf06dd086..4a1f9dbd02e 100644 --- a/static/ui.js +++ b/static/ui.js @@ -10375,7 +10375,9 @@ function _shouldKeepSettledWorklogOpenForPinnedFollow(){ // the new max, which looks like a large backward jump even though pinned state // is correct. Keep the just-settled worklog open for pinned followers so the // live->settled DOM swap is height-stable; unpinned readers still get compact - // settled worklogs and preserve their viewport normally. + // settled worklogs and preserve their viewport normally. This intentionally + // wins over a transient user-collapsed live worklog while the reader remains + // pinned: avoiding the visible STREAM_DONE jump takes precedence for followers. // Use the sticky pin state as the authority. During live DOM rebuilds the raw // bottom distance can transiently exceed a threshold even for a pinned follower // (the assistant body/worklog grows before follow writes land), so a near-bottom diff --git a/tests/test_issue4970_stream_done_shrink_regression.py b/tests/test_issue4970_stream_done_shrink_regression.py index 09d463c907e..1f92c9abdee 100644 --- a/tests/test_issue4970_stream_done_shrink_regression.py +++ b/tests/test_issue4970_stream_done_shrink_regression.py @@ -35,6 +35,7 @@ def test_pinned_follow_keeps_settled_worklog_open_to_avoid_stream_done_shrink(): assert "_scrollPinned" in helper assert "!_messageUserUnpinned" in helper assert "bottom distance can transiently exceed" in helper + assert "avoiding the visible STREAM_DONE jump takes precedence" in helper render_fn = _function_body(UI_JS, "_renderSettledAnchorSceneForMessage") assert "const keepSettledWorklogOpen=_shouldKeepSettledWorklogOpenForPinnedFollow();" in render_fn From 9634774a066ff1359bdeb02ef83d605be0429662 Mon Sep 17 00:00:00 2001 From: allenliang2022 Date: Mon, 29 Jun 2026 10:05:55 +0800 Subject: [PATCH 3/4] fix(chat): scope STREAM_DONE keep-open to just-settled turn via one-shot token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address maintainer must-fix: _shouldKeepSettledWorklogOpenForPinnedFollow() keyed only on global pin state, so every historical settled worklog re-expanded on any pinned re-render — defeating the compact default. Gate the keep-open exception on a one-shot token = the just-settled stream id (armed at STREAM_DONE renderMessages, disarmed right after), threaded as _shouldKeep...(streamId). Unpinned path unchanged. Replace source-string test with a behavioral Node test that drives a second pinned turn and asserts it collapses. --- static/messages.js | 7 ++ static/ui.js | 44 ++++++--- ...issue4970_stream_done_shrink_regression.py | 89 ++++++++++++++----- 3 files changed, 105 insertions(+), 35 deletions(-) diff --git a/static/messages.js b/static/messages.js index de17fa888a1..d666030783f 100644 --- a/static/messages.js +++ b/static/messages.js @@ -5001,6 +5001,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const shouldFollowOnDone=isActiveSession&&((typeof _shouldFollowMessagesOnDomReplace==='function') ? _shouldFollowMessagesOnDomReplace() : (typeof _isMessagePaneNearBottom==='function'&&_isMessagePaneNearBottom(1200))); + const _settledStreamId=isActiveSession?(S.activeStreamId||(d&&d.stream_id)||''):''; if(isActiveSession){ S.activeStreamId=null; } @@ -5151,7 +5152,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // turn boundary so the following syncTopbar() refetches the authoritative // effort exactly once (not per-token — the storm short-circuit is intact). if(typeof _lastReasoningFetchKey!=='undefined') _lastReasoningFetchKey=null; + // Arm the one-shot keep-open token for JUST this settled turn so a + // pinned follower's worklog stays height-stable (no STREAM_DONE shrink + // jump); disarm right after the render so historical worklogs collapse + // compact as normal. Scoped to the just-settled stream id. + if(typeof _armKeepSettledWorklogOpen==='function') _armKeepSettledWorklogOpen(_settledStreamId); syncTopbar();renderMessages({preserveScroll:true}); + if(typeof _disarmKeepSettledWorklogOpen==='function') _disarmKeepSettledWorklogOpen(); if(shouldFollowOnDone&&typeof scrollToBottom==='function') scrollToBottom(); if(typeof noteWorkspaceMutationsFromToolCalls==='function') noteWorkspaceMutationsFromToolCalls(S.toolCalls); loadDir('.', { preservePreview: true }); diff --git a/static/ui.js b/static/ui.js index 4a1f9dbd02e..31e53b368e9 100644 --- a/static/ui.js +++ b/static/ui.js @@ -10368,23 +10368,45 @@ function _renderSettledAnchorSceneTransparentForMessage(message, segment, rawIdx } return wrote; } -function _shouldKeepSettledWorklogOpenForPinnedFollow(){ +// One-shot token: the stream id of the turn that JUST settled at STREAM_DONE. +// The keep-open exception applies to ONLY this one turn's settled render, then +// is cleared so every other (historical) settled worklog renders compact even +// while the reader is pinned. Set right before the STREAM_DONE +// renderMessages({preserveScroll:true}) call and cleared after the settled-scene +// render pass; null at all other times. +let _keepSettledWorklogOpenForStreamId=null; +function _shouldKeepSettledWorklogOpenForPinnedFollow(streamId){ // Round 6 scroll-jump guard: while the reader is pinned at the live tail, - // collapsing the live worklog into a compact settled summary can shrink the - // transcript by hundreds of px at STREAM_DONE. The browser clamps scrollTop to - // the new max, which looks like a large backward jump even though pinned state - // is correct. Keep the just-settled worklog open for pinned followers so the + // collapsing the JUST-settled live worklog into a compact summary can shrink + // the transcript by hundreds of px at STREAM_DONE. The browser clamps scrollTop + // to the new max, which looks like a large backward jump even though pinned + // state is correct. Keep that one worklog open for pinned followers so the // live->settled DOM swap is height-stable; unpinned readers still get compact // settled worklogs and preserve their viewport normally. This intentionally // wins over a transient user-collapsed live worklog while the reader remains // pinned: avoiding the visible STREAM_DONE jump takes precedence for followers. - // Use the sticky pin state as the authority. During live DOM rebuilds the raw - // bottom distance can transiently exceed a threshold even for a pinned follower - // (the assistant body/worklog grows before follow writes land), so a near-bottom - // check here would incorrectly collapse the settled worklog and reintroduce the - // STREAM_DONE shrink jump. + // SCOPING: the exception is gated on the one-shot token matching this turn's + // stream id, so it applies ONLY to the turn that just settled — not to every + // historical settled worklog on every pinned re-render (which would defeat the + // compact-worklog default for past turns). Pin flags use the sticky pin state + // because during live DOM rebuilds the raw bottom distance can transiently + // exceed a threshold even for a pinned follower. + if(!streamId||_keepSettledWorklogOpenForStreamId!==streamId) return false; return !!(_scrollPinned && !_messageUserUnpinned); } +// One-shot token set/clear API used by the STREAM_DONE handler (messages.js): +// arm the keep-open exception for exactly the turn that just settled, render, +// then disarm so subsequent re-renders collapse historical worklogs as normal. +function _armKeepSettledWorklogOpen(streamId){ + _keepSettledWorklogOpenForStreamId=streamId?String(streamId):null; +} +function _disarmKeepSettledWorklogOpen(){ + _keepSettledWorklogOpenForStreamId=null; +} +if(typeof window!=='undefined'){ + window._armKeepSettledWorklogOpen=_armKeepSettledWorklogOpen; + window._disarmKeepSettledWorklogOpen=_disarmKeepSettledWorklogOpen; +} function _renderSettledAnchorSceneForMessage(message, segment, rawIdx){ if(!message||!message._anchor_activity_scene||!segment) return false; if(typeof isTransparentStream==='function'&&isTransparentStream()){ @@ -10393,7 +10415,6 @@ function _renderSettledAnchorSceneForMessage(message, segment, rawIdx){ if(typeof isCompactWorklogMode==='function'&&!isCompactWorklogMode()) return false; const blocks=_assistantTurnBlocks(segment.closest('.assistant-turn')); if(!blocks) return false; - const keepSettledWorklogOpen=_shouldKeepSettledWorklogOpenForPinnedFollow(); const scene=message._anchor_activity_scene; const rows=_anchorSceneRowsForRendering(scene,{settled:true}); if(!rows.length) return false; @@ -10407,6 +10428,7 @@ function _renderSettledAnchorSceneForMessage(message, segment, rawIdx){ }); blocks.querySelectorAll('.tool-worklog-group:not([data-anchor-scene-owner="1"]),.tool-call-group:not([data-anchor-scene-owner="1"]),.agent-activity-thinking:not([data-anchor-scene-row="1"]),.wl-reason').forEach(el=>el.remove()); const streamId=String(message._anchor_stream_id||scene.stream_id||scene.identity&&scene.identity.stream_id||''); + const keepSettledWorklogOpen=_shouldKeepSettledWorklogOpenForPinnedFollow(streamId); const activityKey=`anchor-scene:${rawIdx}`; if(streamId&&!_readActivityDisclosureState(activityKey)){ _copyActivityDisclosureState(`live:${streamId}`, activityKey); diff --git a/tests/test_issue4970_stream_done_shrink_regression.py b/tests/test_issue4970_stream_done_shrink_regression.py index 1f92c9abdee..e76ef7022e2 100644 --- a/tests/test_issue4970_stream_done_shrink_regression.py +++ b/tests/test_issue4970_stream_done_shrink_regression.py @@ -1,8 +1,23 @@ -"""Regression locks for #4970 round 6: stream-end worklog collapse shrink jump.""" +"""Regression locks for #4970 round 6: stream-end worklog collapse shrink jump. + +Round 7 (scoping fix): the keep-open exception must apply to ONLY the turn that +just settled, gated on a one-shot stream-id token, NOT to every historical +settled worklog on every pinned re-render. These tests are BEHAVIORAL: they +extract the real `_shouldKeepSettledWorklogOpenForPinnedFollow` helper plus its +arm/disarm token API from static/ui.js and execute them in Node, then drive two +settled turns while pinned and assert the second (historical) turn collapses. +""" +import json +import shutil +import subprocess +import textwrap from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parent.parent UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") +MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8") def _function_body(src: str, name: str) -> str: @@ -21,34 +36,60 @@ def _function_body(src: str, name: str) -> str: raise AssertionError(f"function {name} body not found") -def test_pinned_follow_keeps_settled_worklog_open_to_avoid_stream_done_shrink(): - """A pinned reader must not see a huge upward clamp when live worklog settles. +def _extract(name: str) -> str: + """Return the full `function name(...){...}` text from ui.js.""" + marker = f"function {name}" + start = UI_JS.index(marker) + body = _function_body(UI_JS, name) + sig = UI_JS[start : UI_JS.index("{", start)] + return f"{sig}{{{body}}}" - The live worklog can be hundreds of px tall. If the settled worklog is forced - collapsed at STREAM_DONE, scrollHeight shrinks and the browser clamps - scrollTop, which looks like a large backward jump even though pinned state is - correct. Pinned followers keep the settled worklog open for this turn; users - who scrolled away still get compact/collapsed settled worklogs. - """ - assert "function _shouldKeepSettledWorklogOpenForPinnedFollow" in UI_JS - helper = _function_body(UI_JS, "_shouldKeepSettledWorklogOpenForPinnedFollow") - assert "_scrollPinned" in helper - assert "!_messageUserUnpinned" in helper - assert "bottom distance can transiently exceed" in helper - assert "avoiding the visible STREAM_DONE jump takes precedence" in helper +def test_helper_and_token_threaded_through_render(): + # Structural: the helper takes a streamId and gates on the one-shot token, + # and the call site threads the message's stream id (not a no-arg call). + helper = _function_body(UI_JS, "_shouldKeepSettledWorklogOpenForPinnedFollow") + assert "_keepSettledWorklogOpenForStreamId" in helper + assert "_scrollPinned" in helper and "!_messageUserUnpinned" in helper render_fn = _function_body(UI_JS, "_renderSettledAnchorSceneForMessage") - assert "const keepSettledWorklogOpen=_shouldKeepSettledWorklogOpenForPinnedFollow();" in render_fn + assert "_shouldKeepSettledWorklogOpenForPinnedFollow(streamId)" in render_fn assert "collapsed:!keepSettledWorklogOpen" in render_fn - group_fn = _function_body(UI_JS, "_anchorSceneWorklogGroup") - assert "opts&&opts.collapsed!==undefined" in group_fn assert "collapsed:(opts&&opts.collapsed!==undefined)?opts.collapsed:!live" in group_fn + # The STREAM_DONE handler arms one-shot then disarms around the render. + assert "_armKeepSettledWorklogOpen(_settledStreamId)" in MESSAGES_JS + assert "_disarmKeepSettledWorklogOpen()" in MESSAGES_JS -def test_unpinned_reader_still_gets_compact_settled_worklog(): - helper = _function_body(UI_JS, "_shouldKeepSettledWorklogOpenForPinnedFollow") - assert "!_messageUserUnpinned" in helper, ( - "The keep-open exception must be limited to pinned followers; unpinned " - "readers should not get their viewport or settled worklog state changed." - ) +@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test") +def test_only_just_settled_turn_stays_open_pinned_history_collapses(): + """Drive two settled turns while pinned; only the just-settled one stays open.""" + helper = _extract("_shouldKeepSettledWorklogOpenForPinnedFollow") + arm = _extract("_armKeepSettledWorklogOpen") + disarm = _extract("_disarmKeepSettledWorklogOpen") + harness = textwrap.dedent(f""" + let _keepSettledWorklogOpenForStreamId=null; + let _scrollPinned=true, _messageUserUnpinned=false; // pinned follower + {helper} + {arm} + {disarm} + const out={{}}; + // Turn A just settled: arm A, render A (open), render historical B (collapsed), disarm. + _armKeepSettledWorklogOpen('streamA'); + out.A_open = _shouldKeepSettledWorklogOpenForPinnedFollow('streamA'); // expect true + out.B_history = _shouldKeepSettledWorklogOpenForPinnedFollow('streamB'); // expect false + _disarmKeepSettledWorklogOpen(); + // After disarm, even A collapses on a later pinned re-render. + out.A_after_disarm = _shouldKeepSettledWorklogOpenForPinnedFollow('streamA'); // false + // Unpinned reader never keeps open even for the armed turn. + _armKeepSettledWorklogOpen('streamA'); _messageUserUnpinned=true; _scrollPinned=false; + out.unpinned = _shouldKeepSettledWorklogOpenForPinnedFollow('streamA'); // false + console.log(JSON.stringify(out)); + """) + res = subprocess.run(["node", "-e", harness], capture_output=True, text=True, timeout=30) + assert res.returncode == 0, res.stderr + out = json.loads(res.stdout.strip()) + assert out["A_open"] is True, "just-settled turn must keep worklog open for pinned follower" + assert out["B_history"] is False, "historical settled worklog must stay collapsed while pinned" + assert out["A_after_disarm"] is False, "exception must be one-shot, cleared after the render" + assert out["unpinned"] is False, "unpinned reader always gets compact settled worklog" From 4d9d459a46aa834a64b6e54f3cdd668802b140ae Mon Sep 17 00:00:00 2001 From: allenliang2022 Date: Mon, 29 Jun 2026 22:27:36 +0800 Subject: [PATCH 4/4] fix(chat): do not collapse a pure-prose turn into a worklog (stream-done jump) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turn that streamed only prose (a long plain-text answer, or a degeneration burst that floods the body with repeated tokens) still projected an anchor activity scene whose activity_rows were all prose/terminal — zero tool/thinking rows. The settle path promoted it to a collapsed worklog anyway (the gate only checked activity_rows.length), hiding the whole answer and shrinking the transcript by the full streamed height at STREAM_DONE, so the browser clamped a bottom-pinned viewport back to the top (the 'jump back' report). Add a worklog-worthiness predicate at both gates: the generation gate (_anchorSceneHasWorklogWorthyRows in messages.js, decides whether to attach a scene at all) and the render gate (_anchorSceneSceneHasWorklogWorthyRows in ui.js, defense-in-depth for already-persisted all-prose scenes). A scene is worklog-worthy only if it has >=1 tool/thinking row or a compression lifecycle card; pure prose is not, so the turn renders as normal visible prose and the viewport stays pinned at the bottom. Behavioral + structural tests in tests/test_pure_prose_turn_not_worklog.py. --- static/messages.js | 26 +++- static/ui.js | 21 +++ tests/test_pure_prose_turn_not_worklog.py | 150 ++++++++++++++++++++++ 3 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 tests/test_pure_prose_turn_not_worklog.py diff --git a/static/messages.js b/static/messages.js index d666030783f..398118b4dbe 100644 --- a/static/messages.js +++ b/static/messages.js @@ -3403,6 +3403,29 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } } } + function _anchorSceneHasWorklogWorthyRows(scene){ + // A worklog (the collapsible "已处理 …" rail) is only meaningful when the turn + // actually DID worklog-worthy work — a tool call, a thinking/reasoning pass, or + // a compression lifecycle card. A turn that only streamed prose (e.g. a long + // plain-text answer, or a degeneration burst that flooded the body with repeated + // tokens) projects an activity scene whose rows are ALL `prose`/`terminal`. Folding + // such a turn into a collapsed worklog hides the whole answer and, at STREAM_DONE, + // shrinks the transcript by the full streamed height → the browser clamps a + // bottom-pinned viewport back to the top (the "jump back" report). Require at least + // one genuinely worklog-worthy row before promoting the turn to a worklog. + const rows=Array.isArray(scene&&scene.activity_rows)?scene.activity_rows:[]; + for(const row of rows){ + if(!row||typeof row!=='object') continue; + const role=String(row.role||''); + if(role==='tool'||role==='thinking') return true; + if(role==='lifecycle'){ + const source=String(row.source_event_type||''); + // compression cards are worklog-worthy; a bare terminal/done lifecycle is not. + if(source==='compressing'||source==='compressed') return true; + } + } + return false; + } function _attachProjectedAnchorSceneToLastAssistant(messages){ if(!_anchorRegistry||!Array.isArray(messages)) return false; let lastAsst=null; @@ -3418,7 +3441,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(!lastAsst) return false; const projectedScene=_projectLiveAnchorActivityScene(); const scene=_completeSettledAnchorSceneForTurn(messages,lastAsstIndex,projectedScene); - if(scene&&Array.isArray(scene.activity_rows)&&scene.activity_rows.length){ + if(scene&&Array.isArray(scene.activity_rows)&&scene.activity_rows.length + &&_anchorSceneHasWorklogWorthyRows(scene)){ lastAsst._anchor_stream_id=streamId; lastAsst._anchor_activity_scene=scene; _persistSettledAnchorScene(lastAsst, scene, lastAsstIndex); diff --git a/static/ui.js b/static/ui.js index 31e53b368e9..5ddbc453cd5 100644 --- a/static/ui.js +++ b/static/ui.js @@ -10330,8 +10330,28 @@ if(typeof window!=='undefined'){ window._projectLiveAnchorActivitySceneForStream=_projectLiveAnchorActivitySceneForStream; window.isLiveAnchorActivitySceneOwner=isLiveAnchorActivitySceneOwner; } +function _anchorSceneSceneHasWorklogWorthyRows(scene){ + // Mirror of messages.js _anchorSceneHasWorklogWorthyRows for the RENDER side: + // a settled scene that was persisted (or hydrated from the backend) before the + // generation-side guard existed can still be all-prose. Such a scene must NOT be + // promoted to a collapsed worklog at render time (it would hide the whole answer + // and shrink the transcript at settle → bottom-pinned jump-back). Require at least + // one tool/thinking/compression row. (defense-in-depth for already-persisted scenes) + const rows=Array.isArray(scene&&scene.activity_rows)?scene.activity_rows:[]; + for(const row of rows){ + if(!row||typeof row!=='object') continue; + const role=String(row.role||''); + if(role==='tool'||role==='thinking') return true; + if(role==='lifecycle'){ + const source=String(row.source_event_type||''); + if(source==='compressing'||source==='compressed') return true; + } + } + return false; +} function _renderSettledAnchorSceneTransparentForMessage(message, segment, rawIdx){ if(!message||!message._anchor_activity_scene||!segment) return false; + if(!_anchorSceneSceneHasWorklogWorthyRows(message._anchor_activity_scene)) return false; const blocks=_assistantTurnBlocks(segment.closest('.assistant-turn')); if(!blocks) return false; const scene=message._anchor_activity_scene; @@ -10409,6 +10429,7 @@ if(typeof window!=='undefined'){ } function _renderSettledAnchorSceneForMessage(message, segment, rawIdx){ if(!message||!message._anchor_activity_scene||!segment) return false; + if(!_anchorSceneSceneHasWorklogWorthyRows(message._anchor_activity_scene)) return false; if(typeof isTransparentStream==='function'&&isTransparentStream()){ return _renderSettledAnchorSceneTransparentForMessage(message,segment,rawIdx); } diff --git a/tests/test_pure_prose_turn_not_worklog.py b/tests/test_pure_prose_turn_not_worklog.py new file mode 100644 index 00000000000..c508a78ba22 --- /dev/null +++ b/tests/test_pure_prose_turn_not_worklog.py @@ -0,0 +1,150 @@ +"""Regression locks: a pure-prose assistant turn must NOT become a collapsed worklog. + +Follow-up to the #4970/#5058 stream-end worklog collapse jump. Root cause of a +recurring "jump back" report: a turn that streamed ONLY prose (a long plain-text +answer, or a degeneration burst that floods the body with repeated tokens) still +projected an anchor activity scene whose `activity_rows` were all `prose`/`terminal` +— zero tool/thinking rows. The settle path promoted it to a collapsed worklog +anyway (the gate only checked `activity_rows.length`), hiding the whole answer and +shrinking the transcript by the full streamed height at STREAM_DONE → the browser +clamps a bottom-pinned viewport back to the top. + +The fix adds a worklog-worthiness predicate at BOTH the generation gate +(`_anchorSceneHasWorklogWorthyRows` in messages.js, decides whether to attach a +scene at all) and the render gate (`_anchorSceneSceneHasWorklogWorthyRows` in +ui.js, defense-in-depth for already-persisted all-prose scenes). A scene is +worklog-worthy only if it has >=1 tool/thinking row or a compression lifecycle +card; pure prose is not. + +These tests are BEHAVIORAL: they extract the real predicate functions from the +static JS and execute them in Node against representative scenes. +""" +import json +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") +MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8") + + +def _function_body(src: str, name: str) -> str: + marker = f"function {name}" + start = src.index(marker) + brace = src.index("{", start) + depth = 0 + for idx in range(brace, len(src)): + ch = src[idx] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return src[brace + 1 : idx] + raise AssertionError(f"function {name} body not found") + + +def _extract(src: str, name: str) -> str: + marker = f"function {name}" + start = src.index(marker) + body = _function_body(src, name) + sig = src[start : src.index("{", start)] + return f"{sig}{{{body}}}" + + +def test_gates_call_worklog_worthy_predicate(): + # Structural lock: BOTH the generation gate and the render gates must be + # guarded by a worklog-worthiness predicate, not just `activity_rows.length`. + # (Counting only the helper DEFINITION is the orphan-definition trap — assert + # the CALL SITES too.) + attach_fn = _function_body(MESSAGES_JS, "_attachProjectedAnchorSceneToLastAssistant") + assert "_anchorSceneHasWorklogWorthyRows(scene)" in attach_fn, ( + "generation gate must require a worklog-worthy scene before attaching" + ) + render_fn = _function_body(UI_JS, "_renderSettledAnchorSceneForMessage") + assert "_anchorSceneSceneHasWorklogWorthyRows(message._anchor_activity_scene)" in render_fn, ( + "compact render gate must reject an all-prose persisted scene" + ) + transparent_fn = _function_body(UI_JS, "_renderSettledAnchorSceneTransparentForMessage") + assert "_anchorSceneSceneHasWorklogWorthyRows(message._anchor_activity_scene)" in transparent_fn, ( + "transparent render gate must reject an all-prose persisted scene" + ) + # The predicate definitions exist on both sides. + assert "function _anchorSceneHasWorklogWorthyRows" in MESSAGES_JS + assert "function _anchorSceneSceneHasWorklogWorthyRows" in UI_JS + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test") +def test_pure_prose_scene_is_not_worklog_worthy(): + """Pure-prose scene → false; tool/thinking/compression scene → true.""" + predicate = _extract(UI_JS, "_anchorSceneSceneHasWorklogWorthyRows") + harness = textwrap.dedent(f""" + {predicate} + const out = {{}}; + // (1) The exact shape that caused the jump: long prose flood + a terminal/done row. + out.pure_prose = _anchorSceneSceneHasWorklogWorthyRows({{ + activity_rows: [ + {{ role: 'prose', source_event_type: 'token', text: 'call\\ncall\\ncall' }}, + {{ role: 'terminal', source_event_type: 'done', text: '' }}, + ] + }}); // expect false + // (2) A real worklog: has a tool row. + out.with_tool = _anchorSceneSceneHasWorklogWorthyRows({{ + activity_rows: [ + {{ role: 'prose', text: 'let me check' }}, + {{ role: 'tool', name: 'terminal', text: '' }}, + ] + }}); // expect true + // (3) A reasoning pass is worklog-worthy. + out.with_thinking = _anchorSceneSceneHasWorklogWorthyRows({{ + activity_rows: [ {{ role: 'thinking', text: 'reasoning...' }} ] + }}); // expect true + // (4) A compression lifecycle card is worklog-worthy. + out.with_compression = _anchorSceneSceneHasWorklogWorthyRows({{ + activity_rows: [ {{ role: 'lifecycle', source_event_type: 'compressed', text: '' }} ] + }}); // expect true + // (5) A bare terminal/done lifecycle is NOT worklog-worthy. + out.bare_lifecycle = _anchorSceneSceneHasWorklogWorthyRows({{ + activity_rows: [ {{ role: 'lifecycle', source_event_type: 'done', text: '' }} ] + }}); // expect false + // (6) Empty / missing rows → false (no worklog for nothing). + out.empty = _anchorSceneSceneHasWorklogWorthyRows({{ activity_rows: [] }}); // false + out.no_scene = _anchorSceneSceneHasWorklogWorthyRows(null); // false + console.log(JSON.stringify(out)); + """) + res = subprocess.run(["node", "-e", harness], capture_output=True, text=True, timeout=30) + assert res.returncode == 0, res.stderr + out = json.loads(res.stdout.strip()) + assert out["pure_prose"] is False, "pure-prose turn must NOT be promoted to a collapsed worklog" + assert out["with_tool"] is True, "a turn with a tool row is a real worklog" + assert out["with_thinking"] is True, "a turn with a thinking row is a real worklog" + assert out["with_compression"] is True, "a compression lifecycle card is worklog-worthy" + assert out["bare_lifecycle"] is False, "a bare terminal/done lifecycle is not worklog-worthy" + assert out["empty"] is False + assert out["no_scene"] is False + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node required for behavioral test") +def test_generation_side_predicate_matches_render_side(): + """The messages.js generation predicate must classify identically to ui.js.""" + gen = _extract(MESSAGES_JS, "_anchorSceneHasWorklogWorthyRows") + harness = textwrap.dedent(f""" + {gen} + const out = {{}}; + out.pure_prose = _anchorSceneHasWorklogWorthyRows({{ + activity_rows: [ {{ role: 'prose', text: 'call\\ncall' }}, {{ role: 'terminal', source_event_type: 'done' }} ] + }}); + out.with_tool = _anchorSceneHasWorklogWorthyRows({{ + activity_rows: [ {{ role: 'tool', name: 'x' }} ] + }}); + console.log(JSON.stringify(out)); + """) + res = subprocess.run(["node", "-e", harness], capture_output=True, text=True, timeout=30) + assert res.returncode == 0, res.stderr + out = json.loads(res.stdout.strip()) + assert out["pure_prose"] is False + assert out["with_tool"] is True