From fea8406ecd28fc2dbfc2b8849e8a565ee86425dd Mon Sep 17 00:00:00 2001 From: alloevil <12680612+alloevil@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:54:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20per-turn=20ledger=20=E2=80=94=20time,?= =?UTF-8?q?=20tokens,=20cost=20per=20user=20turn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session summary gains a table with one row per user turn: wall-clock from the user message to the last agent message of the turn, tool calls and errors, input/output/cache tokens and cost summed from assistant usage, with bars scaled to the session maximum so the expensive turn is visible before reading any number. Rows jump to the originating user message. buildTurnLedger lives in lib/pure.ts (also bundled into public/js/pure.js); unit tests cover attribution, content-part tool calls, both cost shapes, total_tokens-only platforms, and the no-usage case. --- README.md | 1 + frontend/src/lib/legacy-pure.ts | 1 + frontend/src/lib/pure.ts | 119 +++++++++++++++++ .../src/views/sessions/SessionSummary.tsx | 2 + frontend/src/views/sessions/TurnLedger.tsx | 122 ++++++++++++++++++ package.json | 2 +- public/js/pure.js | 83 ++++++++++++ test/unit.test.js | 56 ++++++++ 8 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 frontend/src/views/sessions/TurnLedger.tsx diff --git a/README.md b/README.md index b3d1da4..321e5d6 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ If you build and operate your own agent in production, use a tracing platform. I ## Features +- **Per-turn ledger** — In the session summary: one row per user turn with wall-clock time, tokens (input + output + cache) and cost, bars scaled to the session maximum, tool-call and error counts, click to jump. Answers "why did this take 40 minutes / cost $3" without reading the transcript. - **Multi-platform** — Unified view across OpenClaw, Codex, Claude Code, Hermes, OMP, DeepSeek Harness and Gemini CLI sessions (dsh's multi-frame zstd session logs are decompressed transparently; Gemini CLI's `/rewind` checkpoints are folded so rewound history never renders twice) - **Session browser** — Browse agents, filter/search sessions, view message history - **Tool call inspection** — Expandable tool calls with arguments and results diff --git a/frontend/src/lib/legacy-pure.ts b/frontend/src/lib/legacy-pure.ts index 483644d..6133c31 100644 --- a/frontend/src/lib/legacy-pure.ts +++ b/frontend/src/lib/legacy-pure.ts @@ -12,6 +12,7 @@ export { getTextContent, clusterPrefillContent, buildTraceTurns, + buildTurnLedger, pickAutoPlatform, } from './pure'; export { escapeHtml, renderMarkdownHtml, renderMarkdown } from './markdown'; diff --git a/frontend/src/lib/pure.ts b/frontend/src/lib/pure.ts index de8a5e4..f89b969 100644 --- a/frontend/src/lib/pure.ts +++ b/frontend/src/lib/pure.ts @@ -217,3 +217,122 @@ export function buildTraceTurns(msgs: SessionMessage[], agentSpans: AgentSpan[] for (const tn of turns) tn.spans.sort((a, b) => a.start - b.start); return turns.filter((tn) => tn.spans.length > 0); } + +// --- Per-turn ledger: where did the time, tokens and dollars go? --- +// A turn = one user message plus everything the agent did until the next user +// message. Usage is summed from assistant messages' `usage` (already normalised +// per platform: input/output/cacheRead/cacheWrite tokens, cost as number or +// {total}). Missing usage on a platform leaves the token/cost columns at zero +// rather than hiding the row: the time column still tells the story. + +export interface TurnLedgerRow { + index: number; + text: string; + messageId: string | null; + start: number; + end: number; + durationMs: number; + toolCalls: number; + toolErrors: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + cost: number; +} + +export interface TurnLedger { + rows: TurnLedgerRow[]; + totals: { durationMs: number; toolCalls: number; tokens: number; cost: number }; + hasUsage: boolean; + hasCost: boolean; +} + +function usageCost(usage: SessionMessage['usage']): number { + const c = usage?.cost; + if (typeof c === 'number') return c; + return typeof c?.total === 'number' ? c.total : 0; +} + +function usageNumber(usage: SessionMessage['usage'], ...keys: string[]): number { + if (!usage) return 0; + for (const k of keys) { + const v = usage[k]; + if (typeof v === 'number') return v; + } + return 0; +} + +export function buildTurnLedger(msgs: SessionMessage[]): TurnLedger { + const rows: TurnLedgerRow[] = []; + let cur: TurnLedgerRow | null = null; + let hasUsage = false; + let hasCost = false; + + for (const m of msgs) { + const t = parseTimestampMs(m.timestamp); + if (m.role === 'user') { + const text = getTextContent(m.content || []) + .replace(/\s+/g, ' ') + .trim(); + cur = { + index: rows.length + 1, + text: text.slice(0, 140) || '(user)', + messageId: m.id || null, + start: t ?? Number.NaN, + end: t ?? Number.NaN, + durationMs: 0, + toolCalls: 0, + toolErrors: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + cost: 0, + }; + rows.push(cur); + continue; + } + if (!cur) continue; // pre-user preamble (system prompts) belongs to no turn + if (t !== null) { + if (Number.isNaN(cur.start)) cur.start = t; + cur.end = Math.max(Number.isNaN(cur.end) ? t : cur.end, t); + } + if (m.role === 'toolCall') cur.toolCalls++; + if (m.role === 'toolResult' && m.isError) cur.toolErrors++; + for (const c of m.content || []) { + if (c.type === 'toolCall' || c.type === 'tool_use') cur.toolCalls++; + if (c.type === 'tool_result' && c.is_error) cur.toolErrors++; + } + if (m.usage) { + const inp = usageNumber(m.usage, 'input', 'inputTokens', 'input_tokens', 'prompt_tokens'); + const out = usageNumber(m.usage, 'output', 'outputTokens', 'output_tokens', 'completion_tokens'); + const cr = usageNumber(m.usage, 'cacheRead', 'cache_read_input_tokens', 'cacheReadTokens'); + const cw = usageNumber(m.usage, 'cacheWrite', 'cache_creation_input_tokens', 'cacheWriteTokens'); + const total = usageNumber(m.usage, 'totalTokens', 'total_tokens'); + if (inp || out || cr || cw || total) hasUsage = true; + cur.inputTokens += inp || (out || cr || cw ? 0 : total); + cur.outputTokens += out; + cur.cacheReadTokens += cr; + cur.cacheWriteTokens += cw; + const cost = usageCost(m.usage); + if (cost) hasCost = true; + cur.cost += cost; + } + } + + for (const r of rows) { + r.durationMs = Number.isNaN(r.start) || Number.isNaN(r.end) ? 0 : Math.max(0, r.end - r.start); + } + const totals = rows.reduce( + (acc, r) => { + acc.durationMs += r.durationMs; + acc.toolCalls += r.toolCalls; + acc.tokens += r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens; + acc.cost += r.cost; + return acc; + }, + { durationMs: 0, toolCalls: 0, tokens: 0, cost: 0 } + ); + return { rows, totals, hasUsage, hasCost }; +} diff --git a/frontend/src/views/sessions/SessionSummary.tsx b/frontend/src/views/sessions/SessionSummary.tsx index 158f87d..cd5e8b1 100644 --- a/frontend/src/views/sessions/SessionSummary.tsx +++ b/frontend/src/views/sessions/SessionSummary.tsx @@ -10,6 +10,7 @@ import { formatCost, formatDurationCompact } from '@/lib/pure'; import { cn } from '@/lib/utils'; import { dirForPlatform, loadStoredFlag, saveStoredFlag, SUMMARY_COLLAPSED_KEY, useAppStore } from '@/store'; import { ChildAgentsSection } from '@/views/trace/ChildAgentsSection'; +import { TurnLedger } from './TurnLedger'; import type { ExportFormat } from './exports'; import { runExport } from './exports'; import type { MsgFilter, TimingAnalysis } from './lib'; @@ -350,6 +351,7 @@ export function SessionSummary({ + ) : null} diff --git a/frontend/src/views/sessions/TurnLedger.tsx b/frontend/src/views/sessions/TurnLedger.tsx new file mode 100644 index 0000000..3f60f74 --- /dev/null +++ b/frontend/src/views/sessions/TurnLedger.tsx @@ -0,0 +1,122 @@ +// Per-turn ledger: the answer to "why did this session take 40 minutes / cost $3". +// One row per user turn; bars are proportional to the session maximum so the +// expensive turn is visible without reading numbers. Clicking a row jumps to +// the user message that started it. + +import { useMemo, useState } from 'react'; +import type { SessionMessage } from '@/api/types'; +import { buildTurnLedger, formatCost, formatDurationCompact } from '@/lib/pure'; +import { cn } from '@/lib/utils'; +import { formatNumber } from './lib'; + +type SortKey = 'index' | 'durationMs' | 'tokens' | 'cost'; + +function Bar({ value, max, className }: { value: number; max: number; className: string }) { + const pct = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0; + return ( +
+
+
+ ); +} + +export function TurnLedger({ + messages, + onScrollToMessage, +}: { + messages: SessionMessage[]; + onScrollToMessage: (id: string) => void; +}) { + const ledger = useMemo(() => buildTurnLedger(messages), [messages]); + const [sort, setSort] = useState('index'); + + const rows = useMemo(() => { + const withTokens = ledger.rows.map((r) => ({ + ...r, + tokens: r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens, + })); + if (sort === 'index') return withTokens; + return [...withTokens].sort((a, b) => b[sort] - a[sort]); + }, [ledger, sort]); + + if (ledger.rows.length < 2) return null; + + const maxMs = Math.max(...rows.map((r) => r.durationMs), 0); + const maxTok = Math.max(...rows.map((r) => r.tokens), 0); + const maxCost = Math.max(...rows.map((r) => r.cost), 0); + const { totals } = ledger; + + const header = (key: SortKey, label: string, title: string) => ( + + ); + + return ( +
+
+
+ Per-turn ledger +
+
+ {ledger.rows.length} turns · {formatDurationCompact(totals.durationMs)} + {ledger.hasUsage ? ` · ${formatNumber(totals.tokens)} tok` : ''} + {ledger.hasCost ? ` · ${formatCost(totals.cost)}` : ''} +
+
+
+
{header('index', '#', 'Session order')}
+
turn
+
{header('durationMs', 'time', 'Wall-clock from the user message to the last agent message of the turn')}
+ {ledger.hasUsage ? ( +
{header('tokens', 'tokens', 'input + output + cache read + cache write, summed over the turn')}
+ ) : null} + {ledger.hasCost ?
{header('cost', 'cost', 'Reported cost summed over the turn')}
: null} + + {rows.map((r) => ( + + ))} +
+
+ ); +} diff --git a/package.json b/package.json index b6bc57a..3e8af6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@alloevil/agent-xray", - "version": "1.16.0", + "version": "1.17.0", "description": "Web dashboard for viewing AI agent session logs — supports OpenClaw, Codex, Claude Code, Hermes, OMP, DeepSeek Harness, and Gemini CLI", "main": "server.js", "bin": { diff --git a/public/js/pure.js b/public/js/pure.js index f945361..deb84d8 100644 --- a/public/js/pure.js +++ b/public/js/pure.js @@ -26,6 +26,7 @@ var __axrPure = (() => { var legacy_pure_exports = {}; __export(legacy_pure_exports, { buildTraceTurns: () => buildTraceTurns, + buildTurnLedger: () => buildTurnLedger, clusterPrefillContent: () => clusterPrefillContent, escapeHtml: () => escapeHtml, firstInformativeLine: () => firstInformativeLine, @@ -191,6 +192,88 @@ var __axrPure = (() => { for (const tn of turns) tn.spans.sort((a, b) => a.start - b.start); return turns.filter((tn) => tn.spans.length > 0); } + function usageCost(usage) { + const c = usage?.cost; + if (typeof c === "number") return c; + return typeof c?.total === "number" ? c.total : 0; + } + function usageNumber(usage, ...keys) { + if (!usage) return 0; + for (const k of keys) { + const v = usage[k]; + if (typeof v === "number") return v; + } + return 0; + } + function buildTurnLedger(msgs) { + const rows = []; + let cur = null; + let hasUsage = false; + let hasCost = false; + for (const m of msgs) { + const t = parseTimestampMs(m.timestamp); + if (m.role === "user") { + const text = getTextContent(m.content || []).replace(/\s+/g, " ").trim(); + cur = { + index: rows.length + 1, + text: text.slice(0, 140) || "(user)", + messageId: m.id || null, + start: t ?? Number.NaN, + end: t ?? Number.NaN, + durationMs: 0, + toolCalls: 0, + toolErrors: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + cost: 0 + }; + rows.push(cur); + continue; + } + if (!cur) continue; + if (t !== null) { + if (Number.isNaN(cur.start)) cur.start = t; + cur.end = Math.max(Number.isNaN(cur.end) ? t : cur.end, t); + } + if (m.role === "toolCall") cur.toolCalls++; + if (m.role === "toolResult" && m.isError) cur.toolErrors++; + for (const c of m.content || []) { + if (c.type === "toolCall" || c.type === "tool_use") cur.toolCalls++; + if (c.type === "tool_result" && c.is_error) cur.toolErrors++; + } + if (m.usage) { + const inp = usageNumber(m.usage, "input", "inputTokens", "input_tokens", "prompt_tokens"); + const out = usageNumber(m.usage, "output", "outputTokens", "output_tokens", "completion_tokens"); + const cr = usageNumber(m.usage, "cacheRead", "cache_read_input_tokens", "cacheReadTokens"); + const cw = usageNumber(m.usage, "cacheWrite", "cache_creation_input_tokens", "cacheWriteTokens"); + const total = usageNumber(m.usage, "totalTokens", "total_tokens"); + if (inp || out || cr || cw || total) hasUsage = true; + cur.inputTokens += inp || (out || cr || cw ? 0 : total); + cur.outputTokens += out; + cur.cacheReadTokens += cr; + cur.cacheWriteTokens += cw; + const cost = usageCost(m.usage); + if (cost) hasCost = true; + cur.cost += cost; + } + } + for (const r of rows) { + r.durationMs = Number.isNaN(r.start) || Number.isNaN(r.end) ? 0 : Math.max(0, r.end - r.start); + } + const totals = rows.reduce( + (acc, r) => { + acc.durationMs += r.durationMs; + acc.toolCalls += r.toolCalls; + acc.tokens += r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheWriteTokens; + acc.cost += r.cost; + return acc; + }, + { durationMs: 0, toolCalls: 0, tokens: 0, cost: 0 } + ); + return { rows, totals, hasUsage, hasCost }; + } // frontend/src/lib/markdown.ts function escapeHtml(value) { diff --git a/test/unit.test.js b/test/unit.test.js index 0b91edb..e58f8bb 100644 --- a/test/unit.test.js +++ b/test/unit.test.js @@ -107,6 +107,7 @@ test('pure.js exports the moved helpers via require', () => { 'formatBytes', 'formatCost', 'buildTraceTurns', + 'buildTurnLedger', 'parseTimestampMs', 'getTextContent', 'clusterPrefillContent', @@ -352,3 +353,58 @@ test('renderMarkdownHtml renders headings, lists, links and fenced code', () => assert.ok(html.includes('x')); assert.ok(html.includes('
code<>\n
')); }); + +// --- buildTurnLedger: per-turn time / tokens / cost --- + +test('buildTurnLedger attributes time, tools, tokens and cost to the user turn that caused them', () => { + const msgs = [ + { id: 's', role: 'system', timestamp: iso(-1000), content: [] }, // preamble: no turn + { id: 'u1', role: 'user', timestamp: iso(0), content: [{ type: 'text', text: 'first question' }] }, + { + id: 'a1', + role: 'assistant', + timestamp: iso(5000), + usage: { input: 1000, output: 200, cacheRead: 5000, cost: 0.02 }, + content: [ + { type: 'text', text: 'x' }, + { type: 'toolCall', id: 'c1', name: 'bash' }, + ], + }, + { id: 'tr', role: 'toolResult', timestamp: iso(8000), toolCallId: 'c1', isError: true, content: [] }, + { + id: 'a2', + role: 'assistant', + timestamp: iso(9000), + usage: { input: 1200, output: 50, cost: { total: 0.01 } }, + content: [], + }, + { id: 'u2', role: 'user', timestamp: iso(20000), content: [{ type: 'text', text: 'second' }] }, + { id: 'a3', role: 'assistant', timestamp: iso(21000), usage: { total_tokens: 300 }, content: [] }, + ]; + const l = pure.buildTurnLedger(msgs); + assert.equal(l.rows.length, 2); + const [t1, t2] = l.rows; + assert.equal(t1.text, 'first question'); + assert.equal(t1.messageId, 'u1'); + assert.equal(t1.durationMs, 9000); // user msg -> last agent msg of the turn + assert.equal(t1.toolCalls, 1); // content-part toolCall counted once + assert.equal(t1.toolErrors, 1); + assert.deepEqual([t1.inputTokens, t1.outputTokens, t1.cacheReadTokens, t1.cacheWriteTokens], [2200, 250, 5000, 0]); + assert.ok(Math.abs(t1.cost - 0.03) < 1e-9); // number and {total} cost shapes both summed + assert.equal(t2.durationMs, 1000); + assert.equal(t2.inputTokens, 300); // total_tokens-only platforms land in the input column + assert.equal(t2.cost, 0); + assert.deepEqual(l.totals, { durationMs: 10000, toolCalls: 1, tokens: 7750, cost: 0.03 }); + assert.equal(l.hasUsage, true); + assert.equal(l.hasCost, true); +}); + +test('buildTurnLedger without usage still yields time rows and reports hasUsage=false', () => { + const l = pure.buildTurnLedger([ + { id: 'u1', role: 'user', timestamp: iso(0), content: [{ type: 'text', text: 'q' }] }, + { id: 'a1', role: 'assistant', timestamp: iso(3000), content: [] }, + ]); + assert.equal(l.rows[0].durationMs, 3000); + assert.equal(l.hasUsage, false); + assert.equal(l.hasCost, false); +});