diff --git a/src/client/components/statsContext.tsx b/src/client/components/statsContext.tsx index 6a207b0..fb3980c 100644 --- a/src/client/components/statsContext.tsx +++ b/src/client/components/statsContext.tsx @@ -2,8 +2,10 @@ * The Context card: the session as a flow diagram — the session node center * stage, fed by the two source nodes on the left (the user's own inputs and * the context events the host applied, broken down by kind) and draining - * into the two effect nodes on the right (the live tool calls and the cost - * estimate, split into the current agent's own share and the subagents'). + * into the two effect nodes on the right (the live tool calls with their + * most-called ranking, and the Agent Team ledger: the family's billed-token + * and estimated-cost totals, then one row per scope — the current agent and + * the subagent subtree, its session count included). * Measured bezier ribbons connect the nodes (a wide translucent band plus a * traveling dash of the same hue — element flow, not a conservative Sankey): * left→right on a wide card, top→bottom once the container query in @@ -32,7 +34,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react' import type { ContextEventRecord, ContextTimeline, RequestRecord, SessionCostUsage, SurfaceNode, TimelineCounts } from '../../shared/types' -import { estimateSessionCost, formatCost, formatPriceRate, mergeCostUsage, priceFaceOf, toCurrency } from '../cost' +import { estimateSessionCost, billedTokensOf, formatCost, formatPriceRate, mergeCostUsage, priceFaceOf, toCurrency } from '../cost' import type { CostCurrency, ModelBook, PriceFace } from '../cost' import { sessionsFaceOf, subagentCostFoldOf } from '../agentTree' import type { AgentHeads } from '../agentHeads' @@ -326,6 +328,7 @@ export function makeStatsContext( ].filter((el): el is ReactElement => el !== null) const costTip: ReactNode = [ t('stats.costTip'), + {t('stats.billedTip')}, pricesBlock(rows), ...notes(deepseek), unpriced ? {t('stats.costUnavailable')} : null, @@ -334,6 +337,7 @@ export function makeStatsContext( // the outage note when the subagents' models priced against nothing. const subTip: ReactNode = [ t('stats.subCostTip'), + {t('stats.billedTip')}, pricesBlock(subRows), ...notes(subDeepseek), subUnpriced ? {t('stats.costUnavailable')} : null, @@ -380,7 +384,7 @@ export function makeStatsContext( // left and the bold total right — as a div, or as an anchor opening the // models.dev provider listing in a new tab when the caller hands a // destination (the tooltip still frames and reveals off this same row). - const head = (label: string, total: string, tip?: ReactNode, href?: string): ReactElement => { + const head = (label: string, total: ReactNode, tip?: ReactNode, href?: string): ReactElement => { const body = ( <> @@ -400,13 +404,13 @@ export function makeStatsContext( // a kind class, dimmed on a zero count (the card's total already reads), // '?'-tipped when a tip rides. The label keys the pill — unique within // every card's row. - const pill = (label: string, value: string | number, cls = '', tip?: ReactNode): ReactElement => ( + const pill = (label: string, value: number, cls = '', tip?: ReactNode): ReactElement => ( {label} {tip !== undefined && } - {typeof value === 'number' ? fmt(value) : value} + {fmt(value)} {tip !== undefined && {tip}} ) @@ -417,6 +421,18 @@ export function makeStatsContext( const costPids = new Set(rows.map(r => r.face.pid).filter(p => p !== '')) const costHref = costPids.size === 1 ? 'https://models.dev/providers/' + [...costPids][0] + '/' : undefined const ownCost = estimateSessionCost(props.cost, book, currency) + // The team ledger's token faces, off the same billed buckets the figures + // price: the family's total, the current agent's own, the subagents'. + const familyTokens = billedTokensOf(usage) + const ownTokens = billedTokensOf(props.cost) + const subTokens = billedTokensOf(subUsage) + // One scope's token/cost figure pair — the ledger rows' right side. + const pair = (tokens: number, costText: string): ReactElement => ( + {fmt(tokens)}/{costText} + ) + const costText = cost === null ? '—' : formatCost(cost, currency) + const ownText = ownCost === null ? '—' : formatCost(ownCost, currency) + const subText = subCost === null ? '—' : formatCost(subCost, currency) // The I/O card's total: the user's own inputs plus every file op. const ioTotal = (props.humanInputs ?? 0) + props.files.reads + props.files.writes + props.files.searches + props.files.images // The tool card: the head's live tally figure (the tools' own tally sums @@ -493,18 +509,25 @@ export function makeStatsContext( : null}
- {head(t('stats.cost'), cost === null ? '—' : formatCost(cost, currency), costTip, costHref)} - {/* The split rides only when a subagent subtree actually reported: - without one the family figure IS the agent's own and two pills - would repeat it. */} - {subUsage !== null - ? ( -
- {pill(t('stats.ownCost'), ownCost === null ? '—' : formatCost(ownCost, currency))} - {pill(t('stats.subCost'), subCost === null ? '—' : formatCost(subCost, currency), '', subTip)} -
- ) - : null} + {head(t('stats.team'), pair(familyTokens, costText), costTip, costHref)} + {/* The team ledger: one row per scope — the current agent itself, + then the subagent subtree (its session count from the same + fold). A scope with nothing billed reads 0 tokens / a dashed + cost (the price book's outage note lives in the tips). */} +
+
+ {t('stats.currentAgent')} + {pair(ownTokens, ownText)} +
+
+ + {t('stats.teamSubs', { n: sub.count })} + + + {pair(subTokens, subText)} + {subTip} +
+
diff --git a/src/client/cost.ts b/src/client/cost.ts index dcc2a75..2c8e586 100644 --- a/src/client/cost.ts +++ b/src/client/cost.ts @@ -302,6 +302,31 @@ export function estimateSessionCost( return any ? toCurrency(total, currency) : null } +/** + * The scope's billed-token total: uncached input + cache read/write + output, + * summed over every billed model and pricing period — the token face of the + * same buckets the cost estimate prices. Hostile branches skip, the same + * re-proving the estimator applies. + */ +export function billedTokensOf(usage: SessionCostUsage | null | undefined): number { + if (usage === null || usage === undefined) return 0 + let total = 0 + for (const provider of Object.keys(usage)) { + const models = asRecord(usage[provider]) + if (models === null) continue + for (const model of Object.keys(models)) { + const periods = asRecord(models[model]) + if (periods === null) continue + for (const period of ['peak', 'off'] as const) { + const bucket = asRecord(periods[period]) + if (bucket === null) continue + total += numOf(bucket.uncached) + numOf(bucket.cacheRead) + numOf(bucket.cacheWrite) + numOf(bucket.output) + } + } + } + return total +} + /** * Accumulate one usage's buckets into `out`, summing per (provider, model, * period). Hostile branches skip (the same re-proving the estimator applies diff --git a/src/client/i18n.ts b/src/client/i18n.ts index 38f762f..29aa852 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -35,12 +35,14 @@ export const DICT_ZH: Record = { 'stats.cacheHit': '缓存命中', 'stats.cacheHitTip': '整个会话累计的缓存读取占计费输入的比例。', 'stats.cost': '费用', - 'stats.costTip': '估算的费用,含当前智能体及所有子智能体,仅供参考。', + 'stats.costTip': '估算的费用,含当前智能体及所有层级的子智能体,仅供参考。', 'stats.costTipDeepseek': 'DeepSeek 高峰时段(北京时间工作日 9:00–12:00、14:00–18:00)按挂牌价计费,其余时段半价。', 'stats.costTipCny': '人民币按 1 元 = 0.15 美元换算。', - 'stats.ownCost': '本 Agent', - 'stats.subCost': '子 Agent', - 'stats.subCostTip': '所有子 Agent 会话各自累计费用(同口径估算)的合计,不含当前 Agent 自身。', + 'stats.team': 'Agent 团队', + 'stats.currentAgent': '当前 Agent', + 'stats.teamSubs': '子 Agent × {n}', + 'stats.billedTip': 'Token 用量为计费合计:未缓存输入、缓存读取、缓存写入与输出。', + 'stats.subCostTip': '全部层级的子 Agent 会话各自累计费用(同口径估算)的合计,不含当前 Agent 自身。', 'stats.costPriceHead': '每百万 Token 价格:', 'stats.costPriceBy': '按 {p} · {m} 在 models.dev 的刊登价格如上。', 'stats.costHit': '缓存输入', @@ -449,12 +451,14 @@ export const DICT_EN: Record = { 'stats.cacheHit': 'Cache Hit', 'stats.cacheHitTip': 'Cumulative cache-read share of billed input across the session.', 'stats.cost': 'Cost', - 'stats.costTip': 'Estimated cost for this agent and all its subagents, for reference only.', + 'stats.costTip': 'Estimated cost for this agent and every descendant subagent session, for reference only.', 'stats.costTipDeepseek': 'DeepSeek bills peak windows (Beijing Time weekdays 09:00–12:00, 14:00–18:00) at list price; all other hours are half price.', 'stats.costTipCny': 'The CNY display converts at 1 CNY = 0.15 USD.', - 'stats.ownCost': 'This Agent', - 'stats.subCost': 'Subagents', - 'stats.subCostTip': 'Summed estimates of every subagent session’s own cumulative cost (same basis); the current agent is not included.', + 'stats.team': 'Agent Team', + 'stats.currentAgent': 'Current Agent', + 'stats.teamSubs': 'Subagents × {n}', + 'stats.billedTip': 'Token usage is the billed total: uncached input, cache read, cache write, and output.', + 'stats.subCostTip': 'Summed estimates of every descendant subagent session’s own cumulative cost — all levels of the subtree (same basis); the current agent is not included.', 'stats.costPriceHead': 'Per-1M-token rates:', 'stats.costPriceBy': 'Priced as listed on models.dev for {p} · {m}.', 'stats.costHit': 'Cache input', diff --git a/src/client/styles/stats.css b/src/client/styles/stats.css index 89c5475..707b924 100644 --- a/src/client/styles/stats.css +++ b/src/client/styles/stats.css @@ -152,6 +152,14 @@ a.lc-flow-head:hover .lc-flow-label { color: var(--dsw-alias-label-primary); } .lc-flow-pill b { font-weight: 700; color: var(--dsw-alias-label-primary); } .lc-flow-pill-tint b { color: inherit; } .lc-flow-pill-dim { opacity: 0.45; } +/* The Agent Team ledger's rows: the agent scope's name left, its + tokens/cost pair pinned right — one row per scope under the family header. */ +.lc-flow-rows { display: flex; flex-direction: column; gap: 3px; margin-top: 4px; } +.lc-flow-row { display: flex; align-items: baseline; gap: 6px; font-size: 11px; color: var(--dsw-alias-label-secondary); min-width: 0; } +.lc-flow-row-label { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.lc-flow-pair { margin-left: auto; flex: none; display: inline-flex; align-items: baseline; gap: 5px; font-variant-numeric: tabular-nums; } +.lc-flow-pair b { font-weight: 700; color: var(--dsw-alias-label-primary); } +.lc-flow-pair i { font-style: normal; opacity: 0.55; } /* The session node: the diagram's pivot — raised surface, brand-tinted edge and halo, the two shape figures centered under the label. */ .lc-flow-self { background: var(--dsw-alias-bg-layer-1); border-color: color-mix(in srgb, var(--dsw-alias-brand-primary) 40%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-brand-primary) 8%, transparent); padding: 8px 14px; align-items: center; gap: 4px; } diff --git a/tests/client/components/statsContext.spec.ts b/tests/client/components/statsContext.spec.ts index d2915db..3411f15 100644 --- a/tests/client/components/statsContext.spec.ts +++ b/tests/client/components/statsContext.spec.ts @@ -2,15 +2,16 @@ // React: the session-as-a-flow diagram — the two source cards (I/O: the // whole-session human-input tally plus the file read/write/search/image // pills; Context Events broken down by kind) feeding the session node -// (turns/steps), which drains into the two effect cards (Tool Calls with the -// most-called pills; Cost with the family figure, its per-model rate -// tooltip, and — when a subagent subtree reports — the own/subagents split) -// — in both locales, against an injected model-price book (the store never -// reaches the network). The connector geometry is pinned through -// measureFlow's unit tests (jsdom has no layout); the event rows themselves -// live on the events card (contextView.spec.ts). `countsOfRecords` and -// `toolTallyOf` derive the tallies the split generation's wire head carries, -// pinned here. +// (turns/steps/subagents), which drains into the two effect cards (Tool +// Calls with the most-called pills; the Agent Team ledger — the family's +// billed-token and estimated-cost totals, then one row per scope: the +// current agent and the subagent subtree — ALL levels, the fold walks the +// whole descendant tree) — in both locales, against an injected model-price +// book (the store never reaches the network). The connector geometry is +// pinned through measureFlow's unit tests (jsdom has no layout); the event +// rows themselves live on the events card (contextView.spec.ts). +// `countsOfRecords` and `toolTallyOf` derive the tallies the split +// generation's wire head carries, pinned here. import { act, createElement as h } from 'react' import assert from 'node:assert/strict' @@ -63,6 +64,11 @@ function pillsOf(node: HTMLElement): string[] { return queryAll(node, '.lc-flow-pill').map(el => (el.querySelector('.lc-flow-pill-label')?.textContent ?? '') + (el.querySelector('b')?.textContent ?? '')) } +/** The team ledger's rows as 'label+pair' strings (tips excluded). */ +function rowsOf(node: HTMLElement): string[] { + return queryAll(node, '.lc-flow-row').map(el => (el.querySelector('.lc-flow-row-label')?.textContent ?? '') + (el.querySelector('.lc-flow-pair')?.textContent ?? '')) +} + /** The four card headers (inputs / events / tools / cost), in DOM order. */ function headsOf(container: HTMLElement): { labels: string[]; totals: string[] } { const heads = queryAll(container, '.lc-flow-head') @@ -183,14 +189,16 @@ describe('StatsContext', () => { assert.ok(text(m.container).includes('Context Stats')) const nodes = flowNodes(m.container) const { labels, totals } = headsOf(m.container) - assert.deepEqual(labels, ['Input / Output', 'Context Events', 'Tool Calls', 'Cost?']) - // I/O totals the inputs plus every file op (7 + 9 + 2 + 4 + 1); 1M uncached input bills the doubled peak rate. - assert.deepEqual(totals, ['23', '6', '12', '$0.30']) + assert.deepEqual(labels, ['Input / Output', 'Context Events', 'Tool Calls', 'Agent Team?']) + // I/O totals the inputs plus every file op (7 + 9 + 2 + 4 + 1); the family's 1M + // uncached input bills the doubled peak rate — the team's header pairs its tokens with it. + assert.deepEqual(totals, ['23', '6', '12', '1.0M/$0.30']) assert.deepEqual(pillsOf(nodes[0]), ['Human Inputs?7', 'Read9', 'Written2', 'Searched4', 'Images1']) assert.deepEqual(pillsOf(nodes[1]), ['Inject3', 'Compact2', 'Prune1']) // The three most-called tools pill out; the rest fold into the overflow pill. assert.deepEqual(pillsOf(nodes[3]), ['read8', 'bash3', 'grep2', '+2 more']) - assert.deepEqual(pillsOf(nodes[4]), [], 'no split pills without a subagent subtree') + // The team ledger: the current agent's own share, then the (empty) subtree. + assert.deepEqual(rowsOf(nodes[4]), ['Current Agent1.0M/$0.30', 'Subagents × 0?0/—']) // The session node carries the three shape figures under its label (no subagents here). assert.equal(nodes[2].querySelector('.lc-flow-label')?.textContent, 'Current Session') assert.deepEqual(queryAll(nodes[2], '.lc-flow-kv b').map(el => el.textContent), ['3', '4', '0']) @@ -198,7 +206,7 @@ describe('StatsContext', () => { // The event pills tint by the events card's own kind classes. assert.ok(nodes[1].querySelector('.lc-flow-pill.lc-kind-inject') !== null) assert.ok(nodes[1].querySelector('.lc-flow-pill.lc-kind-prune') !== null) - assert.equal(queryAll(m.container, '.lc-stat-tip').length, 2, 'the inputs-pill and cost tips only') + assert.equal(queryAll(m.container, '.lc-stat-tip').length, 3, 'the inputs-pill, family, and sub-row tips') // The four connectors, two layers each (jsdom measures zeroed boxes — the geometry itself is measureFlow's pin). assert.equal(queryAll(m.container, '.lc-flow-ribbon').length, 4) assert.equal(queryAll(m.container, '.lc-flow-dash').length, 4) @@ -213,10 +221,11 @@ describe('StatsContext', () => { locale: 'en', })) await flush() - assert.deepEqual(headsOf(m.container).totals, ['0', '0', '0', '—']) + assert.deepEqual(headsOf(m.container).totals, ['0', '0', '0', '0/—']) const nodes = flowNodes(m.container) assert.deepEqual(pillsOf(nodes[0]), ['Human Inputs?0', 'Read0', 'Written0'], 'searches/images pill only when they happened') assert.deepEqual(pillsOf(nodes[3]), [], 'no tool pills without calls') + assert.deepEqual(rowsOf(nodes[4]), ['Current Agent0/—', 'Subagents × 0?0/—']) assert.equal(queryAll(nodes[0], '.lc-flow-pill-dim').length, 3, 'every zero pill dims') assert.equal(queryAll(nodes[1], '.lc-flow-pill-dim').length, 3) await m.unmount() @@ -246,10 +255,11 @@ describe('StatsContext', () => { })) await flush() const tips = queryAll(m.container, '.lc-stat-tip').map(el => text(el)) - assert.equal(tips.length, 2) + assert.equal(tips.length, 3, 'the inputs-pill, family, and sub-row tips') assert.ok(tips[0].includes('question answerings'), 'the human-inputs tip explains its tally') const costTip = tips[1] - assert.ok(costTip.includes('this agent and all its subagents'), 'the cost tip names the family scope') + assert.ok(costTip.includes('every descendant subagent session'), 'the cost tip names the family scope — all levels') + assert.ok(costTip.includes('billed total'), 'the tip glosses the token face too') assert.ok(costTip.includes('Per-1M-token rates:')) assert.ok(costTip.includes('Priced as listed on models.dev for deepseek · deepseek-v4-flash.'), 'the listing line names the resolved registry face') // The table lists the book's own rates (the footnotes carry the peak scheme). @@ -331,8 +341,8 @@ describe('StatsContext', () => { const costTip = text(queryAll(m.container, '.lc-stat-tip')[1]) assert.ok(costTip.includes('for deepseek · deepseek-v4-flash.')) assert.ok(costTip.includes('for zhipuai · glm-5.3-flash.')) - // 1M × $0.15 × 2 (the DeepSeek peak) + 2M × $0.075 = $0.45. - assert.ok(headsOf(m.container).totals[3] === '$0.45') + // 1M × $0.15 × 2 (the DeepSeek peak) + 2M × $0.075 = $0.45; the tokens total too. + assert.ok(headsOf(m.container).totals[3] === '3.0M/$0.45') await m.unmount() }) @@ -348,16 +358,17 @@ describe('StatsContext', () => { assert.ok(text(m.container).includes('上下文统计')) const nodes = flowNodes(m.container) const { labels, totals } = headsOf(m.container) - assert.deepEqual(labels, ['输入输出', '上下文事件', '工具调用', '费用?']) + assert.deepEqual(labels, ['输入输出', '上下文事件', '工具调用', 'Agent 团队?']) // $0.30 / 0.15 = ¥2; the rates convert through the same fixed rate. - assert.deepEqual(totals, ['3', '1', '2', '¥2.00']) + assert.deepEqual(totals, ['3', '1', '2', '1.0M/¥2.00']) assert.deepEqual(pillsOf(nodes[0]), ['用户输入?0', '读取2', '写入1']) assert.deepEqual(pillsOf(nodes[1]), ['注入0', '压缩1', '剪枝0']) assert.deepEqual(pillsOf(nodes[3]), ['read2']) + assert.deepEqual(rowsOf(nodes[4]), ['当前 Agent1.0M/¥2.00', '子 Agent × 0?0/—']) assert.equal(nodes[2].querySelector('.lc-flow-label')?.textContent, '当前会话') assert.deepEqual(queryAll(nodes[2], '.lc-flow-kv i').map(el => el.textContent), ['轮次', '步数', '子 Agent']) const costTip = text(queryAll(m.container, '.lc-stat-tip')[1]) - assert.ok(costTip.includes('含当前智能体及所有子智能体'), 'the cost tip names the family scope too') + assert.ok(costTip.includes('含当前智能体及所有层级的子智能体'), 'the cost tip names the family scope too — all levels') assert.ok(costTip.includes('每百万 Token 价格')) assert.ok(costTip.includes('按 deepseek · deepseek-v4-flash 在 models.dev 的刊登价格如上。')) assert.ok(costTip.includes('人民币按 1 元 = 0.15 美元换算。'), 'the CNY display carries the conversion footnote') @@ -369,7 +380,7 @@ describe('StatsContext', () => { await m.unmount() }) - test('a book that has not landed yet keeps the cost figure dashed', async () => { + test('a book that has not landed yet keeps the cost figure dashed (the tokens still read)', async () => { setModelPricesLoader(() => new Promise(() => {})) const m = await mount(h(StatsContext, { counts: NO_COUNTS, @@ -379,7 +390,7 @@ describe('StatsContext', () => { locale: 'en', })) await flush() - assert.ok(headsOf(m.container).totals[3] === '—') + assert.ok(headsOf(m.container).totals[3] === '1.0M/—') assert.ok(!text(m.container).includes('unavailable'), 'a pending fetch is not a failure') await m.unmount() }) @@ -394,7 +405,7 @@ describe('StatsContext', () => { locale: 'en', })) await flush() - assert.ok(headsOf(m.container).totals[3] === '—') + assert.ok(headsOf(m.container).totals[3] === '1.0M/—') const costTip = text(queryAll(m.container, '.lc-stat-tip')[1]) assert.ok(costTip.includes('unavailable')) assert.ok(!costTip.includes('Per-1M-token rates')) @@ -419,7 +430,7 @@ describe('StatsContext', () => { })) await flush() // 1M at the doubled $0.3 peak miss rate + 2M at the $0.15 off-peak (book) rate. - assert.ok(headsOf(m.container).totals[3] === '$0.60') + assert.ok(headsOf(m.container).totals[3] === '3.0M/$0.60') const costTip = text(queryAll(m.container, '.lc-stat-tip')[1]) assert.ok(costTip.includes('Cache input $0.00'), 'the table lists the book rates once, whatever the billed buckets') assert.ok(costTip.includes('Uncached input $0.15')) @@ -437,7 +448,7 @@ describe('StatsContext', () => { locale: 'en', })) await flush() - assert.ok(headsOf(m.container).totals[3] === '—') + assert.ok(headsOf(m.container).totals[3] === '1/—') assert.ok(text(queryAll(m.container, '.lc-stat-tip')[1]).includes('unavailable')) await m.unmount() }) @@ -467,7 +478,7 @@ describe('StatsContext', () => { const rows = queryAll(m.container, '.lc-stat-tip-row').map(el => text(el)) assert.ok(rows.every(r => !r.includes('|'))) // Both buckets bill at list price: 2M × $0.075 — no half-price off-peak. - assert.ok(headsOf(m.container).totals[3] === '$0.15') + assert.ok(headsOf(m.container).totals[3] === '2.0M/$0.15') await m.unmount() }) }) @@ -488,15 +499,16 @@ describe('StatsContext — the subagent split (injected seat)', () => { })) await flush() // Family total: 1M × $0.30 (the doubled peak) + 2M × $0.075; own share $0.30, the subagents' $0.15. - assert.equal(headsOf(m.container).totals[3], '$0.45') - assert.deepEqual(pillsOf(flowNodes(m.container)[4]), ['This Agent$0.30', 'Subagents?$0.15']) + assert.equal(headsOf(m.container).totals[3], '3.0M/$0.45') + assert.deepEqual(rowsOf(flowNodes(m.container)[4]), ['Current Agent1.0M/$0.30', 'Subagents × 2?2.0M/$0.15']) // The session node's third figure counts the subtree. assert.deepEqual(queryAll(flowNodes(m.container)[2], '.lc-flow-kv b').map(el => el.textContent), ['0', '0', '2']) const tips = queryAll(m.container, '.lc-stat-tip').map(el => text(el)) // The rate table covers BOTH sides' models in either tip. assert.ok(tips[1].includes('for deepseek · deepseek-v4-flash.')) assert.ok(tips[1].includes('for zhipuai · glm-5.3-flash.')) - assert.ok(tips[2].includes('every subagent session')) + assert.ok(tips[2].includes('every descendant subagent session')) + assert.ok(tips[2].includes('all levels'), 'the sub tip spells out the subtree covers all levels') assert.ok(tips[2].includes('for zhipuai · glm-5.3-flash.'), 'the sub tip carries its own price table') await m.unmount() }) @@ -510,8 +522,8 @@ describe('StatsContext — the subagent split (injected seat)', () => { locale: 'en', })) await flush() - assert.equal(headsOf(m.container).totals[3], '$0.15') - assert.deepEqual(pillsOf(flowNodes(m.container)[4]), ['This Agent—', 'Subagents?$0.15']) + assert.equal(headsOf(m.container).totals[3], '2.0M/$0.15') + assert.deepEqual(rowsOf(flowNodes(m.container)[4]), ['Current Agent0/—', 'Subagents × 1?2.0M/$0.15']) await m.unmount() }) @@ -528,7 +540,7 @@ describe('StatsContext — the subagent split (injected seat)', () => { })) await flush() // The off bucket bills at book: 1M × $0.15. - assert.equal(headsOf(m.container).totals[3], '$0.15') + assert.equal(headsOf(m.container).totals[3], '1.0M/$0.15') const tips = queryAll(m.container, '.lc-stat-tip').map(el => text(el)) assert.ok(tips[1].includes('peak windows')) assert.ok(tips[2].includes('peak windows'), 'the sub tip explains the scheme its own figure rides') @@ -547,15 +559,15 @@ describe('StatsContext — the subagent split (injected seat)', () => { await flush() // The family's own model still prices: $0.30 (the doubled peak). The // unpriceable sub branch merges in (pricing zero) but cannot lift the - // total — the sub pill dashes. - assert.equal(headsOf(m.container).totals[3], '$0.30') - assert.deepEqual(pillsOf(flowNodes(m.container)[4]), ['This Agent$0.30', 'Subagents?—']) + // total — the sub cost dashes; its single token still reads. + assert.equal(headsOf(m.container).totals[3], '1.0M/$0.30') + assert.deepEqual(rowsOf(flowNodes(m.container)[4]), ['Current Agent1.0M/$0.30', 'Subagents × 1?1/—']) const tips = queryAll(m.container, '.lc-stat-tip').map(el => text(el)) assert.ok(tips[2].includes('unavailable')) await m.unmount() }) - test('a null seat keeps the figure dashed with no split pills and no outage notes', async () => { + test('a null seat keeps the figure dashed with the ledger zeroed and no outage notes', async () => { const m = await mount(h(StatsContext, { counts: NO_COUNTS, files: NO_FILES, @@ -563,8 +575,8 @@ describe('StatsContext — the subagent split (injected seat)', () => { locale: 'en', })) await flush() - assert.equal(headsOf(m.container).totals[3], '—') - assert.deepEqual(pillsOf(flowNodes(m.container)[4]), []) + assert.equal(headsOf(m.container).totals[3], '0/—') + assert.deepEqual(rowsOf(flowNodes(m.container)[4]), ['Current Agent0/—', 'Subagents × 0?0/—']) assert.ok(!text(m.container).includes('unavailable')) await m.unmount() }) @@ -642,8 +654,8 @@ describe('StatsContext — the real subagent-cost seat (makeSubagentCost)', () = })) await flush() // The warm row's usage prices while the cold read is still in flight. - assert.equal(headsOf(m.container).totals[3], '$0.15') - assert.deepEqual(pillsOf(flowNodes(m.container)[4]), ['This Agent—', 'Subagents?$0.15']) + assert.equal(headsOf(m.container).totals[3], '2.0M/$0.15') + assert.deepEqual(rowsOf(flowNodes(m.container)[4]), ['Current Agent0/—', 'Subagents × 2?2.0M/$0.15']) assert.deepEqual(calls, ['cold'], 'only the timeline-less relative fetched') // Both descendants count toward the session node's third figure, cold included. assert.deepEqual(queryAll(flowNodes(m.container)[2], '.lc-flow-kv b').map(el => el.textContent), ['0', '0', '2']) @@ -677,8 +689,8 @@ describe('StatsContext — the real subagent-cost seat (makeSubagentCost)', () = }) }) await flush() - assert.equal(headsOf(m.container).totals[3], '$0.30') - assert.deepEqual(pillsOf(flowNodes(m.container)[4]), ['This Agent—', 'Subagents?$0.30']) + assert.equal(headsOf(m.container).totals[3], '4.0M/$0.30') + assert.deepEqual(rowsOf(flowNodes(m.container)[4]), ['Current Agent0/—', 'Subagents × 2?4.0M/$0.30']) await m.unmount() }) @@ -701,8 +713,8 @@ describe('StatsContext — the real subagent-cost seat (makeSubagentCost)', () = })) await flush() assert.deepEqual(calls, ['cold']) - assert.equal(headsOf(m.container).totals[3], '—') - assert.deepEqual(pillsOf(flowNodes(m.container)[4]), [], 'nothing reported → no split pills') + assert.equal(headsOf(m.container).totals[3], '0/—') + assert.deepEqual(rowsOf(flowNodes(m.container)[4]), ['Current Agent0/—', 'Subagents × 1?0/—']) // The cold descendant still counts — a subagent is one whether or not its usage priced. assert.deepEqual(queryAll(flowNodes(m.container)[2], '.lc-flow-kv b').map(el => el.textContent), ['0', '0', '1']) // A later snapshot tick re-folds the subtree; the sticky failure never re-fetches. @@ -741,9 +753,9 @@ describe('StatsContext — the real subagent-cost seat (makeSubagentCost)', () = })) await flush() assert.deepEqual(calls, ['headless', 'absent']) - // Neither read carried a head: nothing lands, the figure stays a dash, no throw. - assert.equal(headsOf(m.container).totals[3], '—') - assert.deepEqual(pillsOf(flowNodes(m.container)[4]), []) + // Neither read carried a head: nothing lands, the figures stay zeroed, no throw. + assert.equal(headsOf(m.container).totals[3], '0/—') + assert.deepEqual(rowsOf(flowNodes(m.container)[4]), ['Current Agent0/—', 'Subagents × 2?0/—']) // A later tick re-attaches to the settled nulls without re-fetching. await act(async () => { face.setState({ @@ -768,8 +780,8 @@ describe('StatsContext — the real subagent-cost seat (makeSubagentCost)', () = sessionId: 'root', })) await flush() - assert.equal(headsOf(m.container).totals[3], '—') - assert.deepEqual(pillsOf(flowNodes(m.container)[4]), []) + assert.equal(headsOf(m.container).totals[3], '0/—') + assert.deepEqual(rowsOf(flowNodes(m.container)[4]), ['Current Agent0/—', 'Subagents × 0?0/—']) await m.unmount() }) @@ -790,8 +802,8 @@ describe('StatsContext — the real subagent-cost seat (makeSubagentCost)', () = })) await flush() assert.deepEqual(calls, []) - assert.equal(headsOf(m.container).totals[3], '—') - assert.deepEqual(pillsOf(flowNodes(m.container)[4]), []) + assert.equal(headsOf(m.container).totals[3], '0/—') + assert.deepEqual(rowsOf(flowNodes(m.container)[4]), ['Current Agent0/—', 'Subagents × 0?0/—']) await m.unmount() }) }) diff --git a/tests/client/cost.spec.ts b/tests/client/cost.spec.ts index 502cb0b..0f8de52 100644 --- a/tests/client/cost.spec.ts +++ b/tests/client/cost.spec.ts @@ -7,9 +7,9 @@ import assert from 'node:assert/strict' import { describe, test } from 'vitest' -import { estimateSessionCost, formatCost, formatPriceRate, mergeCostUsage, priceFaceOf, priceIndexOf, priceOf, toCurrency } from '../../src/client/cost' +import { estimateSessionCost, billedTokensOf, formatCost, formatPriceRate, mergeCostUsage, priceFaceOf, priceIndexOf, priceOf, toCurrency } from '../../src/client/cost' import type { ModelBook, ModelPrices } from '../../src/client/cost' -import type { CostBucketTotals } from '../../src/shared/types' +import type { CostBucketTotals, SessionCostUsage } from '../../src/shared/types' const M = 1_000_000 @@ -239,8 +239,36 @@ describe('priceIndexOf / the model-side resolution tiers', () => { }) }) -describe('estimateSessionCost', () => { - test('null usage or null book prices to null', () => { +describe('billedTokensOf (the token face of the billed buckets)', () => { + test('null and empty usages total zero', () => { + assert.equal(billedTokensOf(null), 0) + assert.equal(billedTokensOf(undefined), 0) + assert.equal(billedTokensOf({}), 0) + }) + + test('sums every bucket across providers, models, and periods', () => { + const usage: SessionCostUsage = { + p1: { + m1: { peak: { uncached: 1, cacheRead: 2, cacheWrite: 3, output: 4 }, off: { uncached: 10, cacheRead: 20, cacheWrite: 30, output: 40 } }, + m2: { peak: { uncached: 100, cacheRead: 0, cacheWrite: 0, output: 0 } }, + }, + p2: { m3: { off: { uncached: 0, cacheRead: 0, cacheWrite: 0, output: 1000 } } }, + } + assert.equal(billedTokensOf(usage), 1 + 2 + 3 + 4 + 10 + 20 + 30 + 40 + 100 + 1000) + }) + + test('hostile branches skip, the estimator’s own resilience', () => { + const usage = { + junk: 5, + p: { m: 'garbage', m2: { peak: null, off: { uncached: 'x', cacheRead: 7 } } }, + } as unknown as SessionCostUsage + // The non-record provider/model/period branches drop whole; the junk + // bucket field coerces to zero (numOf), the number survives. + assert.equal(billedTokensOf(usage), 7) + }) +}) + +describe('estimateSessionCost', () => { test('null usage or null book prices to null', () => { assert.equal(estimateSessionCost(null, BOOK_B, 'usd'), null) assert.equal(estimateSessionCost(undefined, BOOK_B, 'cny'), null) assert.equal(estimateSessionCost({}, null, 'usd'), null)