feat(context): rebuild the cost card as the Agent Team ledger

The flow's fourth node becomes a three-row team ledger: the family's
billed-token and estimated-cost totals in the header, then one row per
scope — the current agent and the subagent subtree with its session count
— each pairing its billed tokens with the estimated cost (billedTokensOf:
the token face of the same buckets the estimate prices). The subagent
scope is the WHOLE descendant tree, which the copy now spells out; the
rows render unconditionally (a scope with nothing billed reads 0 / —),
and both tooltips gloss the token figure.
This commit is contained in:
bowenliang123 committed 2026-10-07 11:53:43 +08:00
1 parent c4ad2e8a9c
commit 3dc892c221
6 files changed
+183 -83

No files matched your search

+41 -18
View File
@@ -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'),
<span key="billed" className="lc-stat-tip-row">{t('stats.billedTip')}</span>,
pricesBlock(rows),
...notes(deepseek),
unpriced ? <span key="unavailable">{t('stats.costUnavailable')}</span> : null,
@@ -334,6 +337,7 @@ export function makeStatsContext(
// the outage note when the subagents' models priced against nothing.
const subTip: ReactNode = [
t('stats.subCostTip'),
<span key="billed" className="lc-stat-tip-row">{t('stats.billedTip')}</span>,
pricesBlock(subRows),
...notes(subDeepseek),
subUnpriced ? <span key="unavailable">{t('stats.costUnavailable')}</span> : 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 = (
<>
<span className="lc-flow-label">
@@ -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 => (
<span key={label} className={'lc-flow-pill' + cls + (value === 0 ? ' lc-flow-pill-dim' : '') + (tip === undefined ? '' : ' lc-stat-tipped group/tip')}>
<span className="lc-flow-pill-label">
{label}
{tip !== undefined && <i className="lc-stat-q group-hover/tip:text-(--dsw-alias-label-primary) group-hover/tip:border-(--dsw-alias-label-primary)" aria-hidden="true">?</i>}
</span>
<b>{typeof value === 'number' ? fmt(value) : value}</b>
<b>{fmt(value)}</b>
{tip !== undefined && <span className="lc-tip lc-stat-tip group-hover/tip:opacity-100" role="tooltip">{tip}</span>}
</span>
)
@@ -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 => (
<span className="lc-flow-pair"><b>{fmt(tokens)}</b><i>/</i><b>{costText}</b></span>
)
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}
</div>
<div className="lc-flow-node" ref={nodeRef('cost')}>
{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
? (
<div className="lc-flow-pills">
{pill(t('stats.ownCost'), ownCost === null ? '—' : formatCost(ownCost, currency))}
{pill(t('stats.subCost'), subCost === null ? '—' : formatCost(subCost, currency), '', subTip)}
</div>
)
: 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). */}
<div className="lc-flow-rows">
<div className="lc-flow-row">
<span className="lc-flow-row-label">{t('stats.currentAgent')}</span>
{pair(ownTokens, ownText)}
</div>
<div className="lc-flow-row lc-stat-tipped group/tip">
<span className="lc-flow-row-label">
{t('stats.teamSubs', { n: sub.count })}
<i className="lc-stat-q group-hover/tip:text-(--dsw-alias-label-primary) group-hover/tip:border-(--dsw-alias-label-primary)" aria-hidden="true">?</i>
</span>
{pair(subTokens, subText)}
<span className="lc-tip lc-stat-tip group-hover/tip:opacity-100" role="tooltip">{subTip}</span>
</div>
</div>
</div>
</div>
</div>
+25
View File
@@ -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
+12 -8
View File
@@ -35,12 +35,14 @@ export const DICT_ZH: Record<string, string> = {
'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<string, string> = {
'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',
+8
View File
@@ -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; }
+65 -53
View File
@@ -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()
})
})
+32 -4
View File
@@ -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)