feat(agents): live session turn counter alongside runtime timer#446
feat(agents): live session turn counter alongside runtime timer#446
Conversation
| const [expandedEventItems, setExpandedEventItems] = useState(() => new Set()); | ||
| const [expandedFileItems, setExpandedFileItems] = useState(() => new Set()); | ||
| const [expandedModelResponse, setExpandedModelResponse] = useState(false); | ||
| const [runHistory, setRunHistory] = useState([]); |
| const [expandedFileItems, setExpandedFileItems] = useState(() => new Set()); | ||
| const [expandedModelResponse, setExpandedModelResponse] = useState(false); | ||
| const [runHistory, setRunHistory] = useState([]); | ||
| const [runDetail, setRunDetail] = useState(null); |
There was a problem hiding this comment.
Pull request overview
Adds turn-aware session telemetry so the Agents UI can show a live “turn count” alongside existing runtime timing, and extends session insights / replay run data to include per-turn usage + duration details.
Changes:
- Add
turnCountdisplay to Agents session cards (live session snapshots). - Generate per-turn timeline insights (
insights.turns) from session message streams. - Extend replayable agent run API responses to include a
turnsarray derived fromusageevents, with corresponding test updates.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| ui/tabs/agents.js | Adds turn count display and introduces (currently unwired) turn timeline UI helpers; adds run-history polling hooks. |
| site/ui/tabs/agents.js | Mirrors Agents UI updates and adds run-history polling for replay runs. |
| lib/session-insights.mjs | Builds insights.turns timeline from messages (tokens, tools, errors, duration). |
| infra/session-tracker.mjs | Includes turnCount/turns on accumulated session payload sent to runtime accumulator. |
| server/ui-server.mjs | Adds turns to replayable run detail responses by scanning usage events. |
| tests/ui-server.test.mjs | Updates replay-run tests for usage events and validates turns + live turnCount snapshot passthrough. |
| tests/session-tracker.test.mjs | Adds coverage asserting per-turn timeline insights generation. |
| infra/runtime-accumulator.mjs | Trailing whitespace/newline-only change. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| fetchRunHistory(); | ||
| const interval = setInterval(() => { | ||
| fetchLogs(); | ||
| fetchContext(); | ||
| fetchRunHistory(); |
There was a problem hiding this comment.
fetchRunHistory() is called here (and on the interval) but no fetchRunHistory function is defined in this module/scope, so opening WorkspaceViewer will throw a ReferenceError. Either define fetchRunHistory (as in the site mirror) or remove these calls until the feature is wired up.
ui/tabs/agents.js
Outdated
| setError(null); | ||
| setCtx(null); | ||
| fetchContext(); | ||
| fetchRunHistory(); |
There was a problem hiding this comment.
fetchRunHistory() is invoked in ContextViewer but there is no fetchRunHistory in this scope, which will crash the session detail view. Define a ContextViewer-local fetcher (e.g., useCallback) or remove the call.
| fetchRunHistory(); |
| const fetchRunHistory = () => { | ||
| if (!sessionId && !agent.taskId) return; | ||
| const query = agent.taskId ? `?limit=10&taskId=${encodeURIComponent(agent.taskId)}` : "?limit=10"; | ||
| apiFetch(`/api/agent-runs${query}`, { _silent: true }) | ||
| .then((res) => { | ||
| if (!active) return; | ||
| const runs = Array.isArray(res?.data) ? res.data : Array.isArray(res) ? res : []; | ||
| setRunHistory(runs); | ||
| const latest = runs[0]?.attemptId; | ||
| if (!latest) return; | ||
| return apiFetch(`/api/agent-runs/${encodeURIComponent(latest)}`, { _silent: true }); | ||
| }) | ||
| .then((res) => { | ||
| if (!active || !res) return; | ||
| setRunDetail(res?.data ?? res ?? null); | ||
| }) |
There was a problem hiding this comment.
fetchRunHistory polls /api/agent-runs and /api/agent-runs/:id every 5s and stores results in runHistory/runDetail, but those state values are not used anywhere in this file. This adds recurring API load with no user-visible effect; either render the run history/detail UI or stop polling until it’s needed (e.g., fetch on-demand when opening a Runs tab).
ui/tabs/agents.js
Outdated
| function formatDurationMs(value) { | ||
| const ms = Number(value || 0); | ||
| if (!Number.isFinite(ms) || ms <= 0) return "0s"; | ||
| if (ms < 1000) return `${Math.round(ms)}ms`; | ||
| const sec = Math.round(ms / 1000); | ||
| if (sec < 60) return `${sec}s`; | ||
| if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`; | ||
| return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`; | ||
| } | ||
|
|
||
| function formatTokenCount(value) { | ||
| const n = Number(value || 0); | ||
| if (!Number.isFinite(n) || n <= 0) return "0"; | ||
| return new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n); | ||
| } | ||
|
|
||
| function SessionTurnsTimeline({ session }) { | ||
| const turns = Array.isArray(session?.insights?.turns) ? session.insights.turns : []; | ||
| if (!turns.length) { | ||
| return html`<div class="chat-view chat-empty-state"> | ||
| <div class="session-empty-icon">${resolveIcon(":repeat:")}</div> | ||
| <div class="session-empty-text">No completed turns recorded yet</div> | ||
| </div>`; | ||
| } | ||
| return html`<div class="fleet-turns-timeline"> | ||
| ${turns.map((turn) => html` | ||
| <div class="fleet-turn-row" key=${`turn-${turn.index}-${turn.startedAt || ""}`}> | ||
| <div class="fleet-turn-row-head"> | ||
| <div class="fleet-turn-row-title">Turn ${turn.index || "?"}</div> | ||
| <div class="fleet-turn-row-meta"> | ||
| ${formatDurationMs(turn.durationMs || 0)} · ${formatTokenCount(turn.tokenCount || turn.tokenUsage?.totalTokens || 0)} tokens | ||
| </div> | ||
| </div> | ||
| <div class="fleet-turn-row-submeta"> | ||
| In ${formatTokenCount(turn.inputTokens || turn.tokenUsage?.inputTokens || 0)} · Out ${formatTokenCount(turn.outputTokens || turn.tokenUsage?.outputTokens || 0)} | ||
| ${turn.toolCalls ? ` · ${turn.toolCalls} tools` : ""} | ||
| ${turn.errors ? ` · ${turn.errors} errors` : ""} | ||
| </div> | ||
| <div class="fleet-turn-row-preview">${truncate(turn.preview || `Turn ${turn.index || "?"}`, 180)}</div> | ||
| </div> | ||
| `)} | ||
| </div>`; | ||
| } |
There was a problem hiding this comment.
SessionTurnsTimeline is introduced but never referenced in this file, so it’s dead code (and formatDurationMs/formatTokenCount become unused helpers). Either wire it into the UI (e.g., a new “Turns” tab/panel) or remove it until it’s actually used.
| function formatDurationMs(value) { | |
| const ms = Number(value || 0); | |
| if (!Number.isFinite(ms) || ms <= 0) return "0s"; | |
| if (ms < 1000) return `${Math.round(ms)}ms`; | |
| const sec = Math.round(ms / 1000); | |
| if (sec < 60) return `${sec}s`; | |
| if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`; | |
| return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`; | |
| } | |
| function formatTokenCount(value) { | |
| const n = Number(value || 0); | |
| if (!Number.isFinite(n) || n <= 0) return "0"; | |
| return new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n); | |
| } | |
| function SessionTurnsTimeline({ session }) { | |
| const turns = Array.isArray(session?.insights?.turns) ? session.insights.turns : []; | |
| if (!turns.length) { | |
| return html`<div class="chat-view chat-empty-state"> | |
| <div class="session-empty-icon">${resolveIcon(":repeat:")}</div> | |
| <div class="session-empty-text">No completed turns recorded yet</div> | |
| </div>`; | |
| } | |
| return html`<div class="fleet-turns-timeline"> | |
| ${turns.map((turn) => html` | |
| <div class="fleet-turn-row" key=${`turn-${turn.index}-${turn.startedAt || ""}`}> | |
| <div class="fleet-turn-row-head"> | |
| <div class="fleet-turn-row-title">Turn ${turn.index || "?"}</div> | |
| <div class="fleet-turn-row-meta"> | |
| ${formatDurationMs(turn.durationMs || 0)} · ${formatTokenCount(turn.tokenCount || turn.tokenUsage?.totalTokens || 0)} tokens | |
| </div> | |
| </div> | |
| <div class="fleet-turn-row-submeta"> | |
| In ${formatTokenCount(turn.inputTokens || turn.tokenUsage?.inputTokens || 0)} · Out ${formatTokenCount(turn.outputTokens || turn.tokenUsage?.outputTokens || 0)} | |
| ${turn.toolCalls ? ` · ${turn.toolCalls} tools` : ""} | |
| ${turn.errors ? ` · ${turn.errors} errors` : ""} | |
| </div> | |
| <div class="fleet-turn-row-preview">${truncate(turn.preview || `Turn ${turn.index || "?"}`, 180)}</div> | |
| </div> | |
| `)} | |
| </div>`; | |
| } |
infra/session-tracker.mjs
Outdated
| turnCount: session.turnCount || 0, | ||
| turns: Array.isArray(session.insights?.turns) ? session.insights.turns : [], |
There was a problem hiding this comment.
These new turnCount/turns fields passed into addCompletedSession() won’t be persisted/used because infra/runtime-accumulator.mjs:normalizeCompletedSession() drops unknown properties. If you need turn count/timeline in the runtime accumulator, update the accumulator normalization/schema to include them; otherwise remove these fields to avoid giving the impression they’re stored.
| turnCount: session.turnCount || 0, | |
| turns: Array.isArray(session.insights?.turns) ? session.insights.turns : [], |
| it("builds per-turn token and duration timeline insights", () => { | ||
| tracker.startSession("task-1", "Timeline Test"); | ||
| tracker.recordEvent("task-1", { | ||
| role: "user", | ||
| content: "Check the failing tests", | ||
| timestamp: "2026-03-26T10:00:00.000Z", | ||
| }); | ||
| tracker.recordEvent("task-1", { | ||
| type: "tool_call", | ||
| content: "read_file(tests/app.test.mjs)", | ||
| timestamp: "2026-03-26T10:00:01.000Z", | ||
| meta: { toolName: "read_file" }, | ||
| }); | ||
| tracker.recordEvent("task-1", { | ||
| role: "assistant", | ||
| content: "I found the flaky assertion.", | ||
| timestamp: "2026-03-26T10:00:04.000Z", | ||
| meta: { | ||
| usage: { input_tokens: 120, output_tokens: 30, total_tokens: 150 }, | ||
| }, | ||
| }); | ||
| tracker.recordEvent("task-1", { | ||
| type: "system", | ||
| content: "Turn completed", | ||
| timestamp: "2026-03-26T10:00:05.000Z", | ||
| meta: { lifecycle: "turn_completed" }, | ||
| }); |
There was a problem hiding this comment.
This test feeds recordEvent() objects shaped like already-normalized session messages ({type, content, timestamp, meta}), but SessionTracker.recordEvent only treats events as “direct messages” when they include role; otherwise they go through #normalizeEvent, which ignores content/meta/timestamp for tool_call and always assigns timestamp to new Date(). As a result, toolCalls and especially durationMs: 4000 will not match deterministically. Either adjust the test to use the supported input shapes (e.g., include role or use the SDK event fields name/arguments), or extend recordEvent/#normalizeEvent to accept and preserve normalized message objects with type/content/timestamp/meta.
…urn-counter-alongside-
Co-authored-by: bosun-ve[bot] <262908237+bosun-ve[bot]@users.noreply.github.com>
Co-authored-by: bosun-ve[bot] <262908237+bosun-ve[bot]@users.noreply.github.com>
Co-authored-by: bosun-ve[bot] <262908237+bosun-ve[bot]@users.noreply.github.com>
Files: ui/tabs/agents.js, infra/session-tracker.mjs
Files: ui/tabs/agents.js, infra/session-tracker.mjs
|
Bosun CI signal: Bosun-created PR currently has failing checks.
|
Task-ID: f8180f1d-37de-4a8e-825a-a81cd49acb37\n\nAutomated PR for task f8180f1d-37de-4a8e-825a-a81cd49acb37\n\n---\n\nBosun-Origin: created