* perf(media): preload visible chat videos * fix(media): ignore detached visibility callbacks * fix(ui): require provable live owner for live-turn preservation (#6948) A completed assistant turn could render twice: when the stream ended (S.activeStreamId cleared) but INFLIGHT[sid] was not yet cleaned, the preserve guard treated bare rendered content (.msg-body presence) as proof of liveness and re-attached the stale live DOM node OVER the rebuilt settled transcript — a second copy of the same message. Data was always clean (state.db, sidecar, /api/session each hold one row); the duplicate existed only in the rendered DOM. Preserve the live turn across the wipe only while there is a provable live owner: an active stream (S.activeStreamId, the #3877 mid-stream case) or explicit live-assistant evidence in the current message projection (S.messages carrying a client-side _live / _activityBurstId / _liveSegmentSeq marker — the reconnect/terminal-projection case). A settled transcript has neither, so the durable settled render wins and appears exactly once. The #5390 blank-turn guard (对话消失) is preserved: a dead empty shell has no live projection either and is still dropped. * changelog: exp batch — #7109 double-render fix + #7076 video preload --------- Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com> Co-authored-by: webtecnica <webtecnica@gmail.com> Co-authored-by: n <a@n> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
264 lines
15 KiB
Python
264 lines
15 KiB
Python
"""Regression: blank assistant turn (对话消失) AND duplicate settled render (#6948)
|
|
— the live-turn preserve guard must require a PROVABLE live owner.
|
|
|
|
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 "对话消失".
|
|
|
|
First fix (#5390)
|
|
-----------------
|
|
Gate preservation on "real rendered content OR an active stream":
|
|
|
|
const _hasRealLiveContent=!!_lt.querySelector(
|
|
'.msg-body, .tool-card-row, .wl-reason'
|
|
);
|
|
if(_hasRealLiveContent || S.activeStreamId){ _preservedLiveTurn=_lt; }
|
|
|
|
That stopped the EMPTY shell but made rendered content itself act as authority.
|
|
|
|
Second bug (#6948)
|
|
------------------
|
|
After an assistant turn COMPLETES, the same message can render twice in the
|
|
feed (first copy without model label, second with it). Data was always clean —
|
|
state.db, the sidecar, and /api/session each hold one row; the duplicate is a
|
|
rendering artifact: a stale live-turn DOM node survives the settled-transcript
|
|
swap and is re-attached on top of it. When the stream has ended (S.activeStreamId
|
|
nulled) but `INFLIGHT[sid]` has not been cleaned yet, `_hasRealLiveContent` is
|
|
still true (the completed body is in the DOM), so the DEAD live turn is
|
|
preserved and re-attached OVER the settled transcript — two copies.
|
|
|
|
Final contract (this file)
|
|
--------------------------
|
|
Preservation requires a PROVABLE live owner: current-session ownership, an
|
|
`INFLIGHT` owner, and EITHER an active stream (`S.activeStreamId`) OR explicit
|
|
live-assistant evidence in the current message projection (`S.messages` — a
|
|
client-side `_live` / `_activityBurstId` / `_liveSegmentSeq` marker merged in
|
|
from the INFLIGHT tail or a server journal snapshot, i.e. the reconnect /
|
|
terminal-projection case). Bare `.msg-body` presence never proves liveness: a
|
|
settled transcript has no live projection, so the durable settled transcript
|
|
wins once no live owner remains. Both reported regressions are covered: the
|
|
dead EMPTY shell (no projection → dropped) and the dead CONTENTFUL turn
|
|
(no projection → dropped, settled transcript renders exactly once).
|
|
"""
|
|
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_live_owner_not_dom_content(self):
|
|
guard = _preserve_guard_src()
|
|
# Must gate the preserve on a PROVABLE live owner — an active stream or
|
|
# explicit live-assistant evidence in the message projection — never on
|
|
# bare DOM content presence. (#6948)
|
|
assert "S.activeStreamId" in guard, (
|
|
"preserve guard must still preserve a genuinely-streaming turn (#3877)"
|
|
)
|
|
assert "_hasLiveAssistantProjection" in guard, (
|
|
"preserve guard must consult the current message projection for "
|
|
"live-assistant evidence"
|
|
)
|
|
assert "S.messages" in guard, (
|
|
"live-assistant evidence must come from the message projection"
|
|
)
|
|
# The projection check must look at assistant-role messages carrying a
|
|
# client-side live marker (_live / _activityBurstId / _liveSegmentSeq).
|
|
assert "role==='assistant'" in guard, (
|
|
"live-assistant evidence must be scoped to assistant messages"
|
|
)
|
|
assert "m._live" in guard and "_activityBurstId" in guard and "_liveSegmentSeq" in guard, (
|
|
"live-assistant evidence must accept the client-side live markers"
|
|
)
|
|
# The assignment must be inside the new conditional.
|
|
assert re.search(
|
|
r"if\(S\.activeStreamId\s*\|\|\s*_hasLiveAssistantProjection\)\{\s*_preservedLiveTurn=_lt;",
|
|
guard,
|
|
), "preserve assignment must be gated by (activeStreamId || live projection)"
|
|
# DOM-content presence must NOT act as authority (#6948 regression guard).
|
|
assert "_hasRealLiveContent" not in guard, (
|
|
"bare .msg-body presence must not prove liveness — a contentful dead "
|
|
"live turn re-attached over the settled transcript is the #6948 duplicate"
|
|
)
|
|
|
|
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: current-session
|
|
// ownership + INFLIGHT owner + (active stream || live projection).
|
|
function guardWouldPreserve(lt, activeStreamId, messages, inflightOwner, sessionId){
|
|
if(!inflightOwner) return false; // no-owner rejected
|
|
if(!lt) return false;
|
|
if(lt.dataset&<.dataset.sessionId&<.dataset.sessionId!==sessionId){
|
|
return false; // wrong-session rejected
|
|
}
|
|
const hasLiveProjection=Array.isArray(messages)&&messages.some(m=>
|
|
m&&m.role==='assistant'&&(m._live||m._activityBurstId!==undefined||m._liveSegmentSeq!==undefined)
|
|
);
|
|
return !!activeStreamId || hasLiveProjection;
|
|
}
|
|
// Minimal DOM element stub with querySelector over a class set.
|
|
function el(classes, dataset){
|
|
const set=new Set(classes||[]);
|
|
return {
|
|
dataset: dataset||null,
|
|
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 settledMessages=[]; // no live markers
|
|
const liveMessages=[{role:'assistant',content:'answer',_live:true}];
|
|
const burstLiveMessages=[{role:'assistant',content:'x',_activityBurstId:3}];
|
|
const segLiveMessages=[{role:'assistant',content:'x',_liveSegmentSeq:1}];
|
|
const deadShell = el([]); // empty worklog shell, no content
|
|
const withBody = el(['msg-body']); // contentful (completed) turn
|
|
const wrongSess = el(['msg-body'], {sessionId:'other-sid'});
|
|
// #5390: dead empty shell must NOT be preserved (settled or live projection).
|
|
assert.strictEqual(guardWouldPreserve(deadShell, null, settledMessages, true, 's1'), false, 'dead shell must NOT be preserved');
|
|
// #3877: streaming shell IS preserved regardless of projection.
|
|
assert.strictEqual(guardWouldPreserve(deadShell, 'sid', settledMessages, true, 's1'), true, 'streaming empty shell preserved (#3877)');
|
|
// #6948: a CONTENTFUL dead live node (stream ended, stale INFLIGHT,
|
|
// settled projection) must NOT be preserved — this is the duplicate.
|
|
assert.strictEqual(guardWouldPreserve(withBody, null, settledMessages, true, 's1'), false, 'contentful dead turn must NOT be preserved (#6948)');
|
|
// Reconnect: contentful DOM + explicit live projection IS preserved.
|
|
assert.strictEqual(guardWouldPreserve(withBody, null, liveMessages, true, 's1'), true, 'reconnect live projection preserved');
|
|
assert.strictEqual(guardWouldPreserve(withBody, 'sid', settledMessages, true, 's1'), true, 'active stream preserved');
|
|
// The projection markers are accepted as live-assistant evidence.
|
|
assert.strictEqual(guardWouldPreserve(withBody, null, burstLiveMessages, true, 's1'), true, '_activityBurstId projection preserved');
|
|
assert.strictEqual(guardWouldPreserve(withBody, null, segLiveMessages, true, 's1'), true, '_liveSegmentSeq projection preserved');
|
|
// Ownership gates: wrong-session DOM and no-owner are always rejected.
|
|
assert.strictEqual(guardWouldPreserve(wrongSess, 'sid', liveMessages, true, 's1'), false, 'wrong-session DOM rejected');
|
|
assert.strictEqual(guardWouldPreserve(withBody, 'sid', liveMessages, false, 's1'), false, 'no-owner (no INFLIGHT) rejected');
|
|
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
|
|
|
|
def test_settled_transcript_with_stale_inflight_renders_once(self):
|
|
"""#6948 browser-lifecycle equivalent in the node harness: the full
|
|
renderMessages decision — capture guard + wipe + rebuild — must yield
|
|
exactly ONE assistant row when a contentful live DOM node exists but
|
|
the projection is settled (stream ended, INFLIGHT[sid] stale)."""
|
|
node = shutil.which("node")
|
|
if not node:
|
|
import pytest
|
|
pytest.skip("node not available")
|
|
script = textwrap.dedent(
|
|
"""
|
|
const assert=require('assert');
|
|
// Faithful mirror of the renderMessages preserve decision + the
|
|
// re-attach step. Semantics of the real code (static/ui.js):
|
|
// - the rebuilt DOM renders settled assistant rows from the
|
|
// projection; a live-projection assistant renders as the
|
|
// rebuilt live turn;
|
|
// - when the guard captured the live node it REPLACES the rebuilt
|
|
// live row (segment/whole-turn swap) — never appends on top of a
|
|
// row that already exists in the projection;
|
|
// - mid-stream the in-progress turn is NOT yet in the projection,
|
|
// so the preserved node appends as the live tail (one live row);
|
|
// - when the guard did NOT capture (settled + stale INFLIGHT), the
|
|
// rebuilt settled rows stand alone — exactly one copy.
|
|
function renderMessagesSim(lt, activeStreamId, messages, inflightOwner){
|
|
let preservedLiveTurn=null;
|
|
const sid='s1';
|
|
if(sid&&inflightOwner){
|
|
if(lt&&(!lt.dataset||!lt.dataset.sessionId||lt.dataset.sessionId===sid)){
|
|
const hasLiveProjection=Array.isArray(messages)&&messages.some(m=>
|
|
m&&m.role==='assistant'&&(m._live||m._activityBurstId!==undefined||m._liveSegmentSeq!==undefined)
|
|
);
|
|
if(activeStreamId || hasLiveProjection){ preservedLiveTurn=lt; }
|
|
}
|
|
}
|
|
const liveMark=m=>m&&m.role==='assistant'&&(m._live||m._activityBurstId!==undefined||m._liveSegmentSeq!==undefined);
|
|
const hasLiveRow=Array.isArray(messages)&&messages.some(liveMark);
|
|
const finalRows=(messages||[]).filter(m=>m&&m.role==='assistant'&&!liveMark(m));
|
|
if(preservedLiveTurn){
|
|
// Swap-in replaces the rebuilt live row when one exists; the
|
|
// mid-stream case appends the live tail (turn not yet in the
|
|
// projection). Either way: one row for the turn.
|
|
finalRows.push({role:'assistant',_preserved:true});
|
|
}else if(hasLiveRow){
|
|
finalRows.push({role:'assistant',_rebuiltLive:true});
|
|
}
|
|
return finalRows;
|
|
}
|
|
const settled=[{role:'user',content:'hi'},{role:'assistant',content:'final answer'}];
|
|
const midStreamMessages=[{role:'user',content:'hi'}]; // turn not yet persisted
|
|
const liveTail=[{role:'user',content:'hi'},{role:'assistant',content:'partial',_live:true}];
|
|
const contentfulLiveNode={dataset:{sessionId:'s1'},querySelector:()=>({})};
|
|
// The bug (#6948): stale INFLIGHT + settled projection + contentful
|
|
// dead node → TWO rows before the fix; exactly ONE after.
|
|
assert.strictEqual(
|
|
renderMessagesSim(contentfulLiveNode, null, settled, true).length, 1,
|
|
'settled transcript must render exactly once despite stale INFLIGHT + contentful DOM (#6948)'
|
|
);
|
|
// Mid-stream: active stream keeps the live node attached (one live row).
|
|
assert.strictEqual(
|
|
renderMessagesSim(contentfulLiveNode, 'sid', midStreamMessages, true).length, 1,
|
|
'mid-stream render must keep exactly one live row'
|
|
);
|
|
// Reconnect: explicit live projection keeps the live node (one row).
|
|
assert.strictEqual(
|
|
renderMessagesSim(contentfulLiveNode, null, liveTail, true).length, 1,
|
|
'reconnect with live projection must keep exactly one live row'
|
|
);
|
|
// Rapid turn completion: a second settled turn cannot be duplicated.
|
|
const twoTurns=[
|
|
{role:'user',content:'a'},{role:'assistant',content:'answer one'},
|
|
{role:'user',content:'b'},{role:'assistant',content:'answer two'},
|
|
];
|
|
assert.strictEqual(
|
|
renderMessagesSim(contentfulLiveNode, null, twoTurns, true).length, 2,
|
|
'rapid turn completion must not duplicate settled assistant rows'
|
|
);
|
|
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
|