* 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:
4 files changed
+400
-57
No files matched your search
@@ -5,6 +5,10 @@
|
||||
|
||||
### 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)
|
||||
|
||||
- **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
@@ -2586,6 +2586,37 @@ function _applyMediaPlaybackRate(media, rate=_getStoredMediaPlaybackRate()){
|
||||
media.playbackRate=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=''){
|
||||
const clean=String(name||'').split('?')[0].toLowerCase();
|
||||
if(_VIDEO_EXTS.test(clean)) return 'video';
|
||||
@@ -2752,21 +2783,40 @@ document.addEventListener("loadedmetadata", e=>{
|
||||
_applyMediaPlaybackRate(e.target);
|
||||
}
|
||||
},true);
|
||||
document.addEventListener('play',e=>{
|
||||
if(e.target&&e.target.matches&&e.target.matches('.msg-media-video')){
|
||||
_promoteVisibleVideoPreload(e.target);
|
||||
}
|
||||
},true);
|
||||
function _initMediaPlaybackObserver(){
|
||||
if(!document.body||window._mediaPlaybackObserver) return;
|
||||
_initMediaVisibilityObserver();
|
||||
window._mediaPlaybackObserver=new MutationObserver(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||[]){
|
||||
if(!node||node.nodeType!==1) continue;
|
||||
const media=[];
|
||||
if(node.matches&&node.matches('audio,video')) media.push(node);
|
||||
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});
|
||||
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);
|
||||
else _initMediaPlaybackObserver();
|
||||
@@ -16539,22 +16589,27 @@ function renderMessages(options){
|
||||
if(sid&&INFLIGHT[sid]){
|
||||
const _lt=document.getElementById('liveAssistantTurn');
|
||||
if(_lt&&(!_lt.dataset||!_lt.dataset.sessionId||_lt.dataset.sessionId===sid)){
|
||||
// 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){
|
||||
// Live-turn preservation requires a PROVABLE live owner — never bare DOM
|
||||
// content. (#6948) The live turn is preserved across the wipe only while
|
||||
// (a) the stream is genuinely active (S.activeStreamId — the #3877
|
||||
// mid-stream flicker case this preserve was written for), or (b) the
|
||||
// current message projection (S.messages) still carries explicit
|
||||
// live-assistant evidence — a client-side _live / _activityBurstId /
|
||||
// _liveSegmentSeq marker merged in from the INFLIGHT tail or a server
|
||||
// journal snapshot (the reconnect / terminal-projection case). A settled
|
||||
// transcript has neither, so a contentful but DEAD live node (stream
|
||||
// ended — S.activeStreamId cleared — while INFLIGHT[sid] was not yet
|
||||
// cleaned) is no longer preserved: re-attaching it over the settled
|
||||
// transcript pinned a second copy of the same assistant message (#6948;
|
||||
// data was always clean — state.db, sidecar, and /api/session each hold
|
||||
// one row; the duplicate existed only in the rendered DOM). The #5390
|
||||
// blank-turn guard (对话消失) is preserved: a dead EMPTY shell has no live
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Regression: blank assistant turn (对话消失) — dead empty live-turn shell
|
||||
survives a session-updated swap re-render and hides the settled answer.
|
||||
"""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)
|
||||
------------------------------------------------------------------------
|
||||
@@ -15,20 +15,47 @@ whenever `INFLIGHT[sid]` existed:
|
||||
}
|
||||
|
||||
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
|
||||
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.
|
||||
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
|
||||
@@ -54,24 +81,39 @@ def _preserve_guard_src():
|
||||
|
||||
|
||||
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()
|
||||
# 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"
|
||||
)
|
||||
# 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\(_hasRealLiveContent\s*\|\|\s*S\.activeStreamId\)\{\s*_preservedLiveTurn=_lt;",
|
||||
r"if\(S\.activeStreamId\s*\|\|\s*_hasLiveAssistantProjection\)\{\s*_preservedLiveTurn=_lt;",
|
||||
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):
|
||||
node = shutil.which("node")
|
||||
@@ -81,30 +123,138 @@ class TestBlankLiveTurnPreserveGuard:
|
||||
script = textwrap.dedent(
|
||||
"""
|
||||
const assert=require('assert');
|
||||
// Mirror the guard's decision predicate exactly.
|
||||
function guardWouldPreserve(lt, activeStreamId){
|
||||
// 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;
|
||||
const hasReal=!!lt.querySelector('.msg-body, .tool-card-row, .wl-reason');
|
||||
return hasReal || !!activeStreamId;
|
||||
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){
|
||||
function el(classes, dataset){
|
||||
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;
|
||||
}};
|
||||
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']);
|
||||
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');
|
||||
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');
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user