Merge master (exp-v0.52.344) into stage/7436-reconnect-replay
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
394ff790e5
10 files changed
+1040
-7
No files matched your search
@@ -31,6 +31,13 @@
|
||||
through the reattached stream); the replay stays for legacy restores and for a failed or
|
||||
unavailable scene. (#7436, @atchisonbrent)
|
||||
|
||||
- **A settled assistant answer is no longer shown twice (#2051).** Two client-side paths could put a
|
||||
second copy of a finished answer on screen after a turn settled: a stale live-turn node that was
|
||||
still treated as live, and a settled rebuild branch that appended a turn instead of replacing it.
|
||||
The same fix stops an interrupted turn's notice from appearing twice, and stops the composer's
|
||||
model picker from listing one configured custom-provider model twice. The stored transcript was
|
||||
always correct; only the rendering duplicated it. (#7374, @Thireus)
|
||||
|
||||
- **A hidden browser tab stops polling a session that was deleted.** When a tab is in the
|
||||
background, its stream poll kept asking for a session every few seconds after it was deleted or
|
||||
archived away, forever. A `404`/`410` for the polled session now stops that poll; `503` and network
|
||||
|
||||
@@ -574,6 +574,38 @@ early renderers can avoid chasing `S.messages` during every paint. If settlement
|
||||
later rewrites the transcript message, the anchor must be refreshed from that
|
||||
message instead of allowing the two copies to drift silently.
|
||||
|
||||
### Settled ownership of a restored live turn
|
||||
|
||||
A preserved or snapshotted live-turn node is not evidence of a running turn. The
|
||||
settled assistant message is persisted a few milliseconds before the stream's
|
||||
terminal event clears the active stream id, so a live node whose stream has
|
||||
already ended can survive a transcript rebuild or a session switch and be
|
||||
appended under the settled answer, putting the same answer on screen twice
|
||||
(#6948 follow-up; upstream symptom report #2051).
|
||||
|
||||
A live-turn node is therefore discarded only where the settled transcript is
|
||||
proved to own it: the transcript ends with a settled assistant message, no
|
||||
message still carries a live-projection marker, that settled message's
|
||||
persisted stream identity equals the id of the stream that built the live node
|
||||
— or, only where no scene identity was persisted, the markdown the stream
|
||||
produced equals the persisted message source — and the node carries nothing the
|
||||
settled rebuild could not have produced, meaning no unpersisted tool card,
|
||||
reasoning row or transparent-stream row and no second live segment.
|
||||
|
||||
Ownership is a state proof, never a comparison of two rendered DOM trees: the
|
||||
live body comes from the streaming parser and the settled body from the
|
||||
markdown renderer, and an answer that reads the same as the previous one must
|
||||
not delete a genuinely live turn. An unrecognised node shape keeps the turn.
|
||||
Only the two branches that ADD a turn are gated; a branch that replaces a node
|
||||
cannot duplicate one.
|
||||
|
||||
A settled activity scene projects one answer as well. A `process_prose` row
|
||||
whose text duplicates the scene's final answer — the same text, or a prefix of
|
||||
it within ten percent of its length — is dropped where both settled renderers
|
||||
take their rows, so the answer is not rebuilt as a second assistant segment
|
||||
above itself. Live rendering is unchanged: while the turn streams, the inline
|
||||
live segment is hidden and the prose row is the visible answer.
|
||||
|
||||
## Replay, Reload, And Reconstruction
|
||||
|
||||
Replay/reload should reconstruct the same Assistant Turn Anchor from durable
|
||||
|
||||
+221
-6
@@ -3949,6 +3949,27 @@ function _isEquivalentConfiguredModelEntry(modelId,badge,entries){
|
||||
// providers (@custom:name:model) without collapsing matching model IDs from
|
||||
// different providers.
|
||||
const rawId=String(modelId||'');
|
||||
// `<provider>/<model>` is another routing spelling of `<model>`, so its badge
|
||||
// key must not become a second picker row. configured_model_badges holds every
|
||||
// spelling of a configured model and renderModelDropdown() synthesises a row
|
||||
// for each key this predicate does not recognise. The provider-qualified
|
||||
// spelling was missed because _normalizeConfiguredModelKey() strips only one
|
||||
// leading slash segment (#3360 keeps `vendor_a/x` and `vendor_b/y/x` distinct),
|
||||
// so `acme/example-model` and `custom/acme/example-model` normalise to
|
||||
// different keys and the picker lists one model twice.
|
||||
// Match it the way the `@provider:` rule below does: the badge declares a
|
||||
// provider, the key starts with that provider's `<provider>/` prefix, and an
|
||||
// existing row from the same provider normalises equal to the remainder. Two
|
||||
// different models never satisfy the last clause, so this can only drop a
|
||||
// duplicate of a row the catalog already produced.
|
||||
const slashPrefix=provider?`${provider}/`:'';
|
||||
if(slashPrefix&&rawId.toLowerCase().startsWith(slashPrefix)){
|
||||
const slashRoutedId=rawId.slice(slashPrefix.length);
|
||||
if(slashRoutedId&&(entries||[]).some(entry=>
|
||||
String(entry.providerId||'').toLowerCase()===provider
|
||||
&&_normalizeConfiguredModelKey(entry.value)===_normalizeConfiguredModelKey(slashRoutedId)
|
||||
)) return true;
|
||||
}
|
||||
const prefix=provider?`@${provider}:`:'';
|
||||
if(!prefix||!rawId.toLowerCase().startsWith(prefix)) return false;
|
||||
const routedId=rawId.slice(prefix.length);
|
||||
@@ -9928,6 +9949,99 @@ function _liveAssistantSegmentTextLength(seg){
|
||||
return String(body.textContent||'').trim().length;
|
||||
}
|
||||
|
||||
// ── #6948 follow-up: settled-transcript ownership of a live-turn node ──────
|
||||
// (duplicate assistant answer; upstream symptom report #2051)
|
||||
//
|
||||
// Both live-turn insertion paths — the renderMessages() re-attach and
|
||||
// restoreLiveTurnHtmlForSession() — have one branch that ADDS a turn the
|
||||
// settled rebuild did not produce. The assistant row is persisted a few ms
|
||||
// BEFORE the stream's terminal event clears S.activeStreamId, so a dead live
|
||||
// node left behind by an INFLIGHT entry that outlived its stream is re-attached
|
||||
// on top of the settled transcript: the same answer twice, self-sustaining
|
||||
// across later renders (the node keeps id=liveAssistantTurn and is re-preserved
|
||||
// by the #6948 guard), healed only by a reload.
|
||||
//
|
||||
// Dropping a live node must be an OWNERSHIP proof, never "the rendered text
|
||||
// looks the same":
|
||||
// * the live body is built by the streaming smd parser and the settled body
|
||||
// by renderMd + post-processing, so their textContent differs for anything
|
||||
// past single-paragraph plain text (lists, fenced code with its language
|
||||
// label, tables, `_underscore_` emphasis, the injected copy button) — a
|
||||
// text comparison would miss most real answers; and
|
||||
// * an identical answer earlier in the history (a repeated "Done.", a
|
||||
// greeting, a same-prompt resend) would delete a GENUINELY LIVE turn: while
|
||||
// the current turn is unpersisted, the last settled assistant message IS the
|
||||
// previous turn's answer.
|
||||
// So ownership is proved from state: the transcript must END with a settled
|
||||
// assistant message that THIS stream produced, and the node must carry nothing
|
||||
// the settled rebuild could not have produced.
|
||||
|
||||
// Stream identity persisted on a settled assistant message. The server stamps
|
||||
// _anchor_stream_id from the anchor-scene sidecar record (api/routes.py); the
|
||||
// client stamps _anchor_stream_id and scene.identity.stream_id at stream end
|
||||
// (_attachProjectedAnchorSceneToLastAssistant, static/messages.js).
|
||||
function _settledAssistantStreamId(message){
|
||||
if(!message) return '';
|
||||
const scene=message._anchor_activity_scene||null;
|
||||
const identity=(scene&&scene.identity)||null;
|
||||
return String(message._anchor_stream_id||(scene&&scene.stream_id)||(identity&&identity.stream_id)||'');
|
||||
}
|
||||
// The live-projection markers the #6948 preserve guard already treats as proof
|
||||
// of a live owner. A message the client still considers live is never evidence
|
||||
// that the turn has settled.
|
||||
function _messageHasLiveAssistantProjection(m){
|
||||
return !!(m&&m.role==='assistant'&&(m._live||m._activityBurstId!==undefined||m._liveSegmentSeq!==undefined));
|
||||
}
|
||||
// True when the live turn holds something the settled rebuild cannot have
|
||||
// produced from S.messages — an unpersisted tool card, reasoning/thinking row,
|
||||
// transparent-stream row, or a second live segment. Such a node is never
|
||||
// dropped: #3714's whole premise is that the live DOM can be AHEAD of
|
||||
// S.messages, and a tail-segment match must not discard the tool card or the
|
||||
// earlier segment above it. An unknown node shape fails closed (keep the turn).
|
||||
function _liveTurnCarriesUnsettledContent(turn){
|
||||
if(!turn||typeof turn.querySelectorAll!=='function') return true;
|
||||
if(turn.querySelectorAll(
|
||||
'.tool-card-row,.wl-reason,.agent-activity-thinking,.thinking-card-row,.transparent-event-row'
|
||||
).length) return true;
|
||||
return turn.querySelectorAll('[data-live-assistant="1"]').length>1;
|
||||
}
|
||||
function _settledTranscriptOwnsLiveTurn(sid, turn){
|
||||
if(!turn) return false;
|
||||
const msgs=(typeof S!=='undefined'&&S&&Array.isArray(S.messages))?S.messages:null;
|
||||
if(!msgs||!msgs.length) return false;
|
||||
// The transcript must END with a settled assistant answer. A trailing user
|
||||
// turn means the live turn IS the current turn — nothing of it is persisted
|
||||
// yet, so it can never be a leftover (this is the case a text comparison gets
|
||||
// wrong when the previous answer happens to read the same), and a live
|
||||
// projection anywhere means the rebuild still owns a live turn of its own.
|
||||
const last=msgs[msgs.length-1];
|
||||
if(!last||last.role!=='assistant') return false;
|
||||
if(msgs.some(_messageHasLiveAssistantProjection)) return false;
|
||||
const inflight=(typeof INFLIGHT!=='undefined'&&INFLIGHT)?INFLIGHT[sid]:null;
|
||||
// Owner of the live DOM: INFLIGHT carries the id of the stream that built it
|
||||
// (attachLiveStream), and S.activeStreamId is that same id until the terminal
|
||||
// event clears it.
|
||||
const liveStreamId=String((inflight&&inflight.streamId)||(typeof S!=='undefined'&&S&&S.activeStreamId)||'');
|
||||
const settledStreamId=_settledAssistantStreamId(last);
|
||||
if(settledStreamId){
|
||||
// Identity recorded on both sides decides — renderer- and text-independent,
|
||||
// so code blocks, lists and tables are handled like plain prose.
|
||||
if(!liveStreamId||settledStreamId!==liveStreamId) return false;
|
||||
}else{
|
||||
// No persisted identity on the tail (a turn whose scene was not worklog
|
||||
// worthy). Compare SOURCE with SOURCE — the markdown this stream produced
|
||||
// (INFLIGHT.lastAssistantText) against the persisted message content —
|
||||
// never two independently rendered DOM trees. Regeneration cannot reach
|
||||
// this branch: startRegeneration() truncates S.messages at the user turn,
|
||||
// so the tail is a user message while a regenerated answer streams.
|
||||
const streamed=String((inflight&&inflight.lastAssistantText)||'').replace(/\s+/g,' ').trim();
|
||||
if(!streamed) return false;
|
||||
const settled=String((typeof msgContent==='function'?msgContent(last):last.content)||'').replace(/\s+/g,' ').trim();
|
||||
if(!settled||streamed!==settled) return false;
|
||||
}
|
||||
return !_liveTurnCarriesUnsettledContent(turn);
|
||||
}
|
||||
|
||||
function _mergeRestoredLiveAssistantSegment(restored, existing){
|
||||
if(!restored||!existing) return;
|
||||
const existingLive=existing.querySelector('[data-live-assistant="1"]');
|
||||
@@ -9964,6 +10078,18 @@ function restoreLiveTurnHtmlForSession(sid){
|
||||
const existing=$('liveAssistantTurn');
|
||||
_mergeRestoredLiveAssistantSegment(restored, existing);
|
||||
if(existing) existing.replaceWith(restored);
|
||||
// #6948 follow-up (duplicate assistant answer; #2051): only this branch ADDS a
|
||||
// turn — replacing an existing live turn cannot duplicate. When the settled
|
||||
// transcript already owns the stream this snapshot belongs to, appending it
|
||||
// pins a SECOND copy of the same answer under the settled one. Release the
|
||||
// stale snapshot (no other consumer can use it — it would be re-refused on
|
||||
// every later restore) and report "nothing restored", so loadSession takes the
|
||||
// same path it already takes for an INFLIGHT entry that never carried one.
|
||||
else if(typeof _settledTranscriptOwnsLiveTurn==='function'
|
||||
&&_settledTranscriptOwnsLiveTurn(sid, restored)){
|
||||
inflight.liveTurnHtml=null;
|
||||
return false;
|
||||
}
|
||||
else inner.appendChild(restored);
|
||||
// Transparent Stream: liveTurnHtml is restored via template.innerHTML, which
|
||||
// drops the property-bound onclick/onkeydown handlers wired by
|
||||
@@ -11469,6 +11595,15 @@ function _assistantMessageBelongsInWorklog(m, rawIdx, toolCallAssistantIdxs, vis
|
||||
const isTurnFinalAssistant=!!(opts&&opts.isTurnFinalAssistant);
|
||||
const visibleText=String(visibleContent!==undefined?visibleContent:msgContent(m)||'').trim();
|
||||
const hasVisibleText=!!visibleText&&!_isAssistantEmptyPlaceholderContent(m, visibleText);
|
||||
// The caller only consults this predicate once the turn has settled (it gates
|
||||
// on `!S.busy`), so an `_live` marker seen here is a leftover of the
|
||||
// live-snapshot projection, not an ongoing stream. Folding on it hid the turn's
|
||||
// final answer inline and echoed it into the Worklog, leaving the turn with no
|
||||
// visible content until the #3875 blank-turn fail-safe revealed both copies. A
|
||||
// turn-final assistant message with visible text is the answer, so keep it out
|
||||
// of the fold; every other `_live` message folds as before. Kept as a separate
|
||||
// guard so the live rule below keeps its position.
|
||||
if(m._live&&hasVisibleText&&isTurnFinalAssistant) return false;
|
||||
if(m._live) return true;
|
||||
if(hasVisibleText&&m._anchor_activity_scene) return false;
|
||||
if(hasVisibleText&&isTurnFinalAssistant) return false;
|
||||
@@ -13151,6 +13286,17 @@ function _anchorSceneRowsForRendering(scene, opts){
|
||||
const rows=Array.isArray(scene&&scene.activity_rows)?scene.activity_rows:[];
|
||||
const settled=!!(opts&&opts.settled);
|
||||
const live=!settled;
|
||||
// #6948 follow-up (duplicate assistant answer; #2051): on a SETTLED turn the
|
||||
// assistant segment owns the final answer, so a process_prose row carrying
|
||||
// that same text must not also be rebuilt into the activity scene. The
|
||||
// transparent-stream renderer suppresses it per row
|
||||
// (_anchorSceneTransparentNodeForRow), but the compact-worklog row builder
|
||||
// (_anchorSceneNodeForRow) has no such guard and renders the row as a second
|
||||
// .assistant-segment above the settled one. Drop it here — the single place
|
||||
// BOTH settled renderers (and the #5839 deferred-rows path) get their rows.
|
||||
// LIVE rendering is untouched: while streaming, the inline live segment is
|
||||
// hidden and the prose row IS the visible answer.
|
||||
const settledFinalAnswer=settled?String((scene&&scene.final_answer)||'').trim():'';
|
||||
const out=[];
|
||||
const byKey=new Map();
|
||||
const liveProseTextKeys=new Map();
|
||||
@@ -13173,6 +13319,9 @@ function _anchorSceneRowsForRendering(scene, opts){
|
||||
if(_anchorSceneIsSettledSuccessfulCompression(row,settled)) continue;
|
||||
const text=String(row.text||'').trim();
|
||||
if((row.role==='prose'||row.role==='thinking')&&!text) continue;
|
||||
if(settledFinalAnswer&&row.role==='prose'
|
||||
&&typeof _anchorSceneProseDuplicatesFinalAnswer==='function'
|
||||
&&_anchorSceneProseDuplicatesFinalAnswer(text,settledFinalAnswer)) continue; // #6948 follow-up
|
||||
const key=keyFor(row);
|
||||
if(byKey.has(key)){
|
||||
const index=byKey.get(key);
|
||||
@@ -13447,6 +13596,26 @@ function _anchorSceneProseMatchesFinalAnswer(proseText, finalAnswer){
|
||||
const shorter=Math.min(a.length,b.length), longer=Math.max(a.length,b.length);
|
||||
return shorter>=80 && (shorter/longer)>=0.9;
|
||||
}
|
||||
// #6948 follow-up (duplicate assistant answer; #2051): the same near-equality
|
||||
// test as _anchorSceneProseMatchesFinalAnswer, minus its absolute `shorter>=80`
|
||||
// floor. That floor makes the matcher a no-op for every SHORT final answer
|
||||
// (greetings, "Done.", one-liners), so a prose row that is the answer minus its
|
||||
// last streamed token — e.g. "Hey! What can I help you with today" vs
|
||||
// "...today?" — was rebuilt as a SECOND assistant-segment beside the settled
|
||||
// one. The >=0.9 length ratio is what actually keeps a distinct short
|
||||
// intermediate sentence from being swallowed by a long final answer (Codex
|
||||
// #4568), so it alone is kept. Deliberately a separate helper: the per-row
|
||||
// render-time matcher above, and the tests pinning its exact predicate, stay
|
||||
// untouched.
|
||||
function _anchorSceneProseDuplicatesFinalAnswer(proseText, finalAnswer){
|
||||
if(_anchorSceneProseMatchesFinalAnswer(proseText,finalAnswer)) return true;
|
||||
const norm=(s)=>String(s||'').replace(/\s+/g,' ').trim();
|
||||
const a=norm(proseText), b=norm(finalAnswer);
|
||||
if(!a||!b) return false;
|
||||
if(!(a.startsWith(b)||b.startsWith(a))) return false;
|
||||
const shorter=Math.min(a.length,b.length), longer=Math.max(a.length,b.length);
|
||||
return (shorter/longer)>=0.9;
|
||||
}
|
||||
function _anchorSceneWorklogGroup(blocks, opts){
|
||||
if(!blocks) return null;
|
||||
const live=!!(opts&&opts.live);
|
||||
@@ -16922,6 +17091,23 @@ function _processWakeupCardHtml(info, rawText, extras){
|
||||
return `<details class="process-wakeup-card"><summary class="process-wakeup-summary"><span class="process-wakeup-toggle">${li('chevron-right',12)}</span><span class="process-wakeup-label">${li('terminal',13)}<span>${esc(t('process_wakeup_label'))}</span></span>${cmdHtml}${chip}${extras.timeHtml||''}</summary><div class="process-wakeup-detail">${extras.filesHtml||''}${patternRow}${cmdRow}<div class="msg-body process-wakeup-body">${outHtml}</div>${extras.footHtml||''}</div></details>`;
|
||||
}
|
||||
|
||||
// #2051: parse into a <template> and move the nodes instead of insertAdjacentHTML —
|
||||
// every step is idempotent, so a DOM-API wrapper (e.g. an anti-fingerprinting
|
||||
// extension) that executes the call twice cannot duplicate the block.
|
||||
function _insertSegmentBlock(seg, html){
|
||||
if(!seg) return;
|
||||
if(typeof document!=='undefined'&&typeof document.createElement==='function'){
|
||||
try{
|
||||
const tpl=document.createElement('template');
|
||||
if('content' in tpl){
|
||||
tpl.innerHTML=html;
|
||||
seg.appendChild(tpl.content);
|
||||
return;
|
||||
}
|
||||
}catch(_){ /* fall through to the string path below */ }
|
||||
}
|
||||
seg.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
function renderMessages(options){
|
||||
_lastMessageRenderAt=performance.now();
|
||||
const preserveScroll=!!(options&&options.preserveScroll);
|
||||
@@ -17686,7 +17872,7 @@ function renderMessages(options){
|
||||
if(isLastTextPart&&statusHtml){
|
||||
orderedSeg.insertAdjacentHTML('beforeend', statusHtml);
|
||||
}
|
||||
orderedSeg.insertAdjacentHTML('beforeend', `${isLastTextPart?filesHtml:''}<div class="msg-body">${(typeof m!=='undefined'&&m&&m._media_snapshots&&typeof m._media_snapshots==='object')?_stampMediaSnapshots(partBodyHtml,m._media_snapshots):partBodyHtml}</div>${isLastTextPart?footHtml:''}`);
|
||||
_insertSegmentBlock(orderedSeg, `${isLastTextPart?filesHtml:''}<div class="msg-body">${(typeof m!=='undefined'&&m&&m._media_snapshots&&typeof m._media_snapshots==='object')?_stampMediaSnapshots(partBodyHtml,m._media_snapshots):partBodyHtml}</div>${isLastTextPart?footHtml:''}`);
|
||||
blocks.appendChild(orderedSeg);
|
||||
if(!firstSeg) firstSeg=orderedSeg;
|
||||
});
|
||||
@@ -17746,9 +17932,9 @@ function renderMessages(options){
|
||||
const hasVisibleBody=!!(String(content||'').trim()||filesHtml||recoveryHtml);
|
||||
if(statusHtml){
|
||||
seg.insertAdjacentHTML('beforeend', statusHtml);
|
||||
if(hasVisibleBody) seg.insertAdjacentHTML('beforeend', `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
|
||||
if(hasVisibleBody) _insertSegmentBlock(seg, `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
|
||||
}else if(hasVisibleBody){
|
||||
seg.insertAdjacentHTML('beforeend', `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
|
||||
_insertSegmentBlock(seg, `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
|
||||
}else if(!(thinkingText&&window._showThinking!==false&&!isSimplifiedToolCalling())){
|
||||
seg.classList.add('assistant-segment-anchor');
|
||||
}
|
||||
@@ -18171,12 +18357,15 @@ function renderMessages(options){
|
||||
if(!cards.length&&!anchorReasonHtml&&!thinkingText) continue;
|
||||
const anchorTurn=anchorRow.closest('.assistant-turn');
|
||||
if(!anchorTurn) continue;
|
||||
// Hoisted out of the `if(!state)` block below (same expression, same
|
||||
// value) so the append path can use the ownership fact the group
|
||||
// construction already uses.
|
||||
const anchorIsWorklogSource=anchorRow.classList&&anchorRow.classList.contains('assistant-segment-worklog-source');
|
||||
let state=activityByTurn.get(anchorTurn);
|
||||
if(!state){
|
||||
const includeTurnDuration=!durationAssignedTurns.has(anchorTurn);
|
||||
if(includeTurnDuration) durationAssignedTurns.add(anchorTurn);
|
||||
const activityKey=`assistant:${aIdx}`;
|
||||
const anchorIsWorklogSource=anchorRow.classList&&anchorRow.classList.contains('assistant-segment-worklog-source');
|
||||
const group=ensureActivityGroup(anchorParent,{
|
||||
collapsed:true,
|
||||
anchor:anchorRow,
|
||||
@@ -18196,7 +18385,14 @@ function renderMessages(options){
|
||||
state.cards.push(...cards);
|
||||
_appendWorklogStep(state.group, anchorRow, cards, thinkingText, {
|
||||
live:false,
|
||||
includeAnchorReason:!!includeAnchorReason&&!!anchorReasonHtml,
|
||||
// Echo an anchor's prose as a `.wl-reason` row only when that anchor was
|
||||
// folded into this Worklog. `assistant-segment-worklog-source` is the
|
||||
// proof, and its `display:none` is the only reason the echo is not a
|
||||
// second visible copy. The group construction above already reasons
|
||||
// that way (`syncAnchorReason`); the append path did not, so an anchor
|
||||
// that escapes the fold (the turn-final answer, an `_error` message)
|
||||
// had its text rendered both inline and inside the Worklog.
|
||||
includeAnchorReason:!!includeAnchorReason&&!!anchorReasonHtml&&!!anchorIsWorklogSource,
|
||||
thinkingKey:thinkingText?`thinking:${_normalizeThinkingEchoCompare(thinkingText)}`:'',
|
||||
thinkingDisclosureKey:thinkingText?`thinking:${entry.key}`:'',
|
||||
seenReasons:state.seenReasons,
|
||||
@@ -18485,6 +18681,15 @@ function renderMessages(options){
|
||||
const groups=turn.querySelectorAll('.tool-worklog-group,.tool-call-group');
|
||||
let revealed=false;
|
||||
for(const group of groups){
|
||||
// A settled Worklog whose rows are still deferred (#5839) has an empty
|
||||
// textContent but is not empty in substance. Judging it empty here drops
|
||||
// through to the last-resort un-hide below, and the deferred rows then
|
||||
// materialize the same prose beside the segments it just un-hid.
|
||||
// Materialize first, then judge.
|
||||
if(group.getAttribute&&group.getAttribute('data-worklog-rows-deferred')==='1'
|
||||
&&typeof _materializeDeferredWorklogRows==='function'){
|
||||
_materializeDeferredWorklogRows(group);
|
||||
}
|
||||
if(!(group.textContent||'').trim()) continue; // empty group can't help
|
||||
if(group.classList.contains('tool-call-group-collapsed')){
|
||||
group.classList.remove('tool-call-group-collapsed');
|
||||
@@ -18603,7 +18808,17 @@ function renderMessages(options){
|
||||
// restore the whole preserved turn so nothing the user saw vanishes.
|
||||
if(S.session) _preservedLiveTurn.dataset.sessionId=S.session.session_id;
|
||||
_rebuilt.replaceWith(_preservedLiveTurn);
|
||||
}else{
|
||||
}else if(!(typeof _settledTranscriptOwnsLiveTurn==='function'
|
||||
&&_settledTranscriptOwnsLiveTurn(sid,_preservedLiveTurn))){
|
||||
// #6948 follow-up (duplicate assistant answer; #2051): this is the
|
||||
// only branch that ADDS a turn — the rebuild produced no live turn of
|
||||
// its own. When the settled transcript already ends with THIS stream's
|
||||
// own answer and the preserved node carries nothing unpersisted, the
|
||||
// node is a dead leftover (the row persisted and the turn settled while
|
||||
// INFLIGHT[sid] was not yet cleaned) and appending pins a SECOND copy
|
||||
// that re-preserves itself on every later render until a reload.
|
||||
// Mid-stream the transcript ends with the user turn (or still carries a
|
||||
// live projection), so #3877 preservation is untouched.
|
||||
if(S.session) _preservedLiveTurn.dataset.sessionId=S.session.session_id;
|
||||
inner.appendChild(_preservedLiveTurn);
|
||||
}
|
||||
|
||||
@@ -416,6 +416,10 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds
|
||||
legacy_metadata_source = _function_source(
|
||||
_ui_js(), "_legacySettledFallbackHasToolMetadata"
|
||||
)
|
||||
# #2051: renderMessages() inserts a message block through this helper, so it is
|
||||
# evaluated with it. The shim's createElement() returns no template `content`, so the
|
||||
# helper takes its insertAdjacentHTML fallback here, exactly as before.
|
||||
insert_block_source = _function_source(_ui_js(), "_insertSegmentBlock")
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
class FakeClassList {{
|
||||
@@ -702,6 +706,7 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds
|
||||
|
||||
eval({json.dumps(transparent_source)});
|
||||
eval({json.dumps(legacy_metadata_source)});
|
||||
eval({json.dumps(insert_block_source)});
|
||||
eval({json.dumps(render_source)});
|
||||
|
||||
const toolResult = {{ role: 'tool', tool_call_id: 'toolu_1', content: 'tool result' }};
|
||||
|
||||
@@ -50,6 +50,10 @@ _REAL_FUNCTIONS = (
|
||||
"_insertPreservedCompressionTaskFallback",
|
||||
"_pinCompactionCardAtTop",
|
||||
"_pinSettledCompressionReferenceAtTop",
|
||||
# #2051: renderMessages() inserts a message block through this helper, so it has to be
|
||||
# evaluated with it. The shim's createElement() returns no template `content`, so the
|
||||
# helper takes its insertAdjacentHTML fallback here, exactly as before.
|
||||
"_insertSegmentBlock",
|
||||
"renderMessages",
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
"""Regression: the assistant answer is displayed TWICE (#6948 follow-up;
|
||||
upstream symptom report #2051).
|
||||
|
||||
Two independent renderers can put a second copy of the same answer on screen
|
||||
after a turn settles. Both are covered here.
|
||||
|
||||
1. The live-turn node (static/ui.js)
|
||||
------------------------------------
|
||||
`renderMessages()` re-attaches the preserved `#liveAssistantTurn` node after the
|
||||
transcript wipe, and `restoreLiveTurnHtmlForSession()` re-inserts the
|
||||
`INFLIGHT[sid].liveTurnHtml` snapshot on a session switch / reconnect. Each has
|
||||
ONE branch that *appends* — i.e. adds a turn the settled rebuild did not
|
||||
produce. The assistant row is persisted a few ms BEFORE the stream's terminal
|
||||
event clears `S.activeStreamId`, so a dead live node left behind by an INFLIGHT
|
||||
entry that outlived its stream is appended under the settled copy: the same
|
||||
answer twice, self-sustaining across later renders (the node keeps
|
||||
id=liveAssistantTurn and is re-preserved by the #6948 guard), healed only by a
|
||||
reload.
|
||||
|
||||
The contract (`_settledTranscriptOwnsLiveTurn`) is an OWNERSHIP proof, never a
|
||||
rendered-text comparison:
|
||||
|
||||
* the live body comes from the streaming smd parser and the settled body from
|
||||
renderMd + post-processing, so comparing textContent would only ever match
|
||||
single-paragraph plain text — lists, fenced code, tables and `_emphasis_`
|
||||
render differently in the two pipelines;
|
||||
* comparing text would DELETE a genuinely live turn whenever the previous
|
||||
answer reads the same ("Done.", a greeting, a same-prompt resend), because
|
||||
while the current turn is unpersisted the last settled assistant message is
|
||||
the PREVIOUS turn's answer.
|
||||
|
||||
So the append is skipped only when the transcript ENDS with a settled assistant
|
||||
message that the SAME stream produced (persisted anchor identity, or a
|
||||
source-vs-source text match when the turn has no persisted scene), and the
|
||||
preserved node carries nothing the settled rebuild could not have produced (no
|
||||
unpersisted tool card / reasoning row, no second live segment — #3714).
|
||||
|
||||
2. The settled activity scene (static/ui.js)
|
||||
--------------------------------------------
|
||||
`_anchorSceneRowsForRendering(scene,{settled:true})` feeds both settled
|
||||
renderers. A `process_prose` row whose text IS the final answer is suppressed
|
||||
per-row by the transparent-stream renderer but NOT by the compact-worklog row
|
||||
builder, which rebuilds it as a second `.assistant-segment` above the settled
|
||||
answer. The row is now dropped in the shared filter, using a near-equality test
|
||||
without the absolute `shorter>=80` floor that made the existing matcher a no-op
|
||||
for short answers (the reported case: the row is the answer minus its last
|
||||
streamed token, 35 vs 36 chars). LIVE rendering is untouched — while streaming,
|
||||
the inline live segment is hidden and the prose row IS the visible answer.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.js_source_extract import extract_function
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def js(*names):
|
||||
"""Real source of the named ui.js functions — never a re-implementation."""
|
||||
return "\n".join(extract_function(UI_JS, name) for name in names)
|
||||
|
||||
|
||||
def run_node(script):
|
||||
out = subprocess.run([NODE, "-e", script], capture_output=True, text=True)
|
||||
assert out.returncode == 0, f"node harness failed:\n{out.stderr}\n{out.stdout}"
|
||||
assert "OK" in out.stdout, out.stdout
|
||||
return out.stdout
|
||||
|
||||
|
||||
# Minimal DOM stand-in: class/attribute selector matching over a node tree.
|
||||
DOM_STUB = """
|
||||
const assert=require('assert');
|
||||
class El{
|
||||
constructor(spec){
|
||||
spec=spec||{};
|
||||
this.classes=new Set(spec.classes||[]);
|
||||
this.attrs=Object.assign({},spec.attrs||{});
|
||||
this.children=spec.children||[];
|
||||
}
|
||||
_matchesToken(tok){
|
||||
tok=String(tok).trim();
|
||||
if(tok.startsWith('.')) return this.classes.has(tok.slice(1));
|
||||
const m=/^\\[([a-zA-Z0-9-]+)="([^"]*)"\\]$/.exec(tok);
|
||||
if(m) return String(this.attrs[m[1]]===undefined?'':this.attrs[m[1]])===m[2];
|
||||
return false;
|
||||
}
|
||||
querySelectorAll(sel){
|
||||
const toks=String(sel).split(',');
|
||||
const out=[];
|
||||
const walk=(node)=>{
|
||||
for(const child of node.children){
|
||||
if(toks.some(t=>child._matchesToken(t))) out.push(child);
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
walk(this);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
const seg=(text)=>new El({attrs:{'data-live-assistant':'1'},classes:['assistant-segment'],
|
||||
children:[new El({classes:['msg-body'],children:[]})]});
|
||||
const turnWith=(...kids)=>new El({classes:['assistant-turn'],children:kids});
|
||||
const toolCard=()=>new El({classes:['tool-card-row']});
|
||||
const emptyWorklogShell=()=>new El({classes:['live-worklog'],attrs:{'data-live-worklog-shell':'1'}});
|
||||
var S={messages:[],activeStreamId:null,session:{session_id:'s1'}};
|
||||
var INFLIGHT={};
|
||||
function setState(messages,inflight,activeStreamId){
|
||||
S.messages=messages;
|
||||
S.activeStreamId=activeStreamId===undefined?null:activeStreamId;
|
||||
for(const k of Object.keys(INFLIGHT)) delete INFLIGHT[k];
|
||||
if(inflight) INFLIGHT['s1']=inflight;
|
||||
}
|
||||
const user=(content)=>({role:'user',content});
|
||||
const settledAnswer=(content,streamId)=>{
|
||||
const m={role:'assistant',content};
|
||||
if(streamId) m._anchor_stream_id=streamId;
|
||||
return m;
|
||||
};
|
||||
const sceneIdentityAnswer=(content,streamId)=>({
|
||||
role:'assistant',content,
|
||||
_anchor_activity_scene:{identity:{stream_id:streamId},final_answer:content},
|
||||
});
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_dead_live_turn_is_dropped_only_when_the_stream_is_provably_settled():
|
||||
"""The #2051 duplicate is suppressed; a live turn is never deleted."""
|
||||
script = DOM_STUB + js(
|
||||
"msgContent",
|
||||
"_settledAssistantStreamId",
|
||||
"_messageHasLiveAssistantProjection",
|
||||
"_liveTurnCarriesUnsettledContent",
|
||||
"_settledTranscriptOwnsLiveTurn",
|
||||
) + """
|
||||
const ANSWER='Hey! What can I help you with today?';
|
||||
|
||||
// ── the bug: stream settled, INFLIGHT not yet cleaned → dead leftover ──
|
||||
setState([user('Hi'),settledAnswer(ANSWER,'st1')],{streamId:'st1'},'st1');
|
||||
assert.strictEqual(
|
||||
_settledTranscriptOwnsLiveTurn('s1',turnWith(seg(ANSWER),emptyWorklogShell())),true,
|
||||
'dead live node whose stream the settled transcript owns must be dropped (#2051)');
|
||||
|
||||
// Identity may live only on the persisted scene.
|
||||
setState([user('Hi'),sceneIdentityAnswer(ANSWER,'st1')],{streamId:'st1'},null);
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg(ANSWER))),true,
|
||||
'scene identity.stream_id is accepted as ownership proof');
|
||||
|
||||
// Shape independence: the decision must not depend on rendered text at all, so
|
||||
// a multi-block answer (fenced code / list / table), which the smd parser and
|
||||
// renderMd render to DIFFERENT textContent, is handled like plain prose.
|
||||
setState([user('Hi'),settledAnswer('Here you go:\\n\\n```python\\nprint(1)\\n```','st1')],
|
||||
{streamId:'st1'},'st1');
|
||||
assert.strictEqual(
|
||||
_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Here you go:print(1)'))),true,
|
||||
'a code-block answer must be de-duplicated too (renderer-independent)');
|
||||
|
||||
// ── false positives that a text comparison would cause ──
|
||||
// The current turn is unpersisted: the transcript ends with the USER turn while
|
||||
// the live turn streams text identical to the PREVIOUS answer.
|
||||
setState([user('Hi'),settledAnswer('Done.','st1'),user('Hi')],{streamId:'st2'},'st2');
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false,
|
||||
'a genuinely live turn must never be dropped because an earlier answer matches');
|
||||
// markInflight() shape at send time (no snapshot yet, stream just started).
|
||||
setState([user('Hi'),settledAnswer('Done.','st1'),user('Hi')],{sid:'s1',streamId:'st2',ts:1},'st2');
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Do'))),false,
|
||||
'partial live text under a fresh stream must never be dropped');
|
||||
// A settled tail from a DIFFERENT stream never owns this live turn.
|
||||
setState([user('Hi'),settledAnswer('Done.','st1')],{streamId:'st2'},'st2');
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false,
|
||||
'a tail from another stream must not authorize dropping the live turn');
|
||||
// Reconnect/terminal projection: S.messages still carries a live marker.
|
||||
setState([user('Hi'),Object.assign(settledAnswer('Done.','st1'),{_live:true})],
|
||||
{streamId:'st1'},null);
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false,
|
||||
'a live projection in S.messages means the turn has not settled');
|
||||
// Ownership unknown (no INFLIGHT stream id, no active stream) → fail closed.
|
||||
setState([user('Hi'),settledAnswer('Done.','st1')],{},null);
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false,
|
||||
'unknown live owner must fail closed');
|
||||
setState([],{streamId:'st1'},'st1');
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Done.'))),false,
|
||||
'an empty transcript owns nothing');
|
||||
|
||||
// ── #3714: live-only structure is never discarded ──
|
||||
setState([user('Hi'),settledAnswer(ANSWER,'st1')],{streamId:'st1'},'st1');
|
||||
assert.strictEqual(
|
||||
_settledTranscriptOwnsLiveTurn('s1',turnWith(toolCard(),seg(ANSWER))),false,
|
||||
'a preserved turn carrying an unpersisted tool card must be kept');
|
||||
assert.strictEqual(
|
||||
_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('first'),seg(ANSWER))),false,
|
||||
'a multi-segment live turn must not be dropped on a tail-segment match');
|
||||
assert.strictEqual(
|
||||
_settledTranscriptOwnsLiveTurn('s1',turnWith(new El({classes:['wl-reason']}),seg(ANSWER))),false,
|
||||
'a preserved turn carrying an unpersisted reasoning row must be kept');
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',{}),false,
|
||||
'an unknown node shape must fail closed');
|
||||
|
||||
// ── no persisted scene identity: SOURCE text vs SOURCE text ──
|
||||
setState([user('Hi'),settledAnswer(ANSWER,null)],{streamId:'st1',lastAssistantText:ANSWER},'st1');
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg(ANSWER))),true,
|
||||
'streamed markdown identical to the persisted answer proves ownership');
|
||||
setState([user('Hi'),settledAnswer(ANSWER,null)],
|
||||
{streamId:'st1',lastAssistantText:'Hey! What can I help'},'st1');
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg('Hey! What can I help'))),false,
|
||||
'a still-growing stream must not be dropped against a longer settled answer');
|
||||
setState([user('Hi'),settledAnswer(ANSWER,null)],{streamId:'st1'},'st1');
|
||||
assert.strictEqual(_settledTranscriptOwnsLiveTurn('s1',turnWith(seg(ANSWER))),false,
|
||||
'no identity and no streamed source text → fail closed');
|
||||
console.log('OK');
|
||||
"""
|
||||
run_node(script)
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_append_decision_renders_the_answer_exactly_once():
|
||||
"""Observable outcome of the two append branches, driven by the real
|
||||
predicate: one copy for a settled leftover, and the live turn is still
|
||||
attached in every case where the turn may still be running."""
|
||||
script = DOM_STUB + js(
|
||||
"msgContent",
|
||||
"_settledAssistantStreamId",
|
||||
"_messageHasLiveAssistantProjection",
|
||||
"_liveTurnCarriesUnsettledContent",
|
||||
"_settledTranscriptOwnsLiveTurn",
|
||||
) + """
|
||||
// Mirrors the two call sites: the branch appends unless the settled transcript
|
||||
// provably owns the node (static/ui.js renderMessages re-attach and
|
||||
// restoreLiveTurnHtmlForSession).
|
||||
function visibleAnswers(messages,inflight,activeStreamId,turn){
|
||||
setState(messages,inflight,activeStreamId);
|
||||
const settledCopies=messages.filter(m=>m.role==='assistant'&&!m._live).length;
|
||||
const appended=!_settledTranscriptOwnsLiveTurn('s1',turn)?1:0;
|
||||
return {settledCopies,appended,total:settledCopies+appended};
|
||||
}
|
||||
const ANSWER='Hey! What can I help you with today?';
|
||||
// #2051: exactly one copy of the answer.
|
||||
assert.deepStrictEqual(
|
||||
visibleAnswers([user('Hi'),settledAnswer(ANSWER,'st1')],{streamId:'st1'},'st1',
|
||||
turnWith(seg(ANSWER),emptyWorklogShell())),
|
||||
{settledCopies:1,appended:0,total:1},
|
||||
'the settled answer must render exactly once');
|
||||
// A live turn is still attached while the turn is running (#3877).
|
||||
assert.deepStrictEqual(
|
||||
visibleAnswers([user('Hi'),settledAnswer(ANSWER,'st1'),user('Hi again')],
|
||||
{streamId:'st2'},'st2',turnWith(seg(ANSWER))),
|
||||
{settledCopies:1,appended:1,total:2},
|
||||
'a running turn must stay on screen even when it repeats an earlier answer');
|
||||
// A leftover carrying unpersisted structure is kept whole (#3714).
|
||||
assert.deepStrictEqual(
|
||||
visibleAnswers([user('Hi'),settledAnswer(ANSWER,'st1')],{streamId:'st1'},'st1',
|
||||
turnWith(toolCard(),seg(ANSWER))).appended,1,
|
||||
'never discard live-only blocks the rebuild did not produce');
|
||||
console.log('OK');
|
||||
"""
|
||||
run_node(script)
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_settled_scene_drops_the_prose_row_that_is_the_final_answer():
|
||||
script = DOM_STUB + js(
|
||||
"_anchorSceneIsSettledSuccessfulCompression",
|
||||
"_anchorSceneProseMatchesFinalAnswer",
|
||||
"_anchorSceneProseDuplicatesFinalAnswer",
|
||||
"_anchorSceneRowsForRendering",
|
||||
) + """
|
||||
function _anchorSceneToolRowLogicalKey(row){ return (row&&row.row_id)||''; }
|
||||
function _anchorSceneMergeToolRows(_prev,row){ return row; }
|
||||
const FINAL='Hey! What can I help you with today?';
|
||||
const scene={
|
||||
final_answer:FINAL,
|
||||
activity_rows:[
|
||||
{role:'thinking',row_id:'t1',text:'thinking out loud'},
|
||||
{role:'prose',row_id:'p1',text:'Let me check the config first.'},
|
||||
// the reported shape: the answer minus its last streamed token (35 vs 36)
|
||||
{role:'prose',row_id:'p2',text:'Hey! What can I help you with today'},
|
||||
{role:'terminal',row_id:'d1',source_event_type:'done'},
|
||||
],
|
||||
};
|
||||
const texts=(opts)=>_anchorSceneRowsForRendering(scene,opts).map(r=>r.text);
|
||||
assert.deepStrictEqual(texts({settled:true}),['thinking out loud','Let me check the config first.'],
|
||||
'the settled scene must not rebuild the final answer as a second segment (#2051)');
|
||||
assert.deepStrictEqual(texts({settled:false}),
|
||||
['thinking out loud','Let me check the config first.','Hey! What can I help you with today'],
|
||||
'LIVE rendering must keep the prose row — it IS the visible answer while streaming');
|
||||
// Exact repeats and multi-block answers are dropped too; source text is compared,
|
||||
// so the markdown pipeline never matters here.
|
||||
const code={final_answer:'Here you go:\\n\\n```python\\nprint(1)\\n```',activity_rows:[
|
||||
{role:'prose',row_id:'p1',text:'Here you go:\\n\\n```python\\nprint(1)\\n```'},
|
||||
{role:'prose',row_id:'p2',text:'Working on it.'}]};
|
||||
assert.deepStrictEqual(_anchorSceneRowsForRendering(code,{settled:true}).map(r=>r.text),
|
||||
['Working on it.'],'an exact fenced-code duplicate must be dropped, intermediates kept');
|
||||
// Codex #4568: a SHORT distinct intermediate that merely prefixes a LONG answer
|
||||
// must survive — the >=0.9 length ratio, not the removed absolute floor, is what
|
||||
// protects it.
|
||||
const long_={final_answer:'Hey! What can I help you with today? I can read files, run commands and edit code.',
|
||||
activity_rows:[{role:'prose',row_id:'p1',text:'Hey! What can'}]};
|
||||
assert.deepStrictEqual(_anchorSceneRowsForRendering(long_,{settled:true}).map(r=>r.text),
|
||||
['Hey! What can'],'a distinct short intermediate must not be swallowed by a long answer');
|
||||
// The pinned per-row matcher is untouched: its absolute floor still rejects the
|
||||
// short near-miss that the new helper accepts.
|
||||
assert.strictEqual(_anchorSceneProseMatchesFinalAnswer('Hey! What can I help you with today',FINAL),false,
|
||||
'_anchorSceneProseMatchesFinalAnswer must keep its >=80 char floor');
|
||||
assert.strictEqual(_anchorSceneProseDuplicatesFinalAnswer('Hey! What can I help you with today',FINAL),true,
|
||||
'the new helper must catch the short near-miss');
|
||||
assert.strictEqual(_anchorSceneProseDuplicatesFinalAnswer('Hey! What can',FINAL),false,
|
||||
'the new helper must keep the >=0.9 ratio guard');
|
||||
console.log('OK');
|
||||
"""
|
||||
run_node(script)
|
||||
|
||||
|
||||
def test_both_append_branches_are_gated_by_the_ownership_check():
|
||||
"""Wiring: the predicate only helps if it guards the two branches that ADD a
|
||||
turn (the replace branches cannot duplicate and must stay unguarded)."""
|
||||
restore = UI_JS[UI_JS.index("function restoreLiveTurnHtmlForSession"):]
|
||||
restore = restore[:restore.index("\nfunction markInflight")]
|
||||
assert "if(existing) existing.replaceWith(restored);" in restore
|
||||
assert "_settledTranscriptOwnsLiveTurn(sid, restored)" in restore, (
|
||||
"the restore append branch must be gated by the ownership check"
|
||||
)
|
||||
assert "inflight.liveTurnHtml=null;" in restore, (
|
||||
"a refused snapshot must be released, or it is re-refused on every restore"
|
||||
)
|
||||
reattach = UI_JS[UI_JS.index(" const _rebuilt=document.getElementById('liveAssistantTurn');"):]
|
||||
reattach = reattach[:reattach.index(" // Only force-scroll when not actively streaming")]
|
||||
assert "_settledTranscriptOwnsLiveTurn(sid,_preservedLiveTurn)" in reattach, (
|
||||
"the renderMessages append branch must be gated by the ownership check"
|
||||
)
|
||||
assert reattach.count("_settledTranscriptOwnsLiveTurn(") == 1, (
|
||||
"the segment-swap and whole-turn replace branches must stay unguarded — "
|
||||
"they replace a node instead of adding one and cannot duplicate"
|
||||
)
|
||||
|
||||
|
||||
def test_segment_block_insertion_moves_nodes_instead_of_reparsing_html():
|
||||
"""Wiring: every message-block insertion in renderMessages() goes through the
|
||||
helper, and the helper MOVES parsed nodes instead of re-parsing a string.
|
||||
|
||||
Appending a DocumentFragment moves its children and empties it, so a DOM-API
|
||||
wrapper that executes the call twice appends nothing the second time; an
|
||||
insertAdjacentHTML wrapper that does the same parses and appends a second copy.
|
||||
That difference is the whole fix, so it is pinned here rather than left to the
|
||||
three call sites.
|
||||
"""
|
||||
helper = UI_JS[UI_JS.index("function _insertSegmentBlock("):]
|
||||
helper = helper[:helper.index("\nfunction renderMessages")]
|
||||
assert "createElement('template')" in helper, (
|
||||
"the block must be parsed into a <template>, not inserted as a string"
|
||||
)
|
||||
assert "seg.appendChild(tpl.content)" in helper, (
|
||||
"the template's content must be MOVED into the segment — that is what makes "
|
||||
"a repeated call a no-op"
|
||||
)
|
||||
assert helper.count("insertAdjacentHTML") == 1, (
|
||||
"insertAdjacentHTML may survive only as the fallback for an environment "
|
||||
"without <template>"
|
||||
)
|
||||
render = UI_JS[UI_JS.index("function renderMessages(options){"):]
|
||||
assert render.count("_insertSegmentBlock(") == 3, (
|
||||
"all three assistant-segment block insertions must go through the helper"
|
||||
)
|
||||
assert "seg.insertAdjacentHTML('beforeend', `${filesHtml}" not in render, (
|
||||
"an assistant segment's block must not be built from an HTML string again"
|
||||
)
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Regression: an interrupted turn's notice — and any turn-final answer — is
|
||||
displayed twice (#6948 / #2051).
|
||||
|
||||
A ``hermes chat`` run killed mid tool-call persists a single assistant message
|
||||
``Operation interrupted.``; the WebUI renders that notice twice. The same
|
||||
doubling was reported for a long tool run's final answer and for an image reply
|
||||
carrying a ``MEDIA:`` line. The stored data is clean in every case — one message
|
||||
in ``state.db``, one in the session sidecar, one in ``/api/session`` — so both
|
||||
copies are produced client-side.
|
||||
|
||||
``renderMessages()`` folds intermediate assistant segments into the collapsed
|
||||
Worklog card (#3401): the inline segment is classed
|
||||
``assistant-segment-worklog-source`` (``display:none``) and its prose is
|
||||
re-rendered as a ``.wl-reason`` row inside the Worklog. Two copies, one hidden —
|
||||
correct, as long as the fold decision is correct.
|
||||
|
||||
``_assistantMessageBelongsInWorklog()`` folded on ``m._live`` before it could
|
||||
reach the rule that protects the answer (``hasVisibleText && isTurnFinalAssistant
|
||||
-> false``). Its caller only consults the predicate once the turn has settled (it
|
||||
gates on ``!S.busy``), so an ``_live`` marker seen there is a leftover of the
|
||||
live-snapshot projection rather than an ongoing stream. Folding on it hides the
|
||||
answer inline and echoes it into the Worklog: the turn then holds two copies and
|
||||
no visible content, which the #3875 blank-turn fail-safe reveals — by expanding
|
||||
the Worklog, or by un-hiding the source segments — so the user sees the notice or
|
||||
answer twice.
|
||||
|
||||
Three contracts are pinned here:
|
||||
|
||||
1. ``_assistantMessageBelongsInWorklog``: a live marker may no longer outrank the
|
||||
turn-final visible answer. Every other ``_live`` message folds as before, and
|
||||
the change can only keep a segment visible, never hide or drop one.
|
||||
2. ``renderMessages``: the Worklog may echo an anchor's prose as a ``.wl-reason``
|
||||
row only when that anchor was folded into it —
|
||||
``assistant-segment-worklog-source`` is that proof, and its ``display:none``
|
||||
is the only reason the echo is not a second visible copy. This also covers
|
||||
anchors that escape the fold for other reasons, such as ``_error`` messages,
|
||||
which are never folded.
|
||||
3. The #3875 fail-safe must materialize a deferred settled Worklog (#5839) before
|
||||
judging it empty; otherwise it falls through to the last-resort un-hide,
|
||||
strips the fold off every source segment, and the deferred rows then render
|
||||
the same prose beside them.
|
||||
|
||||
Each contract is a structural/ownership check rather than a rendered-text
|
||||
comparison, and each fails towards keeping content: the first only un-hides, the
|
||||
second only suppresses an echo whose original is provably visible, the third only
|
||||
builds rows earlier.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.js_source_extract import extract_function
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def js(*names):
|
||||
"""Real source of the named ui.js functions — never a re-implementation."""
|
||||
return "\n".join(extract_function(UI_JS, name) for name in names)
|
||||
|
||||
|
||||
def _function_body(src: str, name: str) -> str:
|
||||
marker = f"function {name}("
|
||||
start = src.find(marker)
|
||||
assert start != -1, f"{name} not found"
|
||||
brace = src.find("{", start)
|
||||
assert brace != -1, f"{name} body not found"
|
||||
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"{name} body not closed")
|
||||
|
||||
|
||||
def run_node(script):
|
||||
out = subprocess.run([NODE, "-e", script], capture_output=True, text=True)
|
||||
assert out.returncode == 0, f"node harness failed:\n{out.stderr}\n{out.stdout}"
|
||||
assert "OK" in out.stdout, out.stdout
|
||||
return out.stdout
|
||||
|
||||
|
||||
HARNESS = """
|
||||
const assert=require('assert');
|
||||
var window={};
|
||||
function _messageHasReasoningPayload(m){ return !!(m&&(m.reasoning||m.reasoning_content)); }
|
||||
const assistant=(content,extra)=>Object.assign({role:'assistant',content},extra||{});
|
||||
"""
|
||||
|
||||
|
||||
def test_live_marker_guard_precedes_the_live_fold_rule():
|
||||
"""Contract 1, structurally: the guard sits before the live fold rule.
|
||||
|
||||
Written as an added guard rather than an edit to `if(m._live) return true;`
|
||||
so the live rule keeps the exact shape and ordering pinned by
|
||||
tests/test_live_to_final_anchor_visible_order.py.
|
||||
"""
|
||||
belongs = _function_body(UI_JS, "_assistantMessageBelongsInWorklog")
|
||||
guard = "if(m._live&&hasVisibleText&&isTurnFinalAssistant) return false;"
|
||||
assert guard in belongs, (
|
||||
"a settled-render live remnant must not fold the turn's final visible answer"
|
||||
)
|
||||
assert belongs.index(guard) < belongs.index("if(m._live) return true;"), (
|
||||
"the guard must be evaluated before the unconditional live fold"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_stale_live_marker_never_folds_the_turn_final_answer():
|
||||
"""The fold must not swallow the answer on a settled-render live remnant."""
|
||||
script = HARNESS + js(
|
||||
"msgContent",
|
||||
"_isAssistantEmptyPlaceholderContent",
|
||||
"_assistantMessageBelongsInWorklog",
|
||||
) + """
|
||||
const FINAL={isTurnFinalAssistant:true};
|
||||
const MID={isTurnFinalAssistant:false};
|
||||
const owners=new Set([0]);
|
||||
|
||||
// The bug: the settled turn-final answer folds on a stale live marker.
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(assistant('Operation interrupted.',{_live:true}),0,owners,undefined,FINAL),
|
||||
false,
|
||||
'a settled turn-final notice must never be folded into the Worklog (#2051)');
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(
|
||||
assistant('Here is the image you asked for.\\n\\nMEDIA:/tmp/example.png',{_live:true}),0,owners,undefined,FINAL),
|
||||
false,
|
||||
'a MEDIA-bearing turn-final answer must never be folded either');
|
||||
// Renderer-independent: the decision must not depend on what the text looks like.
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(
|
||||
assistant('Here you go:\\n\\n```python\\nprint(1)\\n```',{_live:true}),0,owners,undefined,FINAL),
|
||||
false,
|
||||
'a fenced-code turn-final answer must never be folded');
|
||||
|
||||
// Everything else about the fold is unchanged.
|
||||
// A live intermediate segment still folds — that is the Worklog's content.
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(assistant('Reading the files now.',{_live:true}),0,owners,undefined,MID),
|
||||
true,
|
||||
'a live intermediate segment must still fold into the Worklog');
|
||||
// A live turn-final message with no visible text still folds (an empty tool-call
|
||||
// anchor is Worklog material, not an answer).
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(assistant('',{_live:true}),0,owners,undefined,FINAL),
|
||||
true,
|
||||
'an empty live anchor must still fold');
|
||||
// `(empty)` placeholder + reasoning payload is not visible text either.
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(
|
||||
assistant('(empty)',{_live:true,reasoning:'thought'}),0,owners,undefined,FINAL),
|
||||
true,
|
||||
'an (empty) placeholder anchor must still fold');
|
||||
// Settled (no marker at all) is untouched in both directions.
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(assistant('Operation interrupted.'),0,owners,undefined,FINAL),
|
||||
false, 'settled turn-final answer: unchanged');
|
||||
// (A settled message with visible text and no live/burst marker never folds —
|
||||
// `if(hasVisibleText) return false;` precedes the tool-metadata rule. Pinned so a
|
||||
// future change to the live-marker line cannot quietly alter this ordering.)
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(assistant('Reading the files now.'),0,owners,undefined,MID),
|
||||
false, 'settled visible intermediate without a live/burst marker: unchanged');
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(assistant('',{}),0,owners,undefined,MID),
|
||||
true, 'settled empty tool-call anchor still folds: unchanged');
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(assistant('Done.'),0,new Set(),undefined,MID),
|
||||
false, 'settled intermediate with no tool metadata: unchanged');
|
||||
// An error message is still never folded.
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(assistant('**Error:** boom',{_error:true,_live:true}),0,owners,undefined,MID),
|
||||
false, 'an error message is never folded');
|
||||
// Live burst/segment markers on a non-final message still fold.
|
||||
assert.strictEqual(
|
||||
_assistantMessageBelongsInWorklog(assistant('mid',{_activityBurstId:2}),0,new Set(),undefined,MID),
|
||||
true, 'activity-burst anchors still fold');
|
||||
console.log('OK');
|
||||
"""
|
||||
run_node(script)
|
||||
|
||||
|
||||
def test_worklog_reason_echo_requires_a_folded_anchor():
|
||||
"""Contract 2: only a folded anchor may be echoed as a .wl-reason row."""
|
||||
body = _function_body(UI_JS, "renderMessages")
|
||||
assert "includeAnchorReason:!!includeAnchorReason&&!!anchorReasonHtml&&!!anchorIsWorklogSource," in body, (
|
||||
"the settled Worklog step may only re-render an anchor's prose when that "
|
||||
"anchor was folded into it (assistant-segment-worklog-source); without "
|
||||
"that proof the row is a second visible copy of a visible segment"
|
||||
)
|
||||
# The ownership fact must be computed for every entry, not only for the first
|
||||
# entry of a turn (where the group is constructed) — otherwise the append path
|
||||
# would read a stale/undefined value for later anchors in the same turn.
|
||||
hoisted = body.find(
|
||||
"const anchorIsWorklogSource=anchorRow.classList"
|
||||
"&&anchorRow.classList.contains('assistant-segment-worklog-source');"
|
||||
)
|
||||
state_lookup = body.find("let state=activityByTurn.get(anchorTurn);")
|
||||
assert hoisted != -1 and state_lookup != -1, "settled worklog append path not found"
|
||||
assert hoisted < state_lookup, (
|
||||
"anchorIsWorklogSource must be computed per activity entry (before the "
|
||||
"per-turn `state` lookup), not only inside the group-construction branch"
|
||||
)
|
||||
|
||||
|
||||
def test_blank_turn_failsafe_materializes_deferred_rows_before_judging_empty():
|
||||
"""Contract 3: a deferred (#5839) Worklog is not empty."""
|
||||
body = _function_body(UI_JS, "renderMessages")
|
||||
failsafe = body[body.find("Fail-safe invariant (#3875)"):]
|
||||
assert failsafe, "the #3875 fail-safe is missing"
|
||||
materialize = failsafe.find("data-worklog-rows-deferred")
|
||||
empty_check = failsafe.find("continue; // empty group can't help")
|
||||
assert materialize != -1, (
|
||||
"the fail-safe must materialize a deferred settled Worklog before judging "
|
||||
"it empty — otherwise it un-hides every folded segment and the deferred "
|
||||
"rows later render the same prose beside them"
|
||||
)
|
||||
assert materialize < empty_check, (
|
||||
"deferred rows must be materialized before the empty-group check"
|
||||
)
|
||||
# The last-resort un-hide must still exist: content is never lost.
|
||||
assert "seg.classList.remove('assistant-segment-worklog-source');" in failsafe, (
|
||||
"the last-resort un-hide must remain — a duplicate is preferable to a "
|
||||
"blank turn"
|
||||
)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Regression: the composer model picker lists one configured model twice (#2051).
|
||||
|
||||
The picker showed two rows for a single configured model, ``acme/example-model``
|
||||
and ``custom/acme/example-model``, while ``GET /api/models`` returned a ``Custom``
|
||||
group holding exactly one model and ``#modelSelect`` ended up with exactly one
|
||||
matching ``<option>``. Both rows are produced client-side.
|
||||
|
||||
``configured_model_badges`` carries every routing spelling of a configured model.
|
||||
For one custom-provider model that is three keys::
|
||||
|
||||
acme/example-model (bare/canonical)
|
||||
custom/acme/example-model (provider-qualified)
|
||||
@custom:acme/example-model (routable)
|
||||
|
||||
``renderModelDropdown()`` walks that map and pushes a synthetic ``_modelData``
|
||||
row for every key that ``_isEquivalentConfiguredModelEntry()`` does not recognise
|
||||
as an alias of a row the catalog already produced. That predicate recognised two
|
||||
of the three spellings; it missed ``<provider>/<model>`` because
|
||||
``_normalizeConfiguredModelKey()`` strips only one leading slash segment —
|
||||
deliberately, since #3360 requires ``vendor_a/x`` and ``vendor_b/y/x`` to stay
|
||||
distinct. So::
|
||||
|
||||
acme/example-model -> example.model
|
||||
custom/acme/example-model -> acme/example.model (different key)
|
||||
|
||||
No match, so the alias was pushed as a second row: one model, two picker entries.
|
||||
``_deduplicateModelPickerOptions()`` cannot catch this — it dedupes ``<option>``
|
||||
elements inside ``<select>``, and this row never becomes an ``<option>``.
|
||||
|
||||
The contract pinned here: ``<provider>/<model>`` is recognised as an alias of
|
||||
``<model>`` under the same proof the ``@<provider>:<model>`` rule already uses.
|
||||
The badge declares a provider, the key begins with that provider's prefix, and an
|
||||
existing picker row from that same provider normalises equal to the remainder.
|
||||
Two genuinely different models never satisfy the last clause, so the change can
|
||||
only drop a duplicate — never hide a distinct model.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.js_source_extract import extract_function
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def js(*names):
|
||||
"""Real source of the named ui.js functions — never a re-implementation."""
|
||||
return "\n".join(extract_function(UI_JS, name) for name in names)
|
||||
|
||||
|
||||
def run_node(script):
|
||||
out = subprocess.run([NODE, "-e", script], capture_output=True, text=True)
|
||||
assert out.returncode == 0, f"node harness failed:\n{out.stderr}\n{out.stdout}"
|
||||
assert "OK" in out.stdout, out.stdout
|
||||
return out.stdout
|
||||
|
||||
|
||||
HARNESS = """
|
||||
const assert=require('assert');
|
||||
var window={};
|
||||
"""
|
||||
|
||||
# One Custom group holding one model, with three badge spellings of it.
|
||||
CATALOG_ROW = "{value:'acme/example-model',providerId:'custom'}"
|
||||
BADGE = "{role:'primary',label:'Primary',provider:'custom'}"
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_provider_qualified_badge_alias_is_not_a_second_picker_row():
|
||||
"""All three spellings of one configured model collapse to one row (#2051)."""
|
||||
script = HARNESS + js(
|
||||
"_normalizeConfiguredModelKey",
|
||||
"_isEquivalentConfiguredModelEntry",
|
||||
) + f"""
|
||||
const entries=[{CATALOG_ROW}];
|
||||
const badge={BADGE};
|
||||
|
||||
// All three routing spellings name the same model the catalog already rendered.
|
||||
for(const alias of ['acme/example-model',
|
||||
'custom/acme/example-model',
|
||||
'@custom:acme/example-model']){{
|
||||
assert.strictEqual(_isEquivalentConfiguredModelEntry(alias,badge,entries), true,
|
||||
'badge alias '+alias+' must not become a second picker row (#2051)');
|
||||
}}
|
||||
console.log('OK');
|
||||
"""
|
||||
run_node(script)
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_a_genuinely_different_model_is_never_suppressed():
|
||||
"""The new rule must not hide a model the catalog does not have."""
|
||||
script = HARNESS + js(
|
||||
"_normalizeConfiguredModelKey",
|
||||
"_isEquivalentConfiguredModelEntry",
|
||||
) + f"""
|
||||
const entries=[{CATALOG_ROW}];
|
||||
const badge={BADGE};
|
||||
|
||||
// Same provider prefix, different model -> must still render its own row.
|
||||
assert.strictEqual(
|
||||
_isEquivalentConfiguredModelEntry('custom/acme/other-model',badge,entries), false,
|
||||
'a different model under the same provider must keep its own row');
|
||||
|
||||
// Right model name, but no catalog row owned by that provider -> not proven
|
||||
// equivalent, so keep it.
|
||||
assert.strictEqual(
|
||||
_isEquivalentConfiguredModelEntry(
|
||||
'custom/acme/example-model',
|
||||
{{role:'primary',label:'Primary',provider:'otherprovider'}},
|
||||
[{{value:'acme/example-model',providerId:'custom'}}]), false,
|
||||
'a same-named model owned by a different provider must keep its own row');
|
||||
|
||||
// A badge with no provider cannot use the prefix proof at all.
|
||||
assert.strictEqual(
|
||||
_isEquivalentConfiguredModelEntry('custom/acme/example-model',
|
||||
{{role:'primary',label:'Primary'}},entries), false,
|
||||
'a provider-less badge must not be collapsed by the prefix rule');
|
||||
|
||||
// The prefix must be a real path segment, not a substring of the first segment.
|
||||
assert.strictEqual(
|
||||
_isEquivalentConfiguredModelEntry('customer/acme/example-model',badge,entries), false,
|
||||
'only a literal `<provider>/` prefix counts');
|
||||
console.log('OK');
|
||||
"""
|
||||
run_node(script)
|
||||
|
||||
|
||||
def test_prefix_rule_mirrors_the_at_provider_rule_structurally():
|
||||
"""The `<provider>/` proof must require the same three clauses as `@<provider>:`.
|
||||
|
||||
Structural, not behavioural: it pins that the new branch is gated on the
|
||||
badge-declared provider, on a literal prefix match, and on an existing entry
|
||||
of that same provider — so it cannot grow into a loose label comparison.
|
||||
"""
|
||||
src = extract_function(UI_JS, "_isEquivalentConfiguredModelEntry")
|
||||
assert "const slashPrefix=provider?`${provider}/`:'';" in src, (
|
||||
"the provider-qualified alias rule must derive its prefix from the "
|
||||
"badge-declared provider"
|
||||
)
|
||||
assert "rawId.toLowerCase().startsWith(slashPrefix)" in src, (
|
||||
"the rule must require a literal `<provider>/` prefix on the badge key"
|
||||
)
|
||||
assert "String(entry.providerId||'').toLowerCase()===provider" in src, (
|
||||
"the rule must require an existing picker row owned by the same provider"
|
||||
)
|
||||
# It must run before the `@provider:` early return, which would otherwise
|
||||
# short-circuit every non-`@` alias to false.
|
||||
assert src.index("slashPrefix") < src.index(
|
||||
"if(!prefix||!rawId.toLowerCase().startsWith(prefix)) return false;"
|
||||
), "the `<provider>/` rule must be reached before the `@<provider>:` early return"
|
||||
@@ -38,7 +38,10 @@ def test_footer_timestamp_uses_richer_format_for_older_messages():
|
||||
|
||||
def test_timestamp_footer_stays_on_visible_response_segments():
|
||||
assert "if(hasVisibleBody){" in UI_JS
|
||||
assert 'seg.insertAdjacentHTML(\'beforeend\', `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);' in UI_JS, (
|
||||
# #2051: the block goes in through _insertSegmentBlock() rather than
|
||||
# seg.insertAdjacentHTML(), so a wrapped DOM API cannot render it twice. The footer
|
||||
# still rides in the same block, which is what this test is about.
|
||||
assert '_insertSegmentBlock(seg, `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);' in UI_JS, (
|
||||
"Footer timestamp should stay attached to visible response segments"
|
||||
)
|
||||
assert "assistantThinking.set(rawIdx, thinkingText);" in UI_JS, (
|
||||
|
||||
@@ -87,6 +87,13 @@ function makeSeg() {{
|
||||
}};
|
||||
}}
|
||||
|
||||
// #2051: renderMessages() inserts a message block through this helper so a wrapped
|
||||
// DOM API cannot render it twice. Outside a browser the helper's own fallback is
|
||||
// insertAdjacentHTML, which is exactly what it does here.
|
||||
function _insertSegmentBlock(seg, html) {{
|
||||
seg.insertAdjacentHTML('beforeend', html);
|
||||
}}
|
||||
|
||||
const ordinaryBlock = extractBlock(
|
||||
"const hasVisibleBody=!!(String(content||'').trim()||filesHtml||recoveryHtml);",
|
||||
"_assistantTurnBlocks(currentAssistantTurn).appendChild(seg);"
|
||||
|
||||
Reference in new issue
Block a user