diff --git a/api/streaming.py b/api/streaming.py
index 736720ec..e7748440 100644
--- a/api/streaming.py
+++ b/api/streaming.py
@@ -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.
diff --git a/static/messages.js b/static/messages.js
index 36cd47ad..1771f885 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -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){
diff --git a/static/style.css b/static/style.css
index 3fc77ecd..a3f56705 100644
--- a/static/style.css
+++ b/static/style.css
@@ -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 {
diff --git a/static/ui.js b/static/ui.js
index 9503f0c2..ade93b0e 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -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=`
`;
+ group.innerHTML=``;
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;mitc&&(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);
}
diff --git a/tests/test_sprint49.py b/tests/test_sprint49.py
index 2aa7253c..e49c9295 100644
--- a/tests/test_sprint49.py
+++ b/tests/test_sprint49.py
@@ -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
diff --git a/tests/test_turn_duration_display.py b/tests/test_turn_duration_display.py
new file mode 100644
index 00000000..a3f79d24
--- /dev/null
+++ b/tests/test_turn_duration_display.py
@@ -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."
+ )