fix: resolve transparent footer metadata from final segment (#6068 gate r2)
A tool-using Transparent Stream turn renders multiple assistant segments
(activity segment(s) then the final answer). Turn metadata — including
_usedModel — is stamped only on the final assistant message, but the
transparent footer read it via turn.querySelector('.assistant-segment'),
which returns the FIRST segment and thus no metadata. Combined with the
generic footer yielding model ownership to transparent mode, the model
label disappeared entirely on exactly the tool turns where per-turn
attribution matters most.
- Add _transparentTurnMetaMessage(turn): scan .assistant-segment
[data-msg-idx] last→first for the final metadata-bearing assistant
message, falling back to the last assistant segment.
- Use it for the transparent footer's duration/model/TTFT/usage instead
of querySelector's first match.
- Non-blocking UX (per gate): preserve the full uncompacted model id in a
hover title on both the transparent .lf-model span and the settled
.msg-used-model-inline chip.
- Tests: behavioral multi-segment harness asserting exactly one .lf-model
(and the correct final-segment metadata + hover title), plus wiring
checks for the segment-scan helper.
This commit is contained in:
1 parent
9bf876598d
commit
34cc5dbeb2
2 files changed
+132
-18
No files matched your search
+49
-17
@@ -11761,14 +11761,40 @@ function _applyTransparentRowFading(turn){
|
||||
row.setAttribute('data-transparent-fade',String(step));
|
||||
}
|
||||
}
|
||||
// Resolve the assistant message that carries a transparent turn's settled
|
||||
// metadata (duration / used model / TTFT / usage). A tool-using turn renders
|
||||
// multiple assistant segments — earlier activity segment(s) then the final
|
||||
// answer — and the metadata is stamped only on the LAST assistant message
|
||||
// (see api/streaming.py finalization). `turn.querySelector('.assistant-segment')`
|
||||
// returns the FIRST segment, which has no metadata, so the footer (model chip
|
||||
// included) would render empty exactly on tool turns (#6068 gate round 2).
|
||||
// Scan segments last→first for the final metadata-bearing assistant message,
|
||||
// falling back to the last assistant segment when none carries metadata yet.
|
||||
function _transparentTurnMetaMessage(turn){
|
||||
if(!turn)return null;
|
||||
const segs=turn.querySelectorAll('.assistant-segment[data-msg-idx]');
|
||||
let fallback=null;
|
||||
for(let si=segs.length-1;si>=0;si--){
|
||||
const mi=segs[si].getAttribute('data-msg-idx');
|
||||
if(mi==null)continue;
|
||||
const candidate=S.messages[Number(mi)];
|
||||
if(!candidate||candidate.role!=='assistant')continue;
|
||||
if(!fallback)fallback=candidate;
|
||||
if(candidate._turnDuration!=null||candidate._usedModel||candidate._firstTokenMs!=null||candidate._turnUsage)return candidate;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
// ── Transparent turn footer (elapsed · tokens · TTFT · status) ───────────
|
||||
// Mirrors the live run-status line for settled turns in transparent
|
||||
// mode. Shows duration, first-token time, token usage, and final status.
|
||||
// Only rendered for turns that have transparent event rows.
|
||||
function _transparentTurnFooterHtml(durationText, modelText, ttftText, tokensText, statusText){
|
||||
function _transparentTurnFooterHtml(durationText, modelText, ttftText, tokensText, statusText, modelTitle){
|
||||
const parts=[];
|
||||
if(durationText) parts.push(`<span class="lf-time">${esc(durationText)}</span>`);
|
||||
if(modelText) parts.push(`<span class="lf-model">${esc(modelText)}</span>`);
|
||||
if(modelText){
|
||||
const titleAttr=(modelTitle&&modelTitle!==modelText)?` title="${esc(modelTitle)}"`:'';
|
||||
parts.push(`<span class="lf-model"${titleAttr}>${esc(modelText)}</span>`);
|
||||
}
|
||||
if(ttftText) parts.push(`<span class="lf-ttft" title="${esc(t('first_token_time')||'Time to first token')}">TTFT ${esc(ttftText)}</span>`);
|
||||
if(tokensText) parts.push(`<span class="lf-tokens">${esc(tokensText)}</span>`);
|
||||
if(statusText) parts.push(`<span class="lf-status">${esc(statusText)}</span>`);
|
||||
@@ -11788,10 +11814,11 @@ function _renderTransparentTurnFooter(turn, opts){
|
||||
}
|
||||
const durationText=opts&&opts.durationText||'';
|
||||
const modelText=opts&&opts.modelText||'';
|
||||
const modelTitle=opts&&opts.modelTitle||'';
|
||||
const ttftText=opts&&opts.ttftText||'';
|
||||
const tokensText=opts&&opts.tokensText||'';
|
||||
const statusText=opts&&opts.statusText||(t('done')||'Done');
|
||||
const html=_transparentTurnFooterHtml(durationText, modelText, ttftText, tokensText, statusText);
|
||||
const html=_transparentTurnFooterHtml(durationText, modelText, ttftText, tokensText, statusText, modelTitle);
|
||||
let footer=turn.querySelector('.transparent-turn-footer');
|
||||
if(!html){
|
||||
if(footer) footer.remove();
|
||||
@@ -16652,6 +16679,9 @@ function renderMessages(options){
|
||||
const usedModel=document.createElement('span');
|
||||
usedModel.className='msg-used-model-inline';
|
||||
usedModel.textContent=usedModelText;
|
||||
// Preserve the full (uncompacted) model id on hover where available.
|
||||
const usedModelFull=String(msg._usedModel||'').trim();
|
||||
if(usedModelFull&&usedModelFull!==usedModelText) usedModel.title=usedModelFull;
|
||||
fragments.push(usedModel);
|
||||
}
|
||||
if(window._showTokenUsage&&hasTurnUsage){
|
||||
@@ -16701,29 +16731,31 @@ function renderMessages(options){
|
||||
}
|
||||
_applyTransparentRowFading(turn);
|
||||
if(hasTransparentRows){
|
||||
// Find the corresponding message to read duration/usage.
|
||||
const seg=turn.querySelector('.assistant-segment');
|
||||
// Read turn metadata from the final metadata-bearing assistant segment,
|
||||
// not querySelector's first match — a tool turn's activity segment
|
||||
// precedes the answer, and the metadata lives on the last message
|
||||
// (#6068 gate round 2: multi-segment turns lost the model label).
|
||||
const msg=_transparentTurnMetaMessage(turn);
|
||||
let durationText='';
|
||||
let modelText='';
|
||||
let modelTitle='';
|
||||
let ttftText='';
|
||||
let tokensText='';
|
||||
if(seg){
|
||||
const mi=seg.getAttribute('data-msg-idx');
|
||||
if(mi!=null){
|
||||
const msg=S.messages[Number(mi)]||{};
|
||||
if(msg._turnDuration!=null) durationText=_formatTurnDuration(msg._turnDuration);
|
||||
modelText=_usedModelTurnChipLabel(msg);
|
||||
if(msg._firstTokenMs!=null) ttftText=_formatFirstToken(msg._firstTokenMs);
|
||||
if(msg._turnUsage){
|
||||
const inTok=msg._turnUsage.input_tokens||0;
|
||||
const outTok=msg._turnUsage.output_tokens||0;
|
||||
tokensText=`${_fmtTokens(inTok)} in · ${_fmtTokens(outTok)} out`;
|
||||
}
|
||||
if(msg){
|
||||
if(msg._turnDuration!=null) durationText=_formatTurnDuration(msg._turnDuration);
|
||||
modelText=_usedModelTurnChipLabel(msg);
|
||||
if(modelText) modelTitle=String(msg._usedModel||'').trim();
|
||||
if(msg._firstTokenMs!=null) ttftText=_formatFirstToken(msg._firstTokenMs);
|
||||
if(msg._turnUsage){
|
||||
const inTok=msg._turnUsage.input_tokens||0;
|
||||
const outTok=msg._turnUsage.output_tokens||0;
|
||||
tokensText=`${_fmtTokens(inTok)} in · ${_fmtTokens(outTok)} out`;
|
||||
}
|
||||
}
|
||||
_renderTransparentTurnFooter(turn,{
|
||||
durationText,
|
||||
modelText,
|
||||
modelTitle,
|
||||
ttftText,
|
||||
tokensText,
|
||||
statusText: t('done')||'Done',
|
||||
|
||||
@@ -81,6 +81,63 @@ console.log(JSON.stringify(cases));
|
||||
return json.loads(_run_node(source))
|
||||
|
||||
|
||||
def _eval_transparent_multi_segment_footer_cases() -> dict:
|
||||
"""Drive the transparent footer for a realistic multi-segment tool turn.
|
||||
|
||||
Turn shape: user -> assistant tool/activity segment (no metadata) -> tool
|
||||
result -> final assistant carrying _usedModel/_turnDuration. The footer must
|
||||
resolve metadata from the FINAL segment (not querySelector's first match) so
|
||||
the model label renders exactly once as .lf-model. Regression for the #6068
|
||||
round-2 gate where multi-segment turns dropped the label entirely.
|
||||
"""
|
||||
ui_js = UI_JS_PATH.read_text(encoding="utf-8")
|
||||
source = f"""
|
||||
const src = {ui_js!r};
|
||||
function extractFunc(name) {{
|
||||
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
||||
const start = src.search(re);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
let i = src.indexOf('{{', start);
|
||||
let depth = 1; i++;
|
||||
while (depth > 0 && i < src.length) {{
|
||||
if (src[i] === '{{') depth++;
|
||||
else if (src[i] === '}}') depth--;
|
||||
i++;
|
||||
}}
|
||||
return src.slice(start, i);
|
||||
}}
|
||||
function getModelLabel(modelId) {{ return String(modelId || 'Unknown'); }}
|
||||
function esc(s) {{ return String(s == null ? '' : s); }}
|
||||
function t() {{ return ''; }}
|
||||
// Multi-segment tool turn: idx 1 is the activity segment (no metadata),
|
||||
// idx 2 is the final answer that carries the stamped turn metadata.
|
||||
const S = {{ messages: [
|
||||
{{ role: 'user', content: 'hi' }},
|
||||
{{ role: 'assistant', content: '', _toolCalls: [{{}}] }},
|
||||
{{ role: 'assistant', content: 'answer', _usedModel: 'anthropic/claude-sonnet-4.5', _turnDuration: 1.2 }},
|
||||
] }};
|
||||
function FakeSeg(idx) {{ return {{ getAttribute(name) {{ return name === 'data-msg-idx' ? String(idx) : null; }} }}; }}
|
||||
const turn = {{ querySelectorAll(sel) {{ return [FakeSeg(1), FakeSeg(2)]; }} }};
|
||||
eval(extractFunc('_compactComposerModelChipLabel'));
|
||||
eval(extractFunc('_usedModelTurnChipLabel'));
|
||||
eval(extractFunc('_transparentTurnMetaMessage'));
|
||||
eval(extractFunc('_transparentTurnFooterHtml'));
|
||||
const picked = _transparentTurnMetaMessage(turn);
|
||||
const modelText = _usedModelTurnChipLabel(picked || {{}});
|
||||
const modelTitle = picked ? String(picked._usedModel || '') : '';
|
||||
const html = _transparentTurnFooterHtml('1.2s', modelText, '', '', 'Done', modelTitle);
|
||||
const lfModelCount = (html.match(/lf-model/g) || []).length;
|
||||
console.log(JSON.stringify({{
|
||||
pickedUsedModel: picked ? picked._usedModel : null,
|
||||
pickedContent: picked ? picked.content : null,
|
||||
modelText,
|
||||
lfModelCount,
|
||||
hasTitle: html.indexOf('title="anthropic/claude-sonnet-4.5"') >= 0,
|
||||
}}));
|
||||
"""
|
||||
return json.loads(_run_node(source))
|
||||
|
||||
|
||||
def test_streaming_stamps_used_model_on_assistant_message_and_usage_payload():
|
||||
assert "_dm['_usedModel'] = _used_model" in STREAMING_PY
|
||||
# The served model must be read from the agent AFTER the run — the agent
|
||||
@@ -150,7 +207,32 @@ def test_settled_footer_orders_model_chip_after_duration():
|
||||
|
||||
|
||||
def test_transparent_turn_footer_includes_model_between_duration_and_ttft():
|
||||
assert "function _transparentTurnFooterHtml(durationText, modelText, ttftText, tokensText, statusText)" in UI_JS
|
||||
assert "function _transparentTurnFooterHtml(durationText, modelText, ttftText, tokensText, statusText, modelTitle)" in UI_JS
|
||||
assert 'class="lf-model"' in UI_JS
|
||||
assert "modelText=_usedModelTurnChipLabel(msg)" in UI_JS
|
||||
assert ".transparent-turn-footer .lf-model" in STYLE_CSS
|
||||
|
||||
|
||||
def test_transparent_footer_reads_metadata_from_final_segment_not_first():
|
||||
# The transparent footer must resolve turn metadata via the dedicated
|
||||
# last→first segment scan, never querySelector's first-match, which would
|
||||
# read the activity segment and drop the model label on tool turns.
|
||||
assert "const msg=_transparentTurnMetaMessage(turn);" in UI_JS
|
||||
assert "function _transparentTurnMetaMessage(turn)" in UI_JS
|
||||
# The generic footer yields the model chip to the transparent footer.
|
||||
assert "_transparentFooterOwnsModel" in UI_JS
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_transparent_multi_segment_tool_turn_renders_exactly_one_model_label():
|
||||
"""#6068 gate round 2: multi-segment tool turns must show the model once."""
|
||||
cases = _eval_transparent_multi_segment_footer_cases()
|
||||
# Metadata resolved from the FINAL assistant segment, not the first.
|
||||
assert cases["pickedUsedModel"] == "anthropic/claude-sonnet-4.5"
|
||||
assert cases["pickedContent"] == "answer"
|
||||
assert cases["modelText"] # non-empty label
|
||||
# Exactly one .lf-model in the transparent footer (the generic chip is
|
||||
# suppressed by _transparentFooterOwnsModel), so no duplicate and no loss.
|
||||
assert cases["lfModelCount"] == 1
|
||||
# Non-blocking UX: full model id preserved in a hover title.
|
||||
assert cases["hasTitle"]
|
||||
Reference in new issue
Block a user