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:
1 parent
2aa1d168b0
commit
955e31b9a1
3 files changed
+196
-1
No files matched your search
+25
-1
@@ -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){
|
function _attachProjectedAnchorSceneToLastAssistant(messages){
|
||||||
if(!_anchorRegistry||!Array.isArray(messages)) return false;
|
if(!_anchorRegistry||!Array.isArray(messages)) return false;
|
||||||
let lastAsst=null;
|
let lastAsst=null;
|
||||||
@@ -3418,7 +3441,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
|||||||
if(!lastAsst) return false;
|
if(!lastAsst) return false;
|
||||||
const projectedScene=_projectLiveAnchorActivityScene();
|
const projectedScene=_projectLiveAnchorActivityScene();
|
||||||
const scene=_completeSettledAnchorSceneForTurn(messages,lastAsstIndex,projectedScene);
|
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_stream_id=streamId;
|
||||||
lastAsst._anchor_activity_scene=scene;
|
lastAsst._anchor_activity_scene=scene;
|
||||||
_persistSettledAnchorScene(lastAsst, scene, lastAsstIndex);
|
_persistSettledAnchorScene(lastAsst, scene, lastAsstIndex);
|
||||||
|
|||||||
@@ -10330,8 +10330,28 @@ if(typeof window!=='undefined'){
|
|||||||
window._projectLiveAnchorActivitySceneForStream=_projectLiveAnchorActivitySceneForStream;
|
window._projectLiveAnchorActivitySceneForStream=_projectLiveAnchorActivitySceneForStream;
|
||||||
window.isLiveAnchorActivitySceneOwner=isLiveAnchorActivitySceneOwner;
|
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){
|
function _renderSettledAnchorSceneTransparentForMessage(message, segment, rawIdx){
|
||||||
if(!message||!message._anchor_activity_scene||!segment) return false;
|
if(!message||!message._anchor_activity_scene||!segment) return false;
|
||||||
|
if(!_anchorSceneSceneHasWorklogWorthyRows(message._anchor_activity_scene)) return false;
|
||||||
const blocks=_assistantTurnBlocks(segment.closest('.assistant-turn'));
|
const blocks=_assistantTurnBlocks(segment.closest('.assistant-turn'));
|
||||||
if(!blocks) return false;
|
if(!blocks) return false;
|
||||||
const scene=message._anchor_activity_scene;
|
const scene=message._anchor_activity_scene;
|
||||||
@@ -10409,6 +10429,7 @@ if(typeof window!=='undefined'){
|
|||||||
}
|
}
|
||||||
function _renderSettledAnchorSceneForMessage(message, segment, rawIdx){
|
function _renderSettledAnchorSceneForMessage(message, segment, rawIdx){
|
||||||
if(!message||!message._anchor_activity_scene||!segment) return false;
|
if(!message||!message._anchor_activity_scene||!segment) return false;
|
||||||
|
if(!_anchorSceneSceneHasWorklogWorthyRows(message._anchor_activity_scene)) return false;
|
||||||
if(typeof isTransparentStream==='function'&&isTransparentStream()){
|
if(typeof isTransparentStream==='function'&&isTransparentStream()){
|
||||||
return _renderSettledAnchorSceneTransparentForMessage(message,segment,rawIdx);
|
return _renderSettledAnchorSceneTransparentForMessage(message,segment,rawIdx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user