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 = `
${run.lossless === true ? `${escapeHtml(t('tr.lossless.warning'))}
` : ''}
- ${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 = `
${run.lossless === true ? `${escapeHtml(t('tr.lossless.warning'))}
` : ''}
- ${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`);
}
});