feat: show agent turn duration

This commit is contained in:
Michael Lam committed 2026-05-03 20:20:17 -07:00
1 parent bf7bc6b4c4
commit f3fa106cd7
6 files changed
+141 -20

No files matched your search

+16 -1
View File
@@ -2052,6 +2052,7 @@ def _run_agent_streaming(
# Pass personality via ephemeral_system_prompt (agent's own mechanism)
if _personality_prompt:
agent.ephemeral_system_prompt = _personality_prompt
_turn_started_at = getattr(s, 'pending_started_at', None) or time.time()
_previous_messages = list(s.messages or [])
_previous_context_messages = list(_session_context_messages(s))
_pre_compression_count = getattr(
@@ -2449,6 +2450,15 @@ def _run_agent_streaming(
if isinstance(_rm, dict) and _rm.get('role') == 'assistant':
_rm['reasoning'] = _reasoning_text
break
try:
_turn_duration_seconds = max(0.0, time.time() - float(_turn_started_at))
except Exception:
_turn_duration_seconds = 0.0
if s.messages:
for _dm in reversed(s.messages):
if isinstance(_dm, dict) and _dm.get('role') == 'assistant':
_dm['_turnDuration'] = round(_turn_duration_seconds, 3)
break
# Persist context window data on the session so the context-ring
# indicator survives a page reload (#1318). Must run BEFORE
# s.save() for the same reason as the reasoning trace above.
@@ -2496,7 +2506,12 @@ def _run_agent_streaming(
)
except Exception:
logger.debug("Failed to sync session to insights")
usage = {'input_tokens': input_tokens, 'output_tokens': output_tokens, 'estimated_cost': estimated_cost}
usage = {
'input_tokens': input_tokens,
'output_tokens': output_tokens,
'estimated_cost': estimated_cost,
'duration_seconds': round(_turn_duration_seconds, 3),
}
# 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.
+3
View File
@@ -877,6 +877,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
estimated_cost:Math.max(0,curCost-prevCost),
};
}
if(typeof d.usage.duration_seconds==='number'){
lastAsst._turnDuration=d.usage.duration_seconds;
}
}
}
if(d.session.tool_calls&&d.session.tool_calls.length){
+5 -2
View File
@@ -1712,7 +1712,8 @@ body.resizing{user-select:none;cursor:col-resize;}
.tool-call-group-summary:hover{background:var(--surface-subtle-hover);color:var(--text);}
.tool-call-group-label{font-weight:600;color:var(--muted);}
.tool-call-group-list{opacity:.72;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.tool-call-group-count{margin-left:auto;opacity:.56;font-variant-numeric:tabular-nums;}
.tool-call-group-duration{margin-left:auto;opacity:.62;font-variant-numeric:tabular-nums;white-space:nowrap;}
.tool-call-group-count{opacity:.56;font-variant-numeric:tabular-nums;}
.tool-call-group-chevron{opacity:.45;display:inline-flex;transition:transform .16s ease;}
.tool-call-group:not(.tool-call-group-collapsed) .tool-call-group-chevron{transform:rotate(90deg);}
.tool-call-group-body{display:block;padding-left:var(--space-3);}
@@ -2693,11 +2694,13 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
justify-content: flex-start;
gap: 8px;
}
.msg-usage-inline {
.msg-usage-inline,
.msg-duration-inline {
font-size: 11px;
color: var(--muted);
opacity: .7;
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
}
.msg-foot-with-usage .msg-time,
.msg-foot-with-usage .msg-actions {
+54 -16
View File
@@ -1209,6 +1209,17 @@ let _programmaticScroll=false;
});
})();
function _fmtTokens(n){if(!n||n<0)return'0';if(n>=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'k';return String(n);}
function _formatTurnDuration(seconds){
const n=Number(seconds);
if(!Number.isFinite(n)||n<0)return'';
const total=Math.max(0,Math.round(n));
if(total<60)return`${total}s`;
const h=Math.floor(total/3600);
const m=Math.floor((total%3600)/60);
const s=total%60;
if(h)return`${h}h ${m}m`;
return`${m}m ${s}s`;
}
const _MOBILE_CONFIG_BASE_LABEL='Workspace, model, reasoning, and context settings';
@@ -3196,7 +3207,7 @@ function ensureActivityGroup(inner, opts){
group.setAttribute('data-tool-call-group','1');
group.setAttribute('data-agent-activity-group','1');
if(live) group.setAttribute('data-live-tool-call-group','1');
group.innerHTML=`<button type="button" class="tool-call-group-summary" aria-expanded="${collapsed?'false':'true'}" onclick="const g=this.closest('.tool-call-group');const c=g.classList.toggle('tool-call-group-collapsed');this.setAttribute('aria-expanded',String(!c));if(typeof _onLiveActivityToggle==='function')_onLiveActivityToggle(g);"><span class="tool-call-group-chevron">${li('chevron-right',12)}</span><span class="tool-call-group-label">Activity</span><span class="tool-call-group-list">tools / thinking</span><span class="tool-call-group-count">0</span></button><div class="tool-call-group-body"></div>`;
group.innerHTML=`<button type="button" class="tool-call-group-summary" aria-expanded="${collapsed?'false':'true'}" onclick="const g=this.closest('.tool-call-group');const c=g.classList.toggle('tool-call-group-collapsed');this.setAttribute('aria-expanded',String(!c));if(typeof _onLiveActivityToggle==='function')_onLiveActivityToggle(g);"><span class="tool-call-group-chevron">${li('chevron-right',12)}</span><span class="tool-call-group-label">Activity</span><span class="tool-call-group-list">tools / thinking</span><span class="tool-call-group-duration"></span><span class="tool-call-group-count">0</span></button><div class="tool-call-group-body"></div>`;
const anchor=opts.anchor||null;
if(anchor&&anchor.parentElement===inner) anchor.insertAdjacentElement('afterend', group);
else inner.appendChild(group);
@@ -3992,6 +4003,8 @@ function renderMessages(){
const anchorParent=anchorRow.parentElement;
const insertAfterNode = anchorInsertAfter.get(anchorRow) || anchorRow;
const group=ensureActivityGroup(anchorParent,{collapsed:true,anchor:insertAfterNode});
const sourceMsg=S.messages[aIdx]||{};
if(sourceMsg._turnDuration!==undefined) group.setAttribute('data-turn-duration', String(sourceMsg._turnDuration));
const body=group&&group.querySelector('.tool-call-group-body');
if(!body) continue;
const thinkingText=assistantThinking.get(aIdx);
@@ -4043,30 +4056,49 @@ function renderMessages(){
}
}
}
// Render per-turn token usage on each assistant message that has it (#503).
// Replaces the old cumulative-total-on-last-bubble approach.
if(window._showTokenUsage){
// Render per-turn duration and optional token usage on assistant messages.
// Duration stays visible even when token usage is disabled, because it answers
// the basic "how long did that turn take?" UX question.
{
const asstRows=inner.querySelectorAll('.assistant-turn');
let ai=0; // assistant-only index for DOM rows
for(let mi=0;mi<S.messages.length;mi++){
const msg=S.messages[mi];
if(msg.role!=='assistant'){continue;}
if(!msg._turnUsage){ai++;continue;}
const hasTurnUsage=!!msg._turnUsage;
const compactActivityForMessage=isSimplifiedToolCalling()&&(
assistantThinking.has(mi)||
(S.toolCalls||[]).some(tc=>tc&&(tc.assistant_msg_idx!==undefined?tc.assistant_msg_idx:-1)===mi)
);
const durationText=compactActivityForMessage?'':_formatTurnDuration(msg._turnDuration);
if(!hasTurnUsage&&!durationText){ai++;continue;}
if(ai>=asstRows.length) continue;
const row=asstRows[ai];
const footerRows=row.querySelectorAll('.msg-foot');
const targetFoot=footerRows.length?footerRows[footerRows.length-1]:null;
if(!targetFoot||targetFoot.querySelector('.msg-usage-inline')){ai++;continue;}
const usage=document.createElement('span');
usage.className='msg-usage-inline';
const inTok=msg._turnUsage.input_tokens||0;
const outTok=msg._turnUsage.output_tokens||0;
const cost=msg._turnUsage.estimated_cost;
let text=`${_fmtTokens(inTok)} in · ${_fmtTokens(outTok)} out`;
if(cost) text+=` · ~$${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
usage.textContent=text;
targetFoot.classList.add('msg-foot-with-usage');
targetFoot.insertBefore(usage, targetFoot.firstChild);
if(!targetFoot||targetFoot.querySelector('.msg-usage-inline,.msg-duration-inline')){ai++;continue;}
const fragments=[];
if(durationText){
const duration=document.createElement('span');
duration.className='msg-duration-inline';
duration.textContent=`Done in ${durationText}`;
fragments.push(duration);
}
if(window._showTokenUsage&&hasTurnUsage){
const usage=document.createElement('span');
usage.className='msg-usage-inline';
const inTok=msg._turnUsage.input_tokens||0;
const outTok=msg._turnUsage.output_tokens||0;
const cost=msg._turnUsage.estimated_cost;
let text=`${_fmtTokens(inTok)} in · ${_fmtTokens(outTok)} out`;
if(cost) text+=` · ~$${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
usage.textContent=text;
fragments.push(usage);
}
if(fragments.length){
targetFoot.classList.add('msg-foot-with-usage');
for(let i=fragments.length-1;i>=0;i--) targetFoot.insertBefore(fragments[i], targetFoot.firstChild);
}
ai++;
}
}
@@ -4187,6 +4219,7 @@ function _syncToolCallGroupSummary(group){
const label=group.querySelector('.tool-call-group-label');
const list=group.querySelector('.tool-call-group-list');
const badge=group.querySelector('.tool-call-group-count');
const durationEl=group.querySelector('.tool-call-group-duration');
const parts=[];
if(thinkingCount) parts.push('thinking');
if(uniqueNames.length) parts.push(uniqueNames.slice(0,5).join(', ')+(uniqueNames.length>5?'…':''));
@@ -4198,6 +4231,11 @@ function _syncToolCallGroupSummary(group){
else label.textContent='Activity';
}
if(list) list.textContent=parts.join(' · ')||'tools / thinking';
if(durationEl){
const durationText=_formatTurnDuration(group.dataset.turnDuration);
durationEl.textContent=durationText?`Done in ${durationText}`:'';
durationEl.style.display=durationText?'':'none';
}
if(badge) badge.textContent=String(total);
}
+4 -1
View File
@@ -60,7 +60,10 @@ def test_footer_chrome_is_hover_only_for_user_and_assistant_messages():
def test_last_assistant_keeps_usage_visible_and_reveals_time_and_actions_on_hover():
assert "usage.className='msg-usage-inline';" in UI_JS
assert "targetFoot.classList.add('msg-foot-with-usage');" in UI_JS
assert "targetFoot.insertBefore(usage, targetFoot.firstChild);" in UI_JS
assert (
"targetFoot.insertBefore(usage, targetFoot.firstChild);" in UI_JS
or "targetFoot.insertBefore(fragments[i], targetFoot.firstChild);" in UI_JS
)
assert ".assistant-turn .msg-foot-with-usage," in UI_CSS
assert ".msg-row[data-role=\"assistant\"] .msg-foot-with-usage {\n opacity: 1;" in UI_CSS
assert ".msg-foot-with-usage .msg-time,\n.msg-foot-with-usage .msg-actions {\n opacity: 0;" in UI_CSS
+59
View File
@@ -0,0 +1,59 @@
"""Regression tests for per-turn response duration in WebUI.
The WebUI should expose how long an agent turn took, using backend timing so
reload/reconnect does not lose the measurement.
"""
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
STREAMING_PY = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
def test_streaming_done_payload_includes_backend_turn_duration():
assert "duration_seconds" in STREAMING_PY, (
"api/streaming.py should include a backend-measured duration_seconds "
"field in the done usage payload."
)
assert "pending_started_at" in STREAMING_PY and "time.time()" in STREAMING_PY, (
"Turn duration should be measured from the persisted pending_started_at "
"start time, not only from browser-local state."
)
assert "_turnDuration" in STREAMING_PY, (
"The measured duration should be persisted on the assistant message so "
"it survives reload after the SSE stream settles."
)
def test_done_handler_persists_duration_on_last_assistant_message():
assert "d.usage.duration_seconds" in MESSAGES_JS, (
"static/messages.js should read duration_seconds from the done usage payload."
)
assert "lastAsst._turnDuration" in MESSAGES_JS, (
"The done handler should attach the duration to the last assistant message "
"so renderMessages() can display it after the live stream settles."
)
def test_ui_formats_and_renders_turn_duration_in_footer_and_activity_summary():
assert "function _formatTurnDuration" in UI_JS, (
"ui.js should centralize duration formatting for footer and compact activity display."
)
assert "msg-duration-inline" in UI_JS and "Done in" in UI_JS, (
"Expanded/non-activity display should show a subtle footer chip like 'Done in 42s'."
)
assert "tool-call-group-duration" in UI_JS, (
"Compact tool activity summary should have a dedicated duration span at the end of the line."
)
assert "data-turn-duration" in UI_JS, (
"Activity groups need a stable data-turn-duration hook so settled duration can update the summary."
)
assert "compactActivityForMessage" in UI_JS, (
"When compact activity is present, duration should live on the Activity row "
"instead of being duplicated in the assistant footer."
)
assert ".msg-duration-inline" in CSS and ".tool-call-group-duration" in CSS, (
"Duration UI should have explicit CSS hooks for the footer chip and compact activity summary."
)