diff --git a/src/chrome/src/trace/recorder.js b/src/chrome/src/trace/recorder.js index e22111048..150bdf314 100644 --- a/src/chrome/src/trace/recorder.js +++ b/src/chrome/src/trace/recorder.js @@ -10,6 +10,7 @@ import { normalizedThreshold, TRACE_REPAIR_STALE_AFTER_MS, } from './repair.js'; +import { createTraceStats, addTraceEvent, aggregateTraceRuns } from './stats.js'; /** * Trace recorder — writes per-run traces (LLM requests/responses, tool calls, @@ -331,6 +332,14 @@ export async function startRun(meta = {}) { stepCount: 0, totalInputTokens: 0, totalOutputTokens: 0, + llmRequestCount: 0, + llmResponseCount: 0, + toolCallCount: 0, + visionSubCallCount: 0, + errorCount: 0, + retryCount: 0, + totalLlmLatencyMs: 0, + totalToolLatencyMs: 0, finalContent: null, }; await promisifyReq(tx(db, ['runs']).objectStore('runs').put(record)); @@ -656,7 +665,7 @@ export async function endRun(runId, { status = 'done', finalContent = null } = { // all llm_response events — providers report this in their native units // (OpenRouter & OpenAI: USD). Surfaced in the Traces UI so users can // spot expensive-failure runs at a glance. - let totalIn = 0, totalOut = 0, totalCost = 0, stepCount = 0; + const stats = createTraceStats(); let sawLoopError = false; await new Promise((resolve) => { const idx = tx(db, ['events'], 'readonly').objectStore('events').index('runId'); @@ -666,15 +675,7 @@ export async function endRun(runId, { status = 'done', finalContent = null } = { if (!c) return resolve(); const ev = c.value; if (ev.kind === 'error' && ev.data?.phase === 'loop') sawLoopError = true; - if (ev.kind === 'llm_response') { - stepCount = Math.max(stepCount, ev.data?.step || 0); - const u = ev.data?.usage; - if (u) { - totalIn += u.prompt_tokens || 0; - totalOut += u.completion_tokens || 0; - if (typeof u.cost === 'number' && Number.isFinite(u.cost)) totalCost += u.cost; - } - } + addTraceEvent(stats, ev); c.continue(); }; req.onerror = () => resolve(); @@ -686,10 +687,18 @@ export async function endRun(runId, { status = 'done', finalContent = null } = { existing.durationMs = existing.endedAt - existing.startedAt; existing.status = finalStatus; existing.finalContent = finalContent; - existing.stepCount = stepCount; - existing.totalInputTokens = totalIn; - existing.totalOutputTokens = totalOut; - existing.totalCost = totalCost; // null/0 when the provider didn't report cost + existing.stepCount = stats.stepCount; + existing.totalInputTokens = stats.totalInputTokens; + existing.totalOutputTokens = stats.totalOutputTokens; + existing.totalCost = stats.totalCost; // null/0 when the provider didn't report cost + existing.llmRequestCount = stats.llmRequestCount; + existing.llmResponseCount = stats.llmResponseCount; + existing.toolCallCount = stats.toolCallCount; + existing.visionSubCallCount = stats.visionSubCallCount; + existing.errorCount = stats.errorCount; + existing.retryCount = stats.retryCount; + existing.totalLlmLatencyMs = stats.totalLlmLatencyMs; + existing.totalToolLatencyMs = stats.totalToolLatencyMs; await promisifyReq(tx(db, ['runs']).objectStore('runs').put(existing)); } } catch (e) { @@ -760,24 +769,33 @@ export async function repairStaleRuns({ export async function listRuns({ limit = 500, conversationId = null } = {}) { const db = await openDB(); - const idx = tx(db, ['runs'], 'readonly').objectStore('runs').index('startedAt'); + const store = tx(db, ['runs'], 'readonly').objectStore('runs'); + const sessionQuery = Boolean(conversationId && store.indexNames.contains('sessionId')); + const idx = store.index(sessionQuery ? 'sessionId' : 'startedAt'); const out = []; // When conversationId is set, only matching runs count toward `limit`, so a // chat's tool-chain export is not starved by unrelated newer runs. await new Promise((resolve, reject) => { - const req = idx.openCursor(null, 'prev'); + const req = sessionQuery + ? idx.openCursor(IDBKeyRange.only(conversationId)) + : idx.openCursor(null, 'prev'); req.onsuccess = () => { const c = req.result; - if (!c || out.length >= limit) return resolve(); + if (!c || (!sessionQuery && out.length >= limit)) return resolve(); const row = c.value; - if (!conversationId || row?.conversationId === conversationId) { - out.push(row); - } + if (sessionQuery || !conversationId || row?.conversationId === conversationId) out.push(row); c.continue(); }; req.onerror = () => reject(req.error || new Error('listRuns failed')); }); - return out; + if (sessionQuery) out.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0)); + return out.slice(0, limit); +} + +export async function getSessionStats(conversationId, { limit = 500 } = {}) { + if (!conversationId) return aggregateTraceRuns([]); + const runs = await listRuns({ limit, conversationId }); + return aggregateTraceRuns(runs); } export async function getRun(runId) { diff --git a/src/chrome/src/trace/stats.js b/src/chrome/src/trace/stats.js new file mode 100644 index 000000000..731561ca5 --- /dev/null +++ b/src/chrome/src/trace/stats.js @@ -0,0 +1,101 @@ +/** + * Pure trace statistics reducers shared by the recorder, Traces UI, and tests. + * + * Event statistics are computed once when a run is finalized and persisted on + * its run record. Session statistics then sum those bounded run snapshots + * through the existing conversation/session index without replaying events. + */ + +function nonNegativeNumber(value) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? number : 0; +} +function stepNumber(value) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? Math.trunc(number) : 0; +} + +export function createTraceStats() { + return { + stepCount: 0, + llmRequestCount: 0, + llmResponseCount: 0, + toolCallCount: 0, + visionSubCallCount: 0, + errorCount: 0, + retryCount: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + totalLlmLatencyMs: 0, + totalToolLatencyMs: 0, + hasLoopError: false, + }; +} + +export function addTraceEvent(stats, event) { + if (!stats || !event || typeof event !== 'object') return stats; + const data = event.data && typeof event.data === 'object' ? event.data : {}; + + if (event.kind === 'llm_request') { + stats.llmRequestCount += 1; + } else if (event.kind === 'llm_response') { + stats.llmResponseCount += 1; + stats.stepCount = Math.max(stats.stepCount, stepNumber(data.step)); + const usage = data.usage && typeof data.usage === 'object' ? data.usage : {}; + stats.totalInputTokens += nonNegativeNumber(usage.prompt_tokens); + stats.totalOutputTokens += nonNegativeNumber(usage.completion_tokens); + stats.totalCost += nonNegativeNumber(usage.cost); + stats.totalLlmLatencyMs += nonNegativeNumber(data.latencyMs); + } else if (event.kind === 'tool') { + stats.toolCallCount += 1; + stats.totalToolLatencyMs += nonNegativeNumber(data.latencyMs); + } else if (event.kind === 'vision_sub_call') { + stats.visionSubCallCount += 1; + } else if (event.kind === 'error') { + stats.errorCount += 1; + if (data.phase === 'loop') stats.hasLoopError = true; + } else if (event.kind === 'note' && data.note === 'llm_retry') { + stats.retryCount += 1; + } + + return stats; +} + +export function buildTraceStats(events) { + const stats = createTraceStats(); + for (const event of Array.isArray(events) ? events : []) addTraceEvent(stats, event); + return stats; +} + +export function aggregateTraceRuns(runs) { + const stats = createTraceStats(); + let runCount = 0; + let runningRunCount = 0; + + for (const run of Array.isArray(runs) ? runs : []) { + if (!run || typeof run !== 'object') continue; + runCount += 1; + if (run.status === 'running') runningRunCount += 1; + if (run.status === 'loop_stopped') stats.hasLoopError = true; + stats.stepCount += nonNegativeNumber(run.stepCount); + stats.llmRequestCount += nonNegativeNumber(run.llmRequestCount); + stats.llmResponseCount += nonNegativeNumber(run.llmResponseCount); + stats.toolCallCount += nonNegativeNumber(run.toolCallCount); + stats.visionSubCallCount += nonNegativeNumber(run.visionSubCallCount); + stats.errorCount += nonNegativeNumber(run.errorCount); + stats.retryCount += nonNegativeNumber(run.retryCount); + stats.totalInputTokens += nonNegativeNumber(run.totalInputTokens); + stats.totalOutputTokens += nonNegativeNumber(run.totalOutputTokens); + stats.totalCost += nonNegativeNumber(run.totalCost); + stats.totalLlmLatencyMs += nonNegativeNumber(run.totalLlmLatencyMs); + stats.totalToolLatencyMs += nonNegativeNumber(run.totalToolLatencyMs); + } + + return { + runCount, + runningRunCount, + completedRunCount: runCount - runningRunCount, + ...stats, + }; +} diff --git a/src/chrome/src/ui/traces.html b/src/chrome/src/ui/traces.html index c7dd9ec1e..67c0c5399 100644 --- a/src/chrome/src/ui/traces.html +++ b/src/chrome/src/ui/traces.html @@ -247,6 +247,11 @@ letter-spacing: 0.04em; margin-bottom: 6px; } + .conv-summary { + margin-bottom: 7px; + color: var(--text2); + font-size: 11px; + } .conv-turns { display: flex; gap: 6px; diff --git a/src/chrome/src/ui/traces.js b/src/chrome/src/ui/traces.js index 010cebb83..3d017a611 100644 --- a/src/chrome/src/ui/traces.js +++ b/src/chrome/src/ui/traces.js @@ -5,10 +5,11 @@ import { listRuns, getRun, getRunEvents, getScreenshot, - deleteRun, clearAllRuns, repairStaleRuns, + getSessionStats, deleteRun, clearAllRuns, repairStaleRuns, } from '../trace/recorder.js'; import { isKnownKind, isIgnorableKind } from '../trace/event-model.js'; import { buildTraceTrajectory } from '../trace/trajectory.js'; +import { aggregateTraceRuns } from '../trace/stats.js'; import { sanitizeTraceExport } from '../agent/trace-export.js'; import { t } from './i18n.js'; import { escapeHtml, escapeAttr } from './utils.js'; @@ -240,10 +241,19 @@ async function renderCompare(aId, bId) { * chat) so users can jump between them. Hidden in compare mode (panes are * already two-up) and when there's only one run in the conversation. */ -function renderConversationPanel(run, compact) { +function renderConversationPanel(run, compact, sessionStats = null) { if (compact) return ''; const siblings = siblingsOf(run); if (siblings.length < 2) return ''; + const stats = sessionStats || aggregateTraceRuns(siblings); + const totalTokens = stats.totalInputTokens + stats.totalOutputTokens; + const summary = [ + t(stats.runCount === 1 ? 'tr.run' : 'tr.runs', { n: stats.runCount }), + t(stats.stepCount === 1 ? 'tr.step' : 'tr.steps_plural', { n: stats.stepCount }), + totalTokens ? t('tr.tokens_short', { n: totalTokens.toLocaleString() }) : '', + formatCost(stats.totalCost) ? `${t('tr.cost.label')}: ${formatCost(stats.totalCost)}` : '', + stats.errorCount ? `${t('tr.event.error_kind')} ×${stats.errorCount}` : '', + ].filter(Boolean).join(' · '); const turnNumber = siblings.findIndex(r => r.runId === run.runId) + 1; const items = siblings.map((r, i) => { const isCurrent = r.runId === run.runId; @@ -257,6 +267,7 @@ function renderConversationPanel(run, compact) { return `
${escapeHtml(t('tr.conversation.label'))} · ${escapeHtml(t('tr.conversation.turn_of', { n: turnNumber, total: siblings.length }))}
+
${escapeHtml(summary)}
${items}
`; @@ -337,6 +348,9 @@ function renderStepTrajectory(events, compact) { } async function buildRunView(run, events, compact, objectUrls = new Set()) { + const sessionStats = !compact && run.conversationId + ? await getSessionStats(run.conversationId).catch(() => null) + : null; const header = `

${escapeHtml(run.model || t('tr.unknown_model'))}

@@ -351,7 +365,7 @@ async function buildRunView(run, events, compact, objectUrls = new Set()) { ${formatCost(run.totalCost) ? `${escapeHtml(t('tr.cost.label'))} ${escapeHtml(formatCost(run.totalCost))}` : ''}
${run.lossless === true ? `` : ''} - ${renderConversationPanel(run, compact)} + ${renderConversationPanel(run, compact, sessionStats)}
${escapeHtml(run.userMessage || '')}
${run.finalContent ? `
${escapeHtml(t('tr.final_label'))} ${escapeHtml(run.finalContent)}
` : ''} `; diff --git a/src/firefox/src/trace/recorder.js b/src/firefox/src/trace/recorder.js index f86ec22f7..275afd435 100644 --- a/src/firefox/src/trace/recorder.js +++ b/src/firefox/src/trace/recorder.js @@ -10,6 +10,7 @@ import { normalizedThreshold, TRACE_REPAIR_STALE_AFTER_MS, } from './repair.js'; +import { createTraceStats, addTraceEvent, aggregateTraceRuns } from './stats.js'; /** * Trace recorder — writes per-run traces (LLM requests/responses, tool calls, @@ -315,6 +316,14 @@ export async function startRun(meta) { stepCount: 0, totalInputTokens: 0, totalOutputTokens: 0, + llmRequestCount: 0, + llmResponseCount: 0, + toolCallCount: 0, + visionSubCallCount: 0, + errorCount: 0, + retryCount: 0, + totalLlmLatencyMs: 0, + totalToolLatencyMs: 0, finalContent: null, }; await promisifyReq(tx(db, ['runs']).objectStore('runs').put(record)); @@ -638,7 +647,7 @@ export async function endRun(runId, { status = 'done', finalContent = null } = { // Tally usage from events. `totalCost` is the sum of `usage.cost` // across all llm_response events — providers report this in their // native units (OpenRouter & OpenAI: USD). - let totalIn = 0, totalOut = 0, totalCost = 0, stepCount = 0; + const stats = createTraceStats(); let sawLoopError = false; await new Promise((resolve) => { const idx = tx(db, ['events'], 'readonly').objectStore('events').index('runId'); @@ -648,15 +657,7 @@ export async function endRun(runId, { status = 'done', finalContent = null } = { if (!c) return resolve(); const ev = c.value; if (ev.kind === 'error' && ev.data?.phase === 'loop') sawLoopError = true; - if (ev.kind === 'llm_response') { - stepCount = Math.max(stepCount, ev.data?.step || 0); - const u = ev.data?.usage; - if (u) { - totalIn += u.prompt_tokens || 0; - totalOut += u.completion_tokens || 0; - if (typeof u.cost === 'number' && Number.isFinite(u.cost)) totalCost += u.cost; - } - } + addTraceEvent(stats, ev); c.continue(); }; req.onerror = () => resolve(); @@ -668,10 +669,18 @@ export async function endRun(runId, { status = 'done', finalContent = null } = { existing.durationMs = existing.endedAt - existing.startedAt; existing.status = finalStatus; existing.finalContent = finalContent; - existing.stepCount = stepCount; - existing.totalInputTokens = totalIn; - existing.totalOutputTokens = totalOut; - existing.totalCost = totalCost; + existing.stepCount = stats.stepCount; + existing.totalInputTokens = stats.totalInputTokens; + existing.totalOutputTokens = stats.totalOutputTokens; + existing.totalCost = stats.totalCost; + existing.llmRequestCount = stats.llmRequestCount; + existing.llmResponseCount = stats.llmResponseCount; + existing.toolCallCount = stats.toolCallCount; + existing.visionSubCallCount = stats.visionSubCallCount; + existing.errorCount = stats.errorCount; + existing.retryCount = stats.retryCount; + existing.totalLlmLatencyMs = stats.totalLlmLatencyMs; + existing.totalToolLatencyMs = stats.totalToolLatencyMs; await promisifyReq(tx(db, ['runs']).objectStore('runs').put(existing)); } } catch (e) { @@ -742,24 +751,33 @@ export async function repairStaleRuns({ export async function listRuns({ limit = 500, conversationId = null } = {}) { const db = await openDB(); - const idx = tx(db, ['runs'], 'readonly').objectStore('runs').index('startedAt'); + const store = tx(db, ['runs'], 'readonly').objectStore('runs'); + const sessionQuery = Boolean(conversationId && store.indexNames.contains('sessionId')); + const idx = store.index(sessionQuery ? 'sessionId' : 'startedAt'); const out = []; // When conversationId is set, only matching runs count toward `limit`, so a // chat's tool-chain export is not starved by unrelated newer runs. await new Promise((resolve, reject) => { - const req = idx.openCursor(null, 'prev'); + const req = sessionQuery + ? idx.openCursor(IDBKeyRange.only(conversationId)) + : idx.openCursor(null, 'prev'); req.onsuccess = () => { const c = req.result; - if (!c || out.length >= limit) return resolve(); + if (!c || (!sessionQuery && out.length >= limit)) return resolve(); const row = c.value; - if (!conversationId || row?.conversationId === conversationId) { - out.push(row); - } + if (sessionQuery || !conversationId || row?.conversationId === conversationId) out.push(row); c.continue(); }; req.onerror = () => reject(req.error || new Error('listRuns failed')); }); - return out; + if (sessionQuery) out.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0)); + return out.slice(0, limit); +} + +export async function getSessionStats(conversationId, { limit = 500 } = {}) { + if (!conversationId) return aggregateTraceRuns([]); + const runs = await listRuns({ limit, conversationId }); + return aggregateTraceRuns(runs); } export async function getRun(runId) { diff --git a/src/firefox/src/trace/stats.js b/src/firefox/src/trace/stats.js new file mode 100644 index 000000000..731561ca5 --- /dev/null +++ b/src/firefox/src/trace/stats.js @@ -0,0 +1,101 @@ +/** + * Pure trace statistics reducers shared by the recorder, Traces UI, and tests. + * + * Event statistics are computed once when a run is finalized and persisted on + * its run record. Session statistics then sum those bounded run snapshots + * through the existing conversation/session index without replaying events. + */ + +function nonNegativeNumber(value) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? number : 0; +} +function stepNumber(value) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? Math.trunc(number) : 0; +} + +export function createTraceStats() { + return { + stepCount: 0, + llmRequestCount: 0, + llmResponseCount: 0, + toolCallCount: 0, + visionSubCallCount: 0, + errorCount: 0, + retryCount: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + totalLlmLatencyMs: 0, + totalToolLatencyMs: 0, + hasLoopError: false, + }; +} + +export function addTraceEvent(stats, event) { + if (!stats || !event || typeof event !== 'object') return stats; + const data = event.data && typeof event.data === 'object' ? event.data : {}; + + if (event.kind === 'llm_request') { + stats.llmRequestCount += 1; + } else if (event.kind === 'llm_response') { + stats.llmResponseCount += 1; + stats.stepCount = Math.max(stats.stepCount, stepNumber(data.step)); + const usage = data.usage && typeof data.usage === 'object' ? data.usage : {}; + stats.totalInputTokens += nonNegativeNumber(usage.prompt_tokens); + stats.totalOutputTokens += nonNegativeNumber(usage.completion_tokens); + stats.totalCost += nonNegativeNumber(usage.cost); + stats.totalLlmLatencyMs += nonNegativeNumber(data.latencyMs); + } else if (event.kind === 'tool') { + stats.toolCallCount += 1; + stats.totalToolLatencyMs += nonNegativeNumber(data.latencyMs); + } else if (event.kind === 'vision_sub_call') { + stats.visionSubCallCount += 1; + } else if (event.kind === 'error') { + stats.errorCount += 1; + if (data.phase === 'loop') stats.hasLoopError = true; + } else if (event.kind === 'note' && data.note === 'llm_retry') { + stats.retryCount += 1; + } + + return stats; +} + +export function buildTraceStats(events) { + const stats = createTraceStats(); + for (const event of Array.isArray(events) ? events : []) addTraceEvent(stats, event); + return stats; +} + +export function aggregateTraceRuns(runs) { + const stats = createTraceStats(); + let runCount = 0; + let runningRunCount = 0; + + for (const run of Array.isArray(runs) ? runs : []) { + if (!run || typeof run !== 'object') continue; + runCount += 1; + if (run.status === 'running') runningRunCount += 1; + if (run.status === 'loop_stopped') stats.hasLoopError = true; + stats.stepCount += nonNegativeNumber(run.stepCount); + stats.llmRequestCount += nonNegativeNumber(run.llmRequestCount); + stats.llmResponseCount += nonNegativeNumber(run.llmResponseCount); + stats.toolCallCount += nonNegativeNumber(run.toolCallCount); + stats.visionSubCallCount += nonNegativeNumber(run.visionSubCallCount); + stats.errorCount += nonNegativeNumber(run.errorCount); + stats.retryCount += nonNegativeNumber(run.retryCount); + stats.totalInputTokens += nonNegativeNumber(run.totalInputTokens); + stats.totalOutputTokens += nonNegativeNumber(run.totalOutputTokens); + stats.totalCost += nonNegativeNumber(run.totalCost); + stats.totalLlmLatencyMs += nonNegativeNumber(run.totalLlmLatencyMs); + stats.totalToolLatencyMs += nonNegativeNumber(run.totalToolLatencyMs); + } + + return { + runCount, + runningRunCount, + completedRunCount: runCount - runningRunCount, + ...stats, + }; +} diff --git a/src/firefox/src/ui/traces.html b/src/firefox/src/ui/traces.html index c7dd9ec1e..67c0c5399 100644 --- a/src/firefox/src/ui/traces.html +++ b/src/firefox/src/ui/traces.html @@ -247,6 +247,11 @@ letter-spacing: 0.04em; margin-bottom: 6px; } + .conv-summary { + margin-bottom: 7px; + color: var(--text2); + font-size: 11px; + } .conv-turns { display: flex; gap: 6px; diff --git a/src/firefox/src/ui/traces.js b/src/firefox/src/ui/traces.js index b82a7b62e..86b049fe4 100644 --- a/src/firefox/src/ui/traces.js +++ b/src/firefox/src/ui/traces.js @@ -5,10 +5,11 @@ import { listRuns, getRun, getRunEvents, getScreenshot, - deleteRun, clearAllRuns, repairStaleRuns, + getSessionStats, deleteRun, clearAllRuns, repairStaleRuns, } from '../trace/recorder.js'; import { isKnownKind, isIgnorableKind } from '../trace/event-model.js'; import { buildTraceTrajectory } from '../trace/trajectory.js'; +import { aggregateTraceRuns } from '../trace/stats.js'; import { sanitizeTraceExport } from '../agent/trace-export.js'; import { t } from './i18n.js'; import { escapeHtml, escapeAttr } from './utils.js'; @@ -240,10 +241,19 @@ async function renderCompare(aId, bId) { * chat) so users can jump between them. Hidden in compare mode (panes are * already two-up) and when there's only one run in the conversation. */ -function renderConversationPanel(run, compact) { +function renderConversationPanel(run, compact, sessionStats = null) { if (compact) return ''; const siblings = siblingsOf(run); if (siblings.length < 2) return ''; + const stats = sessionStats || aggregateTraceRuns(siblings); + const totalTokens = stats.totalInputTokens + stats.totalOutputTokens; + const summary = [ + t(stats.runCount === 1 ? 'tr.run' : 'tr.runs', { n: stats.runCount }), + t(stats.stepCount === 1 ? 'tr.step' : 'tr.steps_plural', { n: stats.stepCount }), + totalTokens ? t('tr.tokens_short', { n: totalTokens.toLocaleString() }) : '', + formatCost(stats.totalCost) ? `${t('tr.cost.label')}: ${formatCost(stats.totalCost)}` : '', + stats.errorCount ? `${t('tr.event.error_kind')} ×${stats.errorCount}` : '', + ].filter(Boolean).join(' · '); const turnNumber = siblings.findIndex(r => r.runId === run.runId) + 1; const items = siblings.map((r, i) => { const isCurrent = r.runId === run.runId; @@ -257,6 +267,7 @@ function renderConversationPanel(run, compact) { return `
${escapeHtml(t('tr.conversation.label'))} · ${escapeHtml(t('tr.conversation.turn_of', { n: turnNumber, total: siblings.length }))}
+
${escapeHtml(summary)}
${items}
`; @@ -337,6 +348,9 @@ function renderStepTrajectory(events, compact) { } async function buildRunView(run, events, compact, objectUrls = new Set()) { + const sessionStats = !compact && run.conversationId + ? await getSessionStats(run.conversationId).catch(() => null) + : null; const header = `

${escapeHtml(run.model || t('tr.unknown_model'))}

@@ -351,7 +365,7 @@ async function buildRunView(run, events, compact, objectUrls = new Set()) { ${formatCost(run.totalCost) ? `${escapeHtml(t('tr.cost.label'))} ${escapeHtml(formatCost(run.totalCost))}` : ''}
${run.lossless === true ? `` : ''} - ${renderConversationPanel(run, compact)} + ${renderConversationPanel(run, compact, sessionStats)}
${escapeHtml(run.userMessage || '')}
${run.finalContent ? `
${escapeHtml(t('tr.final_label'))} ${escapeHtml(run.finalContent)}
` : ''} `; diff --git a/test/run.js b/test/run.js index 171512df8..ffca502c4 100644 --- a/test/run.js +++ b/test/run.js @@ -8162,6 +8162,8 @@ const TRACE_REPAIR_CH = await import('file://' + path.join(ROOT, 'src/chrome/src const TRACE_REPAIR_FX = await import('file://' + path.join(ROOT, 'src/firefox/src/trace/repair.js').replace(/\\/g, '/')); const TRACE_TRAJECTORY_CH = await import('file://' + path.join(ROOT, 'src/chrome/src/trace/trajectory.js').replace(/\\/g, '/')); const TRACE_TRAJECTORY_FX = await import('file://' + path.join(ROOT, 'src/firefox/src/trace/trajectory.js').replace(/\\/g, '/')); +const TRACE_STATS_CH = await import('file://' + path.join(ROOT, 'src/chrome/src/trace/stats.js').replace(/\\/g, '/')); +const TRACE_STATS_FX = await import('file://' + path.join(ROOT, 'src/firefox/src/trace/stats.js').replace(/\\/g, '/')); const CLOUD_RUNTIME_OUTBOX_CH = await import('file://' + path.join(ROOT, 'src/chrome/src/trace/cloud-runtime-outbox.js').replace(/\\/g, '/')); const CLOUD_RUNTIME_OUTBOX_FX = await import('file://' + path.join(ROOT, 'src/firefox/src/trace/cloud-runtime-outbox.js').replace(/\\/g, '/')); @@ -9585,6 +9587,93 @@ test('trace trajectory: closes run rows for unlisted terminal end statuses', () } }); +test('trace stats: aggregates event metrics and mirrors browser modules', () => { + const events = [ + { kind: 'llm_request', data: { step: 1 } }, + { kind: 'llm_response', data: { step: 1, usage: { prompt_tokens: 10, completion_tokens: 4, cost: 0.12 }, latencyMs: 300 } }, + { kind: 'tool', data: { step: 1, latencyMs: 35 } }, + { kind: 'note', data: { step: 1, note: 'llm_retry' } }, + { kind: 'vision_sub_call', data: { step: 1, latencyMs: 42 } }, + { kind: 'error', data: { step: 1, phase: 'loop', code: 'TRANSPORT' } }, + { kind: 'llm_response', data: { step: 2, usage: { prompt_tokens: 7, completion_tokens: 5, cost: 0.03 }, latencyMs: 120 } }, + ]; + const stats = TRACE_STATS_CH.buildTraceStats(events); + assert.deepEqual(stats, { + stepCount: 2, + llmRequestCount: 1, + llmResponseCount: 2, + toolCallCount: 1, + visionSubCallCount: 1, + errorCount: 1, + retryCount: 1, + totalInputTokens: 17, + totalOutputTokens: 9, + totalCost: 0.15, + totalLlmLatencyMs: 420, + totalToolLatencyMs: 35, + hasLoopError: true, + }); + assert.deepEqual(TRACE_STATS_FX.buildTraceStats(events), stats, 'Chrome/Firefox stats aggregators must agree'); +}); + +test('trace stats: aggregates durable run snapshots without replaying events', () => { + const stats = TRACE_STATS_CH.aggregateTraceRuns([ + { + runId: 'done-run', status: 'done', stepCount: 2, + totalInputTokens: 10, totalOutputTokens: 4, totalCost: 0.12, + llmRequestCount: 1, llmResponseCount: 1, toolCallCount: 1, + visionSubCallCount: 1, errorCount: 1, retryCount: 1, + totalLlmLatencyMs: 300, totalToolLatencyMs: 35, + }, + { + runId: 'running-run', status: 'running', stepCount: 1, + totalInputTokens: 7, totalOutputTokens: 5, totalCost: 0.03, + llmRequestCount: 0, llmResponseCount: 1, toolCallCount: 0, + visionSubCallCount: 0, errorCount: 0, retryCount: 0, + totalLlmLatencyMs: 120, totalToolLatencyMs: 0, + }, + { runId: 'legacy-run', status: 'done', stepCount: 1, totalInputTokens: 2, totalOutputTokens: 1, totalCost: 0.01 }, + ]); + assert.deepEqual(stats, { + runCount: 3, + runningRunCount: 1, + completedRunCount: 2, + stepCount: 4, + llmRequestCount: 1, + llmResponseCount: 2, + toolCallCount: 1, + visionSubCallCount: 1, + errorCount: 1, + retryCount: 1, + totalInputTokens: 19, + totalOutputTokens: 10, + totalCost: 0.16, + totalLlmLatencyMs: 420, + totalToolLatencyMs: 35, + hasLoopError: false, + }); +}); + +test('trace stats: recorder persists bounded run snapshots and reads session indexes', () => { + const sources = ['chrome', 'firefox'].map((browser) => [ + browser, + fs.readFileSync(path.join(ROOT, `src/${browser}/src/trace/recorder.js`), 'utf8'), + ]); + for (const [browser, recorder] of sources) { + assert.match(recorder, /import \{ createTraceStats, addTraceEvent, aggregateTraceRuns \} from '\.\/stats\.js';/, `${browser}: recorder stats import missing`); + assert.match(recorder, /llmRequestCount: 0[\s\S]*?totalToolLatencyMs: 0/, `${browser}: new runs do not initialize stats snapshots`); + assert.match(recorder, /addTraceEvent\(stats, ev\)/, `${browser}: finalized runs do not use the shared event reducer`); + assert.match(recorder, /existing\.totalToolLatencyMs = stats\.totalToolLatencyMs/, `${browser}: finalized run stats are incomplete`); + assert.match(recorder, /index\(sessionQuery \? 'sessionId' : 'startedAt'\)/, `${browser}: session queries do not use the lineage index`); + assert.match(recorder, /export async function getSessionStats\(conversationId/, `${browser}: session stats reader is missing`); + } + assert.equal( + fs.readFileSync(path.join(ROOT, 'src/chrome/src/trace/stats.js'), 'utf8'), + fs.readFileSync(path.join(ROOT, 'src/firefox/src/trace/stats.js'), 'utf8'), + 'Chrome/Firefox stats modules must remain mirrored', + ); +}); + test('trace UI: renders the trajectory rows before the detailed event timeline', () => { for (const browser of ['chrome', 'firefox']) { const traces = fs.readFileSync(path.join(ROOT, `src/${browser}/src/ui/traces.js`), 'utf8'); @@ -9592,9 +9681,13 @@ test('trace UI: renders the trajectory rows before the detailed event timeline', assert.match(traces, /import \{ buildTraceTrajectory \} from '\.\.\/trace\/trajectory\.js';/, `${browser}: Traces UI does not import the trajectory module`); assert.match(traces, /function renderStepTrajectory\(events, compact\)/, `${browser}: trajectory renderer missing`); assert.match(traces, /const rows = buildTraceTrajectory\(events\);/, `${browser}: UI does not build rows through the pure seam`); + assert.match(traces, /import \{ aggregateTraceRuns \} from '\.\.\/trace\/stats\.js';/, `${browser}: UI does not import the session stats seam`); + assert.match(traces, /sessionStats \|\| aggregateTraceRuns\(siblings\)/, `${browser}: conversation panel does not aggregate durable run snapshots`); + assert.match(traces, /getSessionStats\(run\.conversationId\)/, `${browser}: conversation panel does not read indexed session stats`); assert.match(traces, /trajectory-table/, `${browser}: trajectory table class missing from renderer`); assert.match(traces, /renderStepTrajectory\(events, compact\)/, `${browser}: run view does not render the trajectory before details`); assert.match(html, /\.trajectory-table|\.trajectory-row/, `${browser}: trajectory table styles missing`); + assert.match(html, /\.conv-summary/, `${browser}: conversation statistics style missing`); } });