Merge pull request #6439 from nesquena/stage/6113
Release: #6113 per-turn used-model chip in transparent footer
This commit is contained in:
commit
f26abae544
7 changed files with 336 additions and 18 deletions
|
|
@ -17,6 +17,10 @@
|
|||
|
||||
- **The busy-time send behavior is now called "Default message mode," and new installs default to Steer.** The Settings → Preferences control formerly labeled "Busy input mode" is renamed to "Default message mode," and a fresh install now defaults to **Steer** (inject a mid-turn correction without interrupting) instead of Queue. Your existing choice is preserved — if you ever saved settings, your current mode (Queue/Interrupt/Steer) is migrated as-is and unchanged; only never-configured installs pick up the new Steer default. The saved preference still survives a reload or a brief server outage (the localStorage mirror from the previous release is intact). Thanks @rodboev. (#5162, #5145)
|
||||
|
||||
### Added
|
||||
|
||||
- **Each settled assistant turn's footer now shows the model that actually served it.** In transparent-stream mode the turn footer gains a compact model chip (e.g. `8s · claude-opus-4-8 · TTFT 640ms · …`), read *after* the turn completes so a mid-turn fallback shows the real model that answered rather than the one originally requested. The label renders exactly once per turn — a gateway/failover turn keeps its existing routing chip and the additive chip is suppressed — and the footer wraps gracefully at narrow widths and on mobile instead of stretching. The used-model is persisted with the session so it survives a reload. Thanks @franksong2702. (#6113, #6068)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The transcript no longer jumps when a streaming reply settles while you're reading earlier messages.** If you scrolled up to read history and a streaming assistant reply finished (collapsing its expanded worklog to the settled compact view), the viewport could lurch away from what you were reading. The scroll anchor is now captured from the live DOM *before* the collapse render and reused, so your position is preserved through settlement. Readers pinned to the bottom still auto-follow to the newest reply as before. Thanks @franksong2702. (#6390, #6385)
|
||||
|
|
|
|||
|
|
@ -7989,6 +7989,7 @@ _SESSION_MESSAGE_DISPLAY_METADATA_KEYS = (
|
|||
"_turnTps",
|
||||
"_turnUsage",
|
||||
"_firstTokenMs",
|
||||
"_usedModel",
|
||||
"_gatewayRouting",
|
||||
"_statusCard",
|
||||
"_anchor_stream_id",
|
||||
|
|
|
|||
|
|
@ -9931,6 +9931,11 @@ def _run_agent_streaming(
|
|||
requested_model=resolved_model or model,
|
||||
requested_provider=resolved_provider,
|
||||
)
|
||||
# #6068: the served model must be read AFTER agent.run — the agent
|
||||
# mutates agent.model when a fallback fires, so the pre-run
|
||||
# resolved_model would mis-attribute exactly the turns where
|
||||
# attribution matters most.
|
||||
_used_model = getattr(agent, 'model', None) or resolved_model or model
|
||||
if _gateway_routing:
|
||||
s.gateway_routing = _gateway_routing
|
||||
_history = list(getattr(s, 'gateway_routing_history', None) or [])
|
||||
|
|
@ -9947,6 +9952,8 @@ def _run_agent_streaming(
|
|||
_ttft_ms = meter().get_ttft_ms(stream_id)
|
||||
if _ttft_ms is not None:
|
||||
_dm['_firstTokenMs'] = _ttft_ms
|
||||
if _used_model:
|
||||
_dm['_usedModel'] = _used_model
|
||||
break
|
||||
# Persist context window data on the session so the context-ring
|
||||
# indicator survives a page reload (#1318). Must run BEFORE
|
||||
|
|
@ -10334,6 +10341,8 @@ def _run_agent_streaming(
|
|||
_ttft_ms = meter().get_ttft_ms(stream_id)
|
||||
if _ttft_ms is not None:
|
||||
usage['ttft_ms'] = _ttft_ms
|
||||
if _used_model:
|
||||
usage['used_model'] = _used_model
|
||||
# Include context window data from the agent's compressor for the UI indicator.
|
||||
# The session-level persistence happens above (before s.save()) so the values
|
||||
# survive a page reload; this block only populates the live SSE usage payload.
|
||||
|
|
|
|||
|
|
@ -4411,6 +4411,7 @@ body.resizing .sidebar{transition:none!important;}
|
|||
.transparent-turn-footer .lf-time{color:var(--text);}
|
||||
.transparent-turn-footer .lf-sep{opacity:.4;}
|
||||
.transparent-turn-footer .lf-status{color:var(--muted);opacity:.85;}
|
||||
.transparent-turn-footer .lf-model{color:var(--muted);opacity:.85;}
|
||||
.transparent-turn-footer .lf-ttft{color:var(--accent);opacity:.9;}
|
||||
.transparent-turn-footer .lf-tokens{font-variant-numeric:tabular-nums;}
|
||||
|
||||
|
|
@ -6110,6 +6111,7 @@ main.main > #mainPlugin{display:none;}
|
|||
.msg-usage-inline,
|
||||
.msg-duration-inline,
|
||||
.msg-gateway-inline,
|
||||
.msg-used-model-inline,
|
||||
.gateway-failover-inline,
|
||||
.msg-model-warning-inline {
|
||||
font-size: 11px;
|
||||
|
|
|
|||
99
static/ui.js
99
static/ui.js
|
|
@ -7030,6 +7030,17 @@ function _formatGatewayModelLabel(modelId,labelText,routing){
|
|||
const via=_gatewayRoutingLabel(routing);
|
||||
return via?`${base} ${via}`:base;
|
||||
}
|
||||
function _usedModelTurnChipLabel(msg){
|
||||
if(!msg)return'';
|
||||
// Gateway turns own their model label via _formatGatewayModelLabel (which
|
||||
// falls back to msg._usedModel when routing omits used_model), so suppress
|
||||
// the additive chip whenever routing metadata is present — not only when
|
||||
// routing.used_model is set — to guarantee one model label per turn.
|
||||
if(msg._gatewayRouting)return'';
|
||||
const usedModel=String(msg._usedModel||'').trim();
|
||||
if(!usedModel)return'';
|
||||
return _compactComposerModelChipLabel(usedModel,getModelLabel(usedModel));
|
||||
}
|
||||
function _gatewayRoutingFailoverText(routing){
|
||||
if(!routing||!routing.has_failover)return'';
|
||||
const attempts=Array.isArray(routing.routing)?routing.routing:[];
|
||||
|
|
@ -11750,13 +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, 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){
|
||||
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>`);
|
||||
|
|
@ -11775,10 +11813,12 @@ function _renderTransparentTurnFooter(turn, opts){
|
|||
return;
|
||||
}
|
||||
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, 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();
|
||||
|
|
@ -16817,7 +16857,7 @@ function renderMessages(options){
|
|||
const msg=S.messages[mi]||{};
|
||||
if(msg.role!=='assistant') continue;
|
||||
const routing=msg._gatewayRouting||null;
|
||||
const gatewayText=_formatGatewayModelLabel(S.session&&S.session.model||'', '', routing);
|
||||
const gatewayText=_formatGatewayModelLabel(String(msg._usedModel||'').trim()||(S.session&&S.session.model)||'', '', routing);
|
||||
const failoverText=_gatewayRoutingFailoverText(routing);
|
||||
const modelWarningText=_gatewayModelWarningText(routing);
|
||||
const hasTurnUsage=!!msg._turnUsage;
|
||||
|
|
@ -16826,12 +16866,13 @@ function renderMessages(options){
|
|||
// Worklog above the final answer.
|
||||
const compactWorklogForMessage=isCompactWorklogMode()&&(toolCallAssistantIdxs.has(mi)||assistantThinking.has(mi));
|
||||
const durationText=compactWorklogForMessage?'':_formatTurnDuration(msg._turnDuration);
|
||||
if(!hasTurnUsage&&!durationText&&!gatewayText&&!failoverText&&!modelWarningText) continue;
|
||||
const usedModelText=_usedModelTurnChipLabel(msg);
|
||||
if(!hasTurnUsage&&!durationText&&!gatewayText&&!failoverText&&!modelWarningText&&!usedModelText) continue;
|
||||
const seg=assistantSegments.get(mi);
|
||||
const row=seg?seg.closest('.assistant-turn'):null;
|
||||
const footerRows=row?row.querySelectorAll('.msg-foot'):[];
|
||||
const targetFoot=footerRows.length?footerRows[footerRows.length-1]:null;
|
||||
if(!targetFoot||targetFoot.querySelector('.msg-usage-inline,.msg-duration-inline,.msg-gateway-inline,.gateway-failover-inline,.msg-model-warning-inline')) continue;
|
||||
if(!targetFoot||targetFoot.querySelector('.msg-usage-inline,.msg-duration-inline,.msg-gateway-inline,.gateway-failover-inline,.msg-model-warning-inline,.msg-used-model-inline')) continue;
|
||||
const fragments=[];
|
||||
if(modelWarningText){
|
||||
const warning=document.createElement('span');
|
||||
|
|
@ -16857,6 +16898,23 @@ function renderMessages(options){
|
|||
duration.textContent=`Done in ${durationText}`;
|
||||
fragments.push(duration);
|
||||
}
|
||||
// The transparent turn footer owns the model label (.lf-model) whenever
|
||||
// the turn has transparent event rows — skip the generic chip there so
|
||||
// exactly one model label renders per turn. Model sits after duration to
|
||||
// match the transparent footer order (elapsed · model · …).
|
||||
const _transparentFooterOwnsModel=usedModelText&&isTransparentStream()&&row&&(()=>{
|
||||
const blocks=_assistantTurnBlocks(row);
|
||||
return !!(blocks&&blocks.querySelector(':scope > .transparent-event-row'));
|
||||
})();
|
||||
if(usedModelText&&!_transparentFooterOwnsModel){
|
||||
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){
|
||||
const usage=document.createElement('span');
|
||||
usage.className='msg-usage-inline';
|
||||
|
|
@ -16904,26 +16962,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);
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -680,6 +680,7 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds
|
|||
function _formatGatewayModelLabel() {{ return ''; }}
|
||||
function _gatewayRoutingFailoverText() {{ return ''; }}
|
||||
function _gatewayModelWarningText() {{ return ''; }}
|
||||
function _usedModelTurnChipLabel() {{ return ''; }}
|
||||
function _formatTurnDuration() {{ return ''; }}
|
||||
function _renderSettledAnchorSceneForMessage(message, segment, rawIdx) {{
|
||||
const group = new FakeElement('div');
|
||||
|
|
|
|||
238
tests/test_issue6068_used_model_footer.py
Normal file
238
tests/test_issue6068_used_model_footer.py
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
"""Regression coverage for #6068 per-turn used-model footer instrumentation."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from api.models import Session
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
NODE = shutil.which("node")
|
||||
UI_JS_PATH = REPO / "static" / "ui.js"
|
||||
STREAMING_PY = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
|
||||
MODELS_PY = (REPO / "api" / "models.py").read_text(encoding="utf-8")
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _run_node(source: str) -> str:
|
||||
result = subprocess.run(
|
||||
[NODE],
|
||||
input=source,
|
||||
cwd=str(REPO),
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(result.stderr)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _eval_used_model_turn_chip_label_cases() -> dict:
|
||||
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');
|
||||
}}
|
||||
eval(extractFunc('_compactComposerModelChipLabel'));
|
||||
eval(extractFunc('_usedModelTurnChipLabel'));
|
||||
eval(extractFunc('_gatewayProviderName'));
|
||||
eval(extractFunc('_gatewayRoutingLabel'));
|
||||
eval(extractFunc('_formatGatewayModelLabel'));
|
||||
const modelId = 'gpt-5-mini';
|
||||
const expectedPresent = _compactComposerModelChipLabel(modelId, getModelLabel(modelId));
|
||||
const cases = {{
|
||||
present: _usedModelTurnChipLabel({{ _usedModel: modelId }}),
|
||||
expectedPresent,
|
||||
suppressed: _usedModelTurnChipLabel({{
|
||||
_usedModel: modelId,
|
||||
_gatewayRouting: {{ used_model: 'deepseek-v3.2' }},
|
||||
}}),
|
||||
suppressedRoutingOnly: _usedModelTurnChipLabel({{
|
||||
_usedModel: modelId,
|
||||
_gatewayRouting: {{ used_provider: 'openrouter' }},
|
||||
}}),
|
||||
absent: _usedModelTurnChipLabel({{}}),
|
||||
nullMsg: _usedModelTurnChipLabel(null),
|
||||
gatewayFallback: _formatGatewayModelLabel(modelId, getModelLabel(modelId), {{ used_provider: 'openrouter' }}),
|
||||
}};
|
||||
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
|
||||
# mutates agent.model when a fallback fires, so the pre-run resolved_model
|
||||
# would mis-attribute fallback turns.
|
||||
assert "_used_model = getattr(agent, 'model', None) or resolved_model or model" in STREAMING_PY
|
||||
assert "usage['used_model'] = _used_model" in STREAMING_PY
|
||||
|
||||
|
||||
def test_models_allowlist_round_trips_used_model_across_save_reload():
|
||||
assert '"_usedModel"' in MODELS_PY
|
||||
assert "_usedModel" in MODELS_PY.split("_SESSION_MESSAGE_DISPLAY_METADATA_KEYS")[1].split(")")[0]
|
||||
|
||||
session = Session(session_id="6068usedmodel", title="Used model")
|
||||
session.messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "done",
|
||||
"_firstTokenMs": 250,
|
||||
"_usedModel": "gpt-5-mini",
|
||||
},
|
||||
]
|
||||
session.save()
|
||||
|
||||
reloaded = Session.load("6068usedmodel")
|
||||
assert reloaded.messages[-1]["_usedModel"] == "gpt-5-mini"
|
||||
assert reloaded.messages[-1]["_firstTokenMs"] == 250
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
def test_used_model_turn_chip_label_renders_and_suppresses_gateway_duplicate():
|
||||
"""Behavioral footer chip cases from #6068 (not source-string greps)."""
|
||||
cases = _eval_used_model_turn_chip_label_cases()
|
||||
assert cases["present"] == cases["expectedPresent"]
|
||||
assert cases["present"] # non-empty label when _usedModel is set
|
||||
assert cases["suppressed"] == ""
|
||||
# Routing metadata WITHOUT used_model must also suppress the additive chip:
|
||||
# the gateway formatter owns the label (falling back to _usedModel), so a
|
||||
# provider-only routing payload must not render two model labels.
|
||||
assert cases["suppressedRoutingOnly"] == ""
|
||||
assert cases["absent"] == ""
|
||||
assert cases["nullMsg"] == ""
|
||||
# When routing omits used_model, the gateway label falls back to the
|
||||
# caller-provided model id (the settled footer passes msg._usedModel).
|
||||
assert cases["gatewayFallback"].startswith(cases["expectedPresent"])
|
||||
assert "via" in cases["gatewayFallback"]
|
||||
|
||||
|
||||
def test_settled_footer_wires_used_model_chip_in_dom_paths():
|
||||
assert "msg-used-model-inline" in UI_JS
|
||||
assert "_usedModelTurnChipLabel(msg)" in UI_JS
|
||||
# The settled footer must hand the served model to the gateway formatter as
|
||||
# its fallback, and skip the generic chip when the transparent turn footer
|
||||
# owns the model label (one model label per turn).
|
||||
assert "_formatGatewayModelLabel(String(msg._usedModel||'').trim()||(S.session&&S.session.model)||'', '', routing)" in UI_JS
|
||||
assert "_transparentFooterOwnsModel" in UI_JS
|
||||
assert ".msg-used-model-inline" in STYLE_CSS
|
||||
|
||||
|
||||
def test_settled_footer_orders_model_chip_after_duration():
|
||||
# Match the transparent footer order (elapsed · model · …): the duration
|
||||
# fragment must be pushed before the used-model fragment.
|
||||
footer_block = UI_JS.split("const usedModelText=_usedModelTurnChipLabel(msg);", 1)[1]
|
||||
duration_at = footer_block.index("duration.className='msg-duration-inline'")
|
||||
used_model_at = footer_block.index("usedModel.className='msg-used-model-inline'")
|
||||
assert duration_at < used_model_at
|
||||
|
||||
|
||||
def test_transparent_turn_footer_includes_model_between_duration_and_ttft():
|
||||
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"]
|
||||
Loading…
Reference in a new issue