#5390: don't preserve dead empty live-turn shell across DOM wipe (blank assistant turn), rebased on master
This commit is contained in:
1 parent
4e61f9a8ef
commit
da03745b79
2 files changed
+131
-1
No files matched your search
+18
-1
@@ -12566,7 +12566,24 @@ function renderMessages(options){
|
||||
if(sid&&INFLIGHT[sid]){
|
||||
const _lt=document.getElementById('liveAssistantTurn');
|
||||
if(_lt&&(!_lt.dataset||!_lt.dataset.sessionId||_lt.dataset.sessionId===sid)){
|
||||
_preservedLiveTurn=_lt;
|
||||
// Blank-turn fix (对话消失): only preserve the live turn across the DOM
|
||||
// wipe if it is GENUINELY live — either an active stream is still running
|
||||
// (S.activeStreamId set: the #3877 mid-stream flicker case this preserve
|
||||
// was written for), or the turn already holds real rendered content (a
|
||||
// visible answer body, a tool card, or a reasoning row). A DEAD shell —
|
||||
// an interrupted turn whose stream dropped (S.activeStreamId cleared to
|
||||
// null) but whose INFLIGHT[sid] entry was not cleaned, leaving only an
|
||||
// empty worklog group ("Processed Ns" with no body/tool rows) — must NOT
|
||||
// be preserved: re-attaching it on a session-updated swap re-render pins
|
||||
// an avatar-only empty turn OVER the settled transcript, hiding the real
|
||||
// (already-persisted) answer. That is the reported blank. Reproduced +
|
||||
// fix verified on an isolated debug instance (8710): stale INFLIGHT +
|
||||
// empty live-turn survived the swap → blank; gating on real-content /
|
||||
// active-stream clears it while a genuine live turn still renders.
|
||||
const _hasRealLiveContent=!!_lt.querySelector('.msg-body, .tool-card-row, .wl-reason');
|
||||
if(_hasRealLiveContent || S.activeStreamId){
|
||||
_preservedLiveTurn=_lt;
|
||||
}
|
||||
}
|
||||
}
|
||||
const compressionState=(()=>{
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Regression: blank assistant turn (对话消失) — dead empty live-turn shell
|
||||
survives a session-updated swap re-render and hides the settled answer.
|
||||
|
||||
Root cause (reproduced + fixed on an isolated debug instance, 2026-07-01)
|
||||
------------------------------------------------------------------------
|
||||
`renderMessages()` (static/ui.js) preserves the `#liveAssistantTurn` DOM node
|
||||
across the `inner.innerHTML=''` wipe so the smd parser's live reference is not
|
||||
detached mid-stream (#3877 flicker fix). The preserve guard originally fired
|
||||
whenever `INFLIGHT[sid]` existed:
|
||||
|
||||
let _preservedLiveTurn=null;
|
||||
if(sid&&INFLIGHT[sid]){
|
||||
const _lt=document.getElementById('liveAssistantTurn');
|
||||
if(_lt&&(...sessionId matches...)){ _preservedLiveTurn=_lt; }
|
||||
}
|
||||
|
||||
When a turn's SSE dropped (S.activeStreamId cleared to null) but its
|
||||
`INFLIGHT[sid]` entry was NOT cleaned, the live turn was a DEAD empty shell —
|
||||
avatar + an empty worklog group ("Processed Ns", no body/tool rows). On the
|
||||
next `session-updated` self-heal swap (loadSession force + keepStaleUntilLoaded,
|
||||
common under repeated self-wake restarts), the guard re-attached that empty
|
||||
shell OVER the freshly-wiped transcript, pinning an avatar-only blank turn on
|
||||
top of the already-persisted answer. That is the reported "对话消失".
|
||||
|
||||
Fix
|
||||
---
|
||||
Preserve the live turn ONLY when it is genuinely live: an active stream is
|
||||
still running (`S.activeStreamId`) — the #3877 case — OR it already holds real
|
||||
rendered content (`.msg-body`, `.tool-card-row`, or `.wl-reason`). A dead empty
|
||||
shell (no content, no active stream) is no longer preserved, so the swap wipe
|
||||
drops it and the settled transcript renders normally.
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import textwrap
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
|
||||
|
||||
def read(rel):
|
||||
return (REPO / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _preserve_guard_src():
|
||||
src = read("static/ui.js")
|
||||
i = src.find("let _preservedLiveTurn=null;")
|
||||
assert i >= 0, "_preservedLiveTurn guard not found"
|
||||
# capture through the closing of the if-block (next 'const compressionState')
|
||||
j = src.find("const compressionState", i)
|
||||
assert j > i, "guard block end not found"
|
||||
return src[i:j]
|
||||
|
||||
|
||||
class TestBlankLiveTurnPreserveGuard:
|
||||
def test_guard_requires_real_content_or_active_stream(self):
|
||||
guard = _preserve_guard_src()
|
||||
# Must gate the preserve on real content OR an active stream — not merely
|
||||
# on INFLIGHT existence.
|
||||
assert "_hasRealLiveContent" in guard, (
|
||||
"preserve guard must compute whether the live turn has real content"
|
||||
)
|
||||
assert ".msg-body" in guard and ".tool-card-row" in guard and ".wl-reason" in guard, (
|
||||
"real-content check must look for a visible body / tool card / reason row"
|
||||
)
|
||||
assert "S.activeStreamId" in guard, (
|
||||
"preserve guard must still preserve a genuinely-streaming turn (#3877)"
|
||||
)
|
||||
# The assignment must be inside the new conditional.
|
||||
assert re.search(
|
||||
r"if\(_hasRealLiveContent\s*\|\|\s*S\.activeStreamId\)\{\s*_preservedLiveTurn=_lt;",
|
||||
guard,
|
||||
), "preserve assignment must be gated by (hasRealContent || activeStreamId)"
|
||||
|
||||
def test_runtime_rejects_dead_shell_preserves_live(self):
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
import pytest
|
||||
pytest.skip("node not available")
|
||||
script = textwrap.dedent(
|
||||
"""
|
||||
const assert=require('assert');
|
||||
// Mirror the guard's decision predicate exactly.
|
||||
function guardWouldPreserve(lt, activeStreamId){
|
||||
if(!lt) return false;
|
||||
const hasReal=!!lt.querySelector('.msg-body, .tool-card-row, .wl-reason');
|
||||
return hasReal || !!activeStreamId;
|
||||
}
|
||||
// Minimal DOM element stub with querySelector over a class set.
|
||||
function el(classes){
|
||||
const set=new Set(classes||[]);
|
||||
return { querySelector(sel){
|
||||
// sel is a comma list of .class tokens
|
||||
return sel.split(',').map(s=>s.trim().replace(/^\\./,''))
|
||||
.some(c=>set.has(c)) ? {} : null;
|
||||
}};
|
||||
}
|
||||
const deadShell = el([]); // empty worklog shell, no content
|
||||
const withBody = el(['msg-body']);
|
||||
const withTool = el(['tool-card-row']);
|
||||
const withReason= el(['wl-reason']);
|
||||
assert.strictEqual(guardWouldPreserve(deadShell, null), false, 'dead shell must NOT be preserved');
|
||||
assert.strictEqual(guardWouldPreserve(deadShell, 'sid'), true, 'streaming empty shell preserved (#3877)');
|
||||
assert.strictEqual(guardWouldPreserve(withBody, null), true, 'body content preserved');
|
||||
assert.strictEqual(guardWouldPreserve(withTool, null), true, 'tool card preserved');
|
||||
assert.strictEqual(guardWouldPreserve(withReason, null), true, 'reason row preserved');
|
||||
console.log('OK');
|
||||
"""
|
||||
)
|
||||
out = subprocess.run([node, "-e", script], capture_output=True, text=True)
|
||||
assert out.returncode == 0, f"node harness failed: {out.stderr}\n{out.stdout}"
|
||||
assert "OK" in out.stdout
|
||||
Reference in new issue
Block a user