feat: show agent turn duration
This commit is contained in:
1 parent
bf7bc6b4c4
commit
f3fa106cd7
6 files changed
+141
-20
No files matched your search
+16
-1
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
Reference in new issue
Block a user