Batch release — double-render fix (#7109) + inline-video preload (#7076) (#7113)

* 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>
This commit is contained in:
authored and GitHub committed 2026-08-18 00:11:28 +00:00
1 parent 69d5647b60
commit 83e4903274
4 files changed
+400 -57

No files matched your search

+4
View File
@@ -5,6 +5,10 @@
### Fixed ### Fixed
- **A completed assistant turn no longer renders twice in the feed.** In `renderMessages()`, the live-turn preserve guard kept the in-flight assistant node across the DOM rebuild whenever it held any rendered content, so when a stream ended but its inflight bookkeeping was not yet cleared the just-completed turn was re-attached over the rebuilt settled transcript (a first copy without the model label, then a second with it). The stored data was always clean (state.db, sidecar, and `/api/session` each hold one row) — the duplicate existed only in the DOM. Preservation now requires a provable live owner (`S.activeStreamId` or explicit live-projection markers) instead of bare DOM content; the mid-stream flicker guard and blank-turn guard are preserved. Thanks @webtecnica. (#7109)
- **Visible inline chat videos no longer appear stalled after metadata.** Every inline video stayed at `preload="metadata"`, so a currently-visible mobile player could look frozen because the browser stops loading after metadata. A shared IntersectionObserver now promotes a near-viewport video to `preload="auto"` (and immediately on play), keeps off-screen history videos at metadata to avoid buffering every historical video, and unobserves nodes removed before they intersect. No media URL, caching, or controls change. Thanks @allenliang2022. (#7076)
- **Profile goal contract no longer drifts across profiles on older agent builds.** On Hermes Agent builds where the context-override API was exposed but `SessionDB()` still used the import-time default DB path (v2026.5.28–v2026.7.20), a goal mutation in one profile could overwrite another profile's goal. Native profile-goal persistence is now gated on the agent supporting *both* context-home resolution *and* call-time DB-path selection; otherwise it falls back to the legacy per-profile manager. Profiles stay isolated. Thanks @ticketclosed-won. (#6899) - **Profile goal contract no longer drifts across profiles on older agent builds.** On Hermes Agent builds where the context-override API was exposed but `SessionDB()` still used the import-time default DB path (v2026.5.28–v2026.7.20), a goal mutation in one profile could overwrite another profile's goal. Native profile-goal persistence is now gated on the agent supporting *both* context-home resolution *and* call-time DB-path selection; otherwise it falls back to the legacy per-profile manager. Profiles stay isolated. Thanks @ticketclosed-won. (#6899)
- **Packaged `hermes-webui` console entry point.** `pip install`-style wheel installs now expose a `hermes-webui` command that boots the WebUI through `bootstrap:main` — the same launcher path as `python server.py`, so agent-interpreter discovery and dependency validation run before the server starts (a direct `server:main` entry started the UI but broke every chat with a missing-dependency error). Thanks @webtecnica. (#6742) - **Packaged `hermes-webui` console entry point.** `pip install`-style wheel installs now expose a `hermes-webui` command that boots the WebUI through `bootstrap:main` — the same launcher path as `python server.py`, so agent-interpreter discovery and dependency validation run before the server starts (a direct `server:main` entry started the UI but broke every chat with a missing-dependency error). Thanks @webtecnica. (#6742)
+73 -18
View File
@@ -2586,6 +2586,37 @@ function _applyMediaPlaybackRate(media, rate=_getStoredMediaPlaybackRate()){
media.playbackRate=rate; media.playbackRate=rate;
_syncMediaSpeedButtons(media.closest('.msg-media-editor,.preview-media-wrap'),rate); _syncMediaSpeedButtons(media.closest('.msg-media-editor,.preview-media-wrap'),rate);
} }
let _mediaVisibilityObserver=null;
function _promoteVisibleVideoPreload(video){
if(!video||!video.matches||!video.matches('.msg-media-video')) return;
if(video.isConnected===false) return;
if(video.dataset&&video.dataset.visiblePreload==='1') return;
if(video.dataset) video.dataset.visiblePreload='1';
video.preload='auto';
// Off-screen history stays metadata-only. Once a card approaches the
// viewport, restart just that resource so Chromium fills a playable buffer.
if(video.paused&&video.readyState<4&&typeof video.load==='function') video.load();
}
function _observeVideoPreload(video){
if(!video||!video.matches||!video.matches('.msg-media-video')) return;
if(video.dataset&&video.dataset.visiblePreload==='1') return;
if(_mediaVisibilityObserver) _mediaVisibilityObserver.observe(video);
}
function _unobserveVideoPreload(video){
if(!video||!_mediaVisibilityObserver) return;
_mediaVisibilityObserver.unobserve(video);
}
function _initMediaVisibilityObserver(){
if(_mediaVisibilityObserver||typeof IntersectionObserver==='undefined') return;
_mediaVisibilityObserver=new IntersectionObserver(entries=>{
for(const entry of entries){
if(!entry.isIntersecting) continue;
const video=entry.target;
_unobserveVideoPreload(video);
_promoteVisibleVideoPreload(video);
}
},{root:null,rootMargin:'300px 0px',threshold:0.01});
}
function _mediaKindForName(name=''){ function _mediaKindForName(name=''){
const clean=String(name||'').split('?')[0].toLowerCase(); const clean=String(name||'').split('?')[0].toLowerCase();
if(_VIDEO_EXTS.test(clean)) return 'video'; if(_VIDEO_EXTS.test(clean)) return 'video';
@@ -2752,21 +2783,40 @@ document.addEventListener("loadedmetadata", e=>{
_applyMediaPlaybackRate(e.target); _applyMediaPlaybackRate(e.target);
} }
},true); },true);
document.addEventListener('play',e=>{
if(e.target&&e.target.matches&&e.target.matches('.msg-media-video')){
_promoteVisibleVideoPreload(e.target);
}
},true);
function _initMediaPlaybackObserver(){ function _initMediaPlaybackObserver(){
if(!document.body||window._mediaPlaybackObserver) return; if(!document.body||window._mediaPlaybackObserver) return;
_initMediaVisibilityObserver();
window._mediaPlaybackObserver=new MutationObserver(records=>{ window._mediaPlaybackObserver=new MutationObserver(records=>{
for(const rec of records){ for(const rec of records){
for(const node of rec.removedNodes||[]){
if(!node||node.nodeType!==1) continue;
const videos=[];
if(node.matches&&node.matches('.msg-media-video')) videos.push(node);
if(node.querySelectorAll) videos.push(...node.querySelectorAll('.msg-media-video'));
videos.forEach(_unobserveVideoPreload);
}
for(const node of rec.addedNodes||[]){ for(const node of rec.addedNodes||[]){
if(!node||node.nodeType!==1) continue; if(!node||node.nodeType!==1) continue;
const media=[]; const media=[];
if(node.matches&&node.matches('audio,video')) media.push(node); if(node.matches&&node.matches('audio,video')) media.push(node);
if(node.querySelectorAll) media.push(...node.querySelectorAll('audio,video')); if(node.querySelectorAll) media.push(...node.querySelectorAll('audio,video'));
media.forEach(m=>_applyMediaPlaybackRate(m)); media.forEach(m=>{
_applyMediaPlaybackRate(m);
_observeVideoPreload(m);
});
} }
} }
}); });
window._mediaPlaybackObserver.observe(document.body,{childList:true,subtree:true}); window._mediaPlaybackObserver.observe(document.body,{childList:true,subtree:true});
document.querySelectorAll('audio,video').forEach(m=>_applyMediaPlaybackRate(m)); document.querySelectorAll('audio,video').forEach(m=>{
_applyMediaPlaybackRate(m);
_observeVideoPreload(m);
});
} }
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',_initMediaPlaybackObserver); if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',_initMediaPlaybackObserver);
else _initMediaPlaybackObserver(); else _initMediaPlaybackObserver();
@@ -16539,22 +16589,27 @@ function renderMessages(options){
if(sid&&INFLIGHT[sid]){ if(sid&&INFLIGHT[sid]){
const _lt=document.getElementById('liveAssistantTurn'); const _lt=document.getElementById('liveAssistantTurn');
if(_lt&&(!_lt.dataset||!_lt.dataset.sessionId||_lt.dataset.sessionId===sid)){ if(_lt&&(!_lt.dataset||!_lt.dataset.sessionId||_lt.dataset.sessionId===sid)){
// Blank-turn fix (对话消失): only preserve the live turn across the DOM // Live-turn preservation requires a PROVABLE live owner — never bare DOM
// wipe if it is GENUINELY live — either an active stream is still running // content. (#6948) The live turn is preserved across the wipe only while
// (S.activeStreamId set: the #3877 mid-stream flicker case this preserve // (a) the stream is genuinely active (S.activeStreamId — the #3877
// was written for), or the turn already holds real rendered content (a // mid-stream flicker case this preserve was written for), or (b) the
// visible answer body, a tool card, or a reasoning row). A DEAD shell — // current message projection (S.messages) still carries explicit
// an interrupted turn whose stream dropped (S.activeStreamId cleared to // live-assistant evidence — a client-side _live / _activityBurstId /
// null) but whose INFLIGHT[sid] entry was not cleaned, leaving only an // _liveSegmentSeq marker merged in from the INFLIGHT tail or a server
// empty worklog group ("Processed Ns" with no body/tool rows) — must NOT // journal snapshot (the reconnect / terminal-projection case). A settled
// be preserved: re-attaching it on a session-updated swap re-render pins // transcript has neither, so a contentful but DEAD live node (stream
// an avatar-only empty turn OVER the settled transcript, hiding the real // ended — S.activeStreamId cleared — while INFLIGHT[sid] was not yet
// (already-persisted) answer. That is the reported blank. Reproduced + // cleaned) is no longer preserved: re-attaching it over the settled
// fix verified on an isolated debug instance (8710): stale INFLIGHT + // transcript pinned a second copy of the same assistant message (#6948;
// empty live-turn survived the swap → blank; gating on real-content / // data was always clean — state.db, sidecar, and /api/session each hold
// active-stream clears it while a genuine live turn still renders. // one row; the duplicate existed only in the rendered DOM). The #5390
const _hasRealLiveContent=!!_lt.querySelector('.msg-body, .tool-card-row, .wl-reason'); // blank-turn guard (对话消失) is preserved: a dead EMPTY shell has no live
if(_hasRealLiveContent || S.activeStreamId){ // projection either, so it is still dropped with the wipe instead of
// pinning an avatar-only blank turn over the settled answer.
const _hasLiveAssistantProjection=Array.isArray(S.messages)&&S.messages.some(m=>
m&&m.role==='assistant'&&(m._live||m._activityBurstId!==undefined||m._liveSegmentSeq!==undefined)
);
if(S.activeStreamId || _hasLiveAssistantProjection){
_preservedLiveTurn=_lt; _preservedLiveTurn=_lt;
} }
} }
+189 -39
View File
@@ -1,5 +1,5 @@
"""Regression: blank assistant turn (对话消失) — dead empty live-turn shell """Regression: blank assistant turn (对话消失) AND duplicate settled render (#6948)
survives a session-updated swap re-render and hides the settled answer. — the live-turn preserve guard must require a PROVABLE live owner.
Root cause (reproduced + fixed on an isolated debug instance, 2026-07-01) Root cause (reproduced + fixed on an isolated debug instance, 2026-07-01)
------------------------------------------------------------------------ ------------------------------------------------------------------------
@@ -15,20 +15,47 @@ whenever `INFLIGHT[sid]` existed:
} }
When a turn's SSE dropped (S.activeStreamId cleared to null) but its 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 — `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 avatar + an empty worklog group ("Processed Ns", no body/tool rows). On the
next `session-updated` self-heal swap (loadSession force + keepStaleUntilLoaded, next `session-updated` self-heal swap (loadSession force + keepStaleUntilLoaded,
common under repeated self-wake restarts), the guard re-attached that empty 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 shell OVER the freshly-wiped transcript, pinning an avatar-only blank turn on
top of the already-persisted answer. That is the reported "对话消失". top of the already-persisted answer. That is the reported "对话消失".
Fix First fix (#5390)
--- -----------------
Preserve the live turn ONLY when it is genuinely live: an active stream is Gate preservation on "real rendered content OR an active stream":
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 const _hasRealLiveContent=!!_lt.querySelector(
shell (no content, no active stream) is no longer preserved, so the swap wipe '.msg-body, .tool-card-row, .wl-reason'
drops it and the settled transcript renders normally. );
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 pathlib
import re import re
@@ -54,24 +81,39 @@ def _preserve_guard_src():
class TestBlankLiveTurnPreserveGuard: class TestBlankLiveTurnPreserveGuard:
def test_guard_requires_real_content_or_active_stream(self): def test_guard_requires_live_owner_not_dom_content(self):
guard = _preserve_guard_src() guard = _preserve_guard_src()
# Must gate the preserve on real content OR an active stream — not merely # Must gate the preserve on a PROVABLE live owner — an active stream or
# on INFLIGHT existence. # explicit live-assistant evidence in the message projection — never on
assert "_hasRealLiveContent" in guard, ( # bare DOM content presence. (#6948)
"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, ( assert "S.activeStreamId" in guard, (
"preserve guard must still preserve a genuinely-streaming turn (#3877)" "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. # The assignment must be inside the new conditional.
assert re.search( assert re.search(
r"if\(_hasRealLiveContent\s*\|\|\s*S\.activeStreamId\)\{\s*_preservedLiveTurn=_lt;", r"if\(S\.activeStreamId\s*\|\|\s*_hasLiveAssistantProjection\)\{\s*_preservedLiveTurn=_lt;",
guard, guard,
), "preserve assignment must be gated by (hasRealContent || activeStreamId)" ), "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): def test_runtime_rejects_dead_shell_preserves_live(self):
node = shutil.which("node") node = shutil.which("node")
@@ -81,30 +123,138 @@ class TestBlankLiveTurnPreserveGuard:
script = textwrap.dedent( script = textwrap.dedent(
""" """
const assert=require('assert'); const assert=require('assert');
// Mirror the guard's decision predicate exactly. // Mirror the guard's decision predicate exactly: current-session
function guardWouldPreserve(lt, activeStreamId){ // 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) return false;
const hasReal=!!lt.querySelector('.msg-body, .tool-card-row, .wl-reason'); if(lt.dataset&&lt.dataset.sessionId&&lt.dataset.sessionId!==sessionId){
return hasReal || !!activeStreamId; 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. // Minimal DOM element stub with querySelector over a class set.
function el(classes){ function el(classes, dataset){
const set=new Set(classes||[]); const set=new Set(classes||[]);
return { querySelector(sel){ return {
// sel is a comma list of .class tokens dataset: dataset||null,
return sel.split(',').map(s=>s.trim().replace(/^\\./,'')) querySelector(sel){
.some(c=>set.has(c)) ? {} : null; // 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 deadShell = el([]); // empty worklog shell, no content
const withBody = el(['msg-body']); const withBody = el(['msg-body']); // contentful (completed) turn
const withTool = el(['tool-card-row']); const wrongSess = el(['msg-body'], {sessionId:'other-sid'});
const withReason= el(['wl-reason']); // #5390: dead empty shell must NOT be preserved (settled or live projection).
assert.strictEqual(guardWouldPreserve(deadShell, null), false, 'dead shell must NOT be preserved'); assert.strictEqual(guardWouldPreserve(deadShell, null, settledMessages, true, 's1'), false, 'dead shell must NOT be preserved');
assert.strictEqual(guardWouldPreserve(deadShell, 'sid'), true, 'streaming empty shell preserved (#3877)'); // #3877: streaming shell IS preserved regardless of projection.
assert.strictEqual(guardWouldPreserve(withBody, null), true, 'body content preserved'); assert.strictEqual(guardWouldPreserve(deadShell, 'sid', settledMessages, true, 's1'), true, 'streaming empty shell preserved (#3877)');
assert.strictEqual(guardWouldPreserve(withTool, null), true, 'tool card preserved'); // #6948: a CONTENTFUL dead live node (stream ended, stale INFLIGHT,
assert.strictEqual(guardWouldPreserve(withReason, null), true, 'reason row preserved'); // 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'); console.log('OK');
""" """
) )
+134
View File
@@ -0,0 +1,134 @@
"""Behavioral tests for visibility-driven chat video preloading."""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
def _extract_function(name: str) -> str:
start = UI_JS.index(f"function {name}(")
brace = UI_JS.index("{", start)
depth = 1
i = brace + 1
while depth:
if UI_JS[i] == "{":
depth += 1
elif UI_JS[i] == "}":
depth -= 1
i += 1
return UI_JS[start:i]
def _run_node(body: str) -> dict:
script = "\n".join(
[
"let _mediaVisibilityObserver=null;",
_extract_function("_promoteVisibleVideoPreload"),
_extract_function("_observeVideoPreload"),
_extract_function("_unobserveVideoPreload"),
_extract_function("_initMediaVisibilityObserver"),
body,
]
)
result = subprocess.run(
["node", "-e", script],
cwd=ROOT,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_offscreen_video_stays_metadata_only_until_first_intersection():
result = _run_node(
r"""
let callback=null;
const observed=[];
const unobserved=[];
global.IntersectionObserver=class {
constructor(cb,options){callback=cb;this.options=options;}
observe(v){observed.push(v);}
unobserve(v){unobserved.push(v);}
};
const video={
dataset:{}, preload:'metadata', paused:true, readyState:1, loads:0,
matches:s=>s==='.msg-media-video', load(){this.loads+=1;}
};
_initMediaVisibilityObserver();
_observeVideoPreload(video);
const before={preload:video.preload,loads:video.loads,observed:observed.length};
callback([{target:video,isIntersecting:false}]);
const offscreen={preload:video.preload,loads:video.loads,unobserved:unobserved.length};
callback([{target:video,isIntersecting:true}]);
const first={preload:video.preload,loads:video.loads,unobserved:unobserved.length};
callback([{target:video,isIntersecting:true}]);
const second={preload:video.preload,loads:video.loads,unobserved:unobserved.length};
console.log(JSON.stringify({before,offscreen,first,second,options:_mediaVisibilityObserver.options}));
"""
)
assert result["before"] == {"preload": "metadata", "loads": 0, "observed": 1}
assert result["offscreen"] == {"preload": "metadata", "loads": 0, "unobserved": 0}
assert result["first"] == {"preload": "auto", "loads": 1, "unobserved": 1}
assert result["second"] == {"preload": "auto", "loads": 1, "unobserved": 2}
assert result["options"]["rootMargin"] == "300px 0px"
def test_removed_video_is_unobserved_before_it_intersects():
script = "\n".join(
[
"let _mediaVisibilityObserver=null;",
_extract_function("_promoteVisibleVideoPreload"),
_extract_function("_observeVideoPreload"),
_extract_function("_unobserveVideoPreload"),
_extract_function("_initMediaVisibilityObserver"),
_extract_function("_initMediaPlaybackObserver"),
r"""
let ioCallback=null,mutationCallback=null;
const observed=[],unobserved=[];
global.IntersectionObserver=class {
constructor(cb){ioCallback=cb;}
observe(v){observed.push(v);}
unobserve(v){unobserved.push(v);}
};
global.MutationObserver=class {
constructor(cb){mutationCallback=cb;}
observe(){}
};
const documentVideos=[];
global.document={
body:{}, readyState:'complete',
querySelectorAll:()=>documentVideos,
addEventListener:()=>{}
};
global.window={};
global._applyMediaPlaybackRate=()=>{};
const video={nodeType:1,isConnected:true,dataset:{},preload:'metadata',loads:0,load(){this.loads+=1;},matches:s=>s==='.msg-media-video'||s==='audio,video',querySelectorAll:()=>[]};
_initMediaPlaybackObserver();
mutationCallback([{addedNodes:[video],removedNodes:[]}]);
mutationCallback([{addedNodes:[],removedNodes:[video]}]);
video.isConnected=false;
ioCallback([{target:video,isIntersecting:true}]);
console.log(JSON.stringify({observed:observed.length,unobserved:unobserved.length,preload:video.preload,loads:video.loads,marker:video.dataset.visiblePreload||null}));
""",
]
)
result = subprocess.run(
["node", "-e", script], cwd=ROOT, capture_output=True, text=True, timeout=30
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"observed": 1,
"unobserved": 2,
"preload": "metadata",
"loads": 0,
"marker": None,
}