diff --git a/static/ui.js b/static/ui.js
index 6320392e..842bdabe 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -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(`${esc(durationText)}`);
- if(modelText) parts.push(`${esc(modelText)}`);
+ if(modelText){
+ const titleAttr=(modelTitle&&modelTitle!==modelText)?` title="${esc(modelTitle)}"`:'';
+ parts.push(`${esc(modelText)}`);
+ }
if(ttftText) parts.push(`TTFT ${esc(ttftText)}`);
if(tokensText) parts.push(`${esc(tokensText)}`);
if(statusText) parts.push(`${esc(statusText)}`);
@@ -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',
diff --git a/tests/test_issue6068_used_model_footer.py b/tests/test_issue6068_used_model_footer.py
index 6a32ac25..7ba04c53 100644
--- a/tests/test_issue6068_used_model_footer.py
+++ b/tests/test_issue6068_used_model_footer.py
@@ -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"]