fix(chat): do not collapse a pure-prose turn into a worklog (stream-done jump)

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.
This commit is contained in:
allenliang2022 authored and nesquena-hermes committed 2026-06-29 17:06:45 +00:00
1 parent 2aa1d168b0
commit 955e31b9a1
3 files changed
+196 -1

No files matched your search

+25 -1
View File
@@ -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);
+21
View File
@@ -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);
}
+150
View File
@@ -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