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." + )