Merge master (exp-v0.52.344) into stage/7436-reconnect-replay

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
nesquena-hermes committed 2026-09-23 03:21:30 +00:00
commit 394ff790e5
10 files changed
+1040 -7

No files matched your search

+5
View File
@@ -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"
+4 -1
View File
@@ -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, (
+7
View File
@@ -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);"