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){
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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