The tooltip and shared type still described the pre-#78 scheme (peak at list price, off at half), while models.dev lists DeepSeek's off-peak rates and the estimator doubles the peak bucket. Correct the wording in both locales, the cost type, and the READMEs; only DeepSeek's peak buckets double, every other provider prices at list. Express the peak clock in Beijing Time directly (Monday-Friday 09:00-12:00 and 14:00-18:00, weekends off-peak) and rename isPeakUtc to isBeijingPeak. The UTC-to-Beijing refactor is behavior-preserving, so no stateVersion bump is needed. Statutory holidays are deliberately not modeled; record that limitation in docs/compatibility.md. Refs #178
968 lines
44 KiB
TypeScript
968 lines
44 KiB
TypeScript
// StatsContext (src/client/components/statsContext.tsx) rendered in both locales against an injected model-price book (the store never
|
||
// reaches the network). Connector geometry is pinned through measureFlow's unit tests (jsdom has no layout); the event rows live on the
|
||
// events card (contextView.spec.ts). `countsOfRecords` and `toolTallyOf` derive the tallies the split generation's slim head carries.
|
||
|
||
import { act, createElement as h } from 'react'
|
||
import assert from 'node:assert/strict'
|
||
import { afterEach, describe, test, vi, beforeEach } from 'vitest'
|
||
import { countsOfRecords, flowCurve, makeStatsContext, makeSubagentCost, measureFlow, toolTallyOf } from '../../../src/client/components/statsContext'
|
||
import type { SubagentStats } from '../../../src/client/components/statsContext'
|
||
import { DICT_EN } from '../../../src/client/i18n'
|
||
import { makeAgentHeads } from '../../../src/client/agentHeads'
|
||
import { resetModelPrices, setModelPricesLoader } from '../../../src/client/modelPrices'
|
||
import type { ContextEventRecord, ContextTimeline, RequestRecord, SessionCostUsage, SurfaceNode } from '../../../src/shared/types'
|
||
import { TestClientCtx, asClientCtx } from '../helpers/harness'
|
||
import { click, flush, makeKit, mount, query, queryAll, text } from '../helpers/kit'
|
||
|
||
const kit = makeKit()
|
||
const kitZh = makeKit('zh')
|
||
const NO_SUB = (): SubagentStats => ({ usage: null, count: 0 })
|
||
const StatsContext = makeStatsContext(kit, NO_SUB)
|
||
const StatsContextZh = makeStatsContext(kitZh, NO_SUB)
|
||
|
||
/** A minimal real-shaped slice of the models.dev /api.json payload. */
|
||
const PROVIDERS = {
|
||
deepseek: { models: { 'deepseek-v4-flash': { cost: { input: 0.15, output: 0.6, cache_read: 0.003 } } } },
|
||
zhipuai: { models: { 'glm-5.3-flash': { cost: { input: 0.075, output: 0.25, cache_read: 0.015, cache_write: 0 } } } },
|
||
}
|
||
|
||
const COST: SessionCostUsage = {
|
||
'deepseek-official': { 'deepseek-v4-flash': { peak: { uncached: 1_000_000, cacheRead: 0, cacheWrite: 0, output: 0 } } },
|
||
}
|
||
|
||
const NO_FILES = { reads: 0, writes: 0, searches: 0, images: 0 }
|
||
const NO_COUNTS = { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }
|
||
|
||
function req(turn?: number): RequestRecord {
|
||
return {
|
||
time: 0, seq: 0, system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0,
|
||
...(turn !== undefined ? { turn } : {}),
|
||
}
|
||
}
|
||
|
||
function ev(kind: ContextEventRecord['kind']): ContextEventRecord {
|
||
return { seq: 0, time: 0, kind }
|
||
}
|
||
|
||
/** A skill-tagged inject event the way the host fold stamps it (fold.ts). */
|
||
function skillEv(name: string): ContextEventRecord {
|
||
return { seq: 0, time: 0, kind: 'inject', form: 'instructions', sub: 'skill', name }
|
||
}
|
||
|
||
/** The five flow nodes in DOM order: inputs / events / session / tools / cost. */
|
||
function flowNodes(container: HTMLElement): HTMLElement[] {
|
||
return queryAll(container, '.lc-flow-node')
|
||
}
|
||
|
||
/** One node's VISIBLE pills as 'label+figure' strings. The tools node also carries an off-flow gauge (every pill at
|
||
* its natural width, for `ToolPills`' fit) — the gauge is never shown, so it is not part of what a reader sees. */
|
||
function pillsOf(node: HTMLElement): string[] {
|
||
return queryAll(node, '.lc-flow-pill').filter(el => el.closest('.lc-flow-pill-gauge') === null)
|
||
.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 ?? ''))
|
||
}
|
||
|
||
function headsOf(container: HTMLElement): { labels: string[]; totals: string[] } {
|
||
const heads = queryAll(container, '.lc-flow-head')
|
||
return {
|
||
labels: heads.map(el => el.querySelector('.lc-flow-label')?.textContent ?? ''),
|
||
totals: heads.map(el => el.querySelector('.lc-flow-total')?.textContent ?? ''),
|
||
}
|
||
}
|
||
|
||
beforeEach(() => {
|
||
resetModelPrices()
|
||
setModelPricesLoader(() => Promise.resolve(PROVIDERS))
|
||
})
|
||
|
||
afterEach(() => {
|
||
resetModelPrices()
|
||
})
|
||
|
||
describe('countsOfRecords (the inline generation derivation)', () => {
|
||
test('tallies distinct turns, records, the three priced event kinds, and the distinct loaded skills', () => {
|
||
// Two steps in turn 1, one in turn 2, one without a turn (folds as turn 0).
|
||
const counts = countsOfRecords(
|
||
[req(1), req(1), req(2), req()],
|
||
[
|
||
ev('inject'), ev('inject'), ev('inject'), ev('compaction'), ev('compaction'), ev('prune'), ev('model'), ev('mode'),
|
||
// Skill loads and a `/name` invocation count by DISTINCT name; the
|
||
// available-skills catalog digest rides an untagged inject event and never lands in the tally.
|
||
skillEv('grilling'), skillEv('grilling'), skillEv('ponytail'),
|
||
{ ...ev('inject'), name: 'available-skills' },
|
||
// Hostile shapes skip: a nameless tag and a non-string name.
|
||
{ ...ev('inject'), sub: 'skill' },
|
||
{ ...ev('inject'), sub: 'skill', name: 5 } as unknown as ContextEventRecord,
|
||
],
|
||
)
|
||
// model/mode events do not appear (only the three priced kinds do); every
|
||
// inject above rides kind 'inject', the catalog digest and hostile shapes
|
||
// included — only the skills TALLY filters by the tag.
|
||
assert.deepEqual(counts, { turns: 3, steps: 4, injects: 9, compactions: 2, prunes: 1, skills: 2 })
|
||
})
|
||
|
||
test('empty collections tally zero', () => {
|
||
assert.deepEqual(countsOfRecords([], []), { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0, skills: 0 })
|
||
})
|
||
})
|
||
|
||
describe('toolTallyOf (the live-surface per-tool tally)', () => {
|
||
const node = (cat: SurfaceNode['cat'], tool?: string): SurfaceNode => ({ seq: 0, cat, tokens: 0, ...(tool !== undefined ? { tool } : {}) })
|
||
|
||
test('folds the host predicate: tool results, plus skill nodes carrying a tool', () => {
|
||
const tally = toolTallyOf([
|
||
node('tool', 'read'), node('tool', 'read'), node('tool', 'bash'),
|
||
node('skill', 'run_code'), // a skill node carrying a tool counts
|
||
node('skill'), // a bare skill node does not
|
||
node('user'), node('assistant'),
|
||
node('tool'), // an unnamed result counts toward the head figure but rides no pill
|
||
])
|
||
assert.deepEqual(tally, [['read', 2], ['bash', 1], ['run_code', 1]])
|
||
})
|
||
|
||
test('sorts count-desc then name-asc, skipping hostile shapes', () => {
|
||
const tally = toolTallyOf([
|
||
node('tool', 'bash'), node('tool', 'read'), node('tool', 'read'),
|
||
{ seq: 0, cat: 'tool', tokens: 0, tool: 5 } as unknown as SurfaceNode,
|
||
node('tool', ''),
|
||
])
|
||
assert.deepEqual(tally, [['read', 2], ['bash', 1]])
|
||
})
|
||
|
||
test('count ties break name-asc in either insertion order', () => {
|
||
assert.deepEqual(toolTallyOf([node('tool', 'zed'), node('tool', 'apple')]), [['apple', 1], ['zed', 1]])
|
||
assert.deepEqual(toolTallyOf([node('tool', 'apple'), node('tool', 'zed')]), [['apple', 1], ['zed', 1]])
|
||
})
|
||
})
|
||
|
||
describe('measureFlow (the connector geometry)', () => {
|
||
const boxes = {
|
||
inputs: { x: 0, y: 0, w: 100, h: 40 },
|
||
events: { x: 0, y: 60, w: 100, h: 80 },
|
||
session: { x: 160, y: 30, w: 120, h: 80 },
|
||
tools: { x: 340, y: 0, w: 100, h: 40 },
|
||
cost: { x: 340, y: 60, w: 100, h: 80 },
|
||
}
|
||
|
||
test('horizontal mode flows left→right, fanning the session edge at ⅓ and ⅔, colored by element', () => {
|
||
const links = measureFlow(boxes, true)
|
||
assert.equal(links.length, 4)
|
||
// inputs→session: inputs' right-center to the session's left edge at ⅓ (control distance 30 = half the 60px gap).
|
||
assert.equal(links[0].d, `M 100 20 C 130 20 130 ${30 + 80 * (1 / 3)} 160 ${30 + 80 * (1 / 3)}`)
|
||
// events→session: events' right-center to the session's left edge at ⅔.
|
||
assert.equal(links[1].d, `M 100 100 C 130 100 130 ${30 + 80 * (2 / 3)} 160 ${30 + 80 * (2 / 3)}`)
|
||
// session→tools: the session's right edge at ⅓ to tools' left-center.
|
||
assert.equal(links[2].d, `M 280 ${30 + 80 * (1 / 3)} C 310 ${30 + 80 * (1 / 3)} 310 20 340 20`)
|
||
// session→cost: the session's right edge at ⅔ to cost's left-center.
|
||
assert.equal(links[3].d, `M 280 ${30 + 80 * (2 / 3)} C 310 ${30 + 80 * (2 / 3)} 310 100 340 100`)
|
||
assert.deepEqual(links.map(l => l.color), [
|
||
'var(--color-green-500)', 'var(--color-purple-500)', 'var(--color-teal-500)', 'var(--color-pink-500)',
|
||
])
|
||
})
|
||
|
||
test('vertical mode flows top→bottom with the same fan', () => {
|
||
const links = measureFlow(boxes, false)
|
||
assert.equal(links.length, 4)
|
||
// inputs→session: inputs' bottom-center to the session's top edge at ⅓; the 10px gap floors the control distance at 18.
|
||
assert.equal(links[0].d, `M 50 40 C 50 58 ${160 + 120 * (1 / 3)} 12 ${160 + 120 * (1 / 3)} 30`)
|
||
// session→cost: the session's bottom edge at ⅔ to cost's top-center; control distance 25 = half the 50px gap.
|
||
assert.equal(links[3].d, `M ${160 + 120 * (2 / 3)} 110 C ${160 + 120 * (2 / 3)} 135 390 35 390 60`)
|
||
})
|
||
|
||
test('flowCurve floors the control distance so near neighbors still bow', () => {
|
||
assert.equal(
|
||
flowCurve({ x: 0, y: 0, dx: 1, dy: 0 }, { x: 20, y: 0, dx: -1, dy: 0 }),
|
||
'M 0 0 C 18 0 2 0 20 0',
|
||
)
|
||
})
|
||
})
|
||
|
||
describe('StatsContext', () => {
|
||
/** jsdom has no layout, so `ToolPills` shows the whole tally. Stubbing widths lets the fit run for real: a pill is
|
||
* sized by its label's length, and the "+N" chip by its own. */
|
||
function stubWidths(pillWidth: number, chipWidth: number, rowWidth: number): () => void {
|
||
const real = Element.prototype.getBoundingClientRect
|
||
Element.prototype.getBoundingClientRect = function (this: Element): DOMRect {
|
||
const chip = this.classList.contains('lc-flow-pill-dim')
|
||
const inGauge = this.closest('.lc-flow-pill-gauge') !== null
|
||
if (!this.classList.contains('lc-flow-pill') && !this.classList.contains('lc-flow-pills')) return real.call(this)
|
||
if (this.classList.contains('lc-flow-pills-tools')) {
|
||
return { width: rowWidth, height: 0, top: 0, left: 0, right: rowWidth, bottom: 0, x: 0, y: 0, toJSON: () => ({}) } as DOMRect
|
||
}
|
||
const w = chip ? chipWidth : pillWidth
|
||
const left = inGauge && chip ? chipWidth : 0
|
||
return { width: w, height: 0, top: 0, left, right: left + w, bottom: 0, x: left, y: 0, toJSON: () => ({}) } as DOMRect
|
||
}
|
||
return () => { Element.prototype.getBoundingClientRect = real }
|
||
}
|
||
|
||
test('the tools row folds the tail into the overflow pill at the width it is measured against', async () => {
|
||
const restore = stubWidths(100, 40, 260)
|
||
try {
|
||
// 100px pills + a 40px chip + 4px gaps: 260 holds two pills and the chip (248), never three (352).
|
||
const m = await mount(h(StatsContext, {
|
||
counts: NO_COUNTS, files: NO_FILES, tools: [['read', 8], ['bash', 3], ['grep', 2]], locale: 'en',
|
||
}))
|
||
await flush()
|
||
assert.deepEqual(pillsOf(flowNodes(m.container)[3]), ['read8', 'bash3', '+1 more'])
|
||
await m.unmount()
|
||
} finally {
|
||
restore()
|
||
}
|
||
})
|
||
|
||
test('a wide card spells out every tool, with the overflow pill at zero', async () => {
|
||
const restore = stubWidths(100, 40, 900)
|
||
try {
|
||
const m = await mount(h(StatsContext, {
|
||
counts: NO_COUNTS, files: NO_FILES, tools: [['read', 8], ['bash', 3], ['grep', 2]], locale: 'en',
|
||
}))
|
||
await flush()
|
||
assert.deepEqual(pillsOf(flowNodes(m.container)[3]), ['read8', 'bash3', 'grep2', '+0 more'])
|
||
await m.unmount()
|
||
} finally {
|
||
restore()
|
||
}
|
||
})
|
||
|
||
test('folds the flow: source cards feed the session node, which drains into the effect cards', async () => {
|
||
const m = await mount(h(StatsContext, {
|
||
counts: { turns: 3, steps: 4, injects: 3, compactions: 2, prunes: 1, skills: 2 },
|
||
humanInputs: 7,
|
||
answers: 3,
|
||
toolCalls: 12,
|
||
files: { reads: 9, writes: 2, searches: 4, images: 1 },
|
||
tools: [['read', 8], ['bash', 3], ['grep', 2], ['edit', 1], ['glob', 1]],
|
||
cost: COST,
|
||
locale: 'en',
|
||
}))
|
||
await flush()
|
||
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', 'Agent Network?'])
|
||
// 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'])
|
||
// Every tool the row can hold pills out in full (jsdom reports no width, so nothing is folded away here); the
|
||
// overflow pill reads zero. The real fit is measured in the browser, where the card has a width.
|
||
assert.deepEqual(pillsOf(nodes[3]), ['read8', 'bash3', 'grep2', 'edit1', 'glob1', '+0 more'])
|
||
assert.deepEqual(rowsOf(nodes[4]), ['Current Agent1.0M/$0.30', 'Subagents × 0?0/—'])
|
||
// The session node carries two figure rows under its label — turns/steps
|
||
// above skill loads/answers (two skills, three answers above).
|
||
assert.equal(nodes[2].querySelector('.lc-flow-label')?.textContent, 'Current Session')
|
||
const selfRows = queryAll(nodes[2], '.lc-flow-self-stats')
|
||
assert.deepEqual(selfRows.map(row => queryAll(row, '.lc-flow-kv b').map(el => el.textContent)), [['3', '4'], ['2', '3']])
|
||
assert.deepEqual(selfRows.map(row => queryAll(row, '.lc-flow-kv i').map(el => el.textContent)), [['Turns', 'Steps'], ['Skill Loads', 'Answers']])
|
||
// 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, 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)
|
||
await m.unmount()
|
||
})
|
||
|
||
test('absent counters and cost degrade to zeros, dimmed pills, and the dash', async () => {
|
||
const m = await mount(h(StatsContext, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
locale: 'en',
|
||
}))
|
||
await flush()
|
||
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]), ['+0 more'], 'the overflow chip always renders, so the reserved row reads as a figure')
|
||
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()
|
||
})
|
||
|
||
test('the tools total falls back to the tally sum on hosts too old to carry it', async () => {
|
||
const m = await mount(h(StatsContext, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [['read', 8], ['bash', 3]],
|
||
locale: 'en',
|
||
}))
|
||
await flush()
|
||
assert.equal(headsOf(m.container).totals[2], '11')
|
||
const nodes = flowNodes(m.container)
|
||
assert.deepEqual(pillsOf(nodes[3]), ['read8', 'bash3', '+0 more'], 'two tools pill out, the overflow chip reads zero')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('the cost bubble lists the billed models with their billed rates', async () => {
|
||
const m = await mount(h(StatsContext, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
cost: COST,
|
||
locale: 'en',
|
||
}))
|
||
await flush()
|
||
const tips = queryAll(m.container, '.lc-stat-tip').map(el => text(el))
|
||
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('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).
|
||
assert.ok(costTip.includes('Cache input $0.00'))
|
||
assert.ok(costTip.includes('Uncached input $0.15'))
|
||
assert.ok(costTip.includes('Cache write $0.15'))
|
||
assert.ok(costTip.includes('Output $0.60'))
|
||
assert.ok(costTip.includes('peak windows'), 'a DeepSeek session explains the peak/off-peak scheme')
|
||
assert.ok(costTip.includes('off-peak rates'), 'the tip says the listed rates are the off-peak tier')
|
||
assert.ok(costTip.includes('twice the listed rate'), 'the tip says peak bills at twice the listed rate')
|
||
assert.ok(!costTip.includes('|'), 'no peak|off pairs — the footnotes carry the scheme')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a non-DeepSeek session never sees DeepSeek-specific notes', async () => {
|
||
const m = await mount(h(StatsContext, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
cost: { 'zai-coding-cn': { 'glm-5.3-flash': { peak: { uncached: 1_000_000, cacheRead: 0, cacheWrite: 0, output: 0 } } } },
|
||
locale: 'en',
|
||
}))
|
||
await flush()
|
||
const costTip = text(queryAll(m.container, '.lc-stat-tip')[1])
|
||
assert.ok(costTip.includes('Priced as listed on models.dev for zhipuai · glm-5.3-flash.'))
|
||
assert.ok(!costTip.includes('DeepSeek'), 'the DeepSeek scheme note stays out of other providers’ bubbles')
|
||
// glm-5.3-flash lists cache_write at 0 — the zero band drops from the table.
|
||
assert.ok(costTip.includes('Uncached input $0.07'))
|
||
assert.ok(!costTip.includes('Cache write'))
|
||
await m.unmount()
|
||
})
|
||
|
||
test('the cost header links to the models.dev provider listing when one provider priced the scope', async () => {
|
||
const m = await mount(h(StatsContext, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
cost: COST,
|
||
locale: 'en',
|
||
}))
|
||
await flush()
|
||
const links = queryAll(m.container, 'a.lc-flow-head')
|
||
assert.equal(links.length, 1, 'the cost header is the only link')
|
||
assert.equal(links[0].getAttribute('href'), 'https://models.dev/providers/deepseek/')
|
||
assert.equal(links[0].getAttribute('target'), '_blank')
|
||
assert.ok(links[0].textContent.includes('$0.30'), 'the link keeps the header body')
|
||
assert.ok(queryAll(m.container, '.lc-flow-head').length > links.length, 'the sibling headers stay plain')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a multi-provider scope keeps the cost header unlinked', async () => {
|
||
const two: SessionCostUsage = {
|
||
'deepseek-official': { 'deepseek-v4-flash': { peak: { uncached: 1_000_000, cacheRead: 0, cacheWrite: 0, output: 0 } } },
|
||
'zai-coding-cn': { 'glm-5.3-flash': { peak: { uncached: 1_000_000, cacheRead: 0, cacheWrite: 0, output: 0 } } },
|
||
}
|
||
const m = await mount(h(StatsContext, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
cost: two,
|
||
locale: 'en',
|
||
}))
|
||
await flush()
|
||
assert.equal(queryAll(m.container, 'a.lc-flow-head').length, 0, 'two providers name their faces in the tooltip instead')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a multi-provider session names the resolved registry face on each listing line', async () => {
|
||
const two: SessionCostUsage = {
|
||
'deepseek-official': { 'deepseek-v4-flash': { peak: { uncached: 1_000_000, cacheRead: 0, cacheWrite: 0, output: 0 } } },
|
||
'zai-coding-cn': { 'glm-5.3-flash': { peak: { uncached: 2_000_000, cacheRead: 0, cacheWrite: 0, output: 0 } } },
|
||
}
|
||
const m = await mount(h(StatsContext, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
cost: two,
|
||
locale: 'en',
|
||
}))
|
||
await flush()
|
||
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; the tokens total too.
|
||
assert.ok(headsOf(m.container).totals[3] === '3.0M/$0.45')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('the zh locale localizes the cards and prices the cost in CNY at 1 CNY = 0.15 USD', async () => {
|
||
const m = await mount(h(StatsContextZh, {
|
||
counts: { turns: 1, steps: 1, injects: 0, compactions: 1, prunes: 0, skills: 5 },
|
||
answers: 4,
|
||
files: { reads: 2, writes: 1, searches: 0, images: 0 },
|
||
tools: [['read', 2]],
|
||
cost: COST,
|
||
locale: 'zh',
|
||
}))
|
||
await flush()
|
||
assert.ok(text(m.container).includes('上下文统计'))
|
||
const nodes = flowNodes(m.container)
|
||
const { labels, totals } = headsOf(m.container)
|
||
assert.deepEqual(labels, ['输入输出', '上下文事件', '工具调用', 'Agent 网络?'])
|
||
// $0.30 / 0.15 = ¥2; the rates convert through the same fixed rate.
|
||
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', '其他 +0'])
|
||
assert.deepEqual(rowsOf(nodes[4]), ['当前 Agent1.0M/¥2.00', '子 Agent × 0?0/—'])
|
||
assert.equal(nodes[2].querySelector('.lc-flow-label')?.textContent, '当前会话')
|
||
const selfRowsZh = queryAll(nodes[2], '.lc-flow-self-stats')
|
||
assert.deepEqual(selfRowsZh.map(row => queryAll(row, '.lc-flow-kv i').map(el => el.textContent)), [['轮次', '步数'], ['技能加载', '模型回复']])
|
||
assert.deepEqual(selfRowsZh.map(row => queryAll(row, '.lc-flow-kv b').map(el => el.textContent)), [['1', '1'], ['5', '4']])
|
||
const costTip = text(queryAll(m.container, '.lc-stat-tip')[1])
|
||
assert.ok(costTip.includes('含当前智能体及所有层级的子智能体'), 'the cost tip names the family scope too — all levels')
|
||
assert.ok(costTip.includes('每百万 Token 价格'))
|
||
assert.ok(costTip.includes('挂牌价为谷时价'), 'the zh tip says the listed rates are off-peak')
|
||
assert.ok(costTip.includes('2 倍计费'), 'the zh tip says peak bills at twice the listed rate')
|
||
assert.ok(costTip.includes('按 deepseek · deepseek-v4-flash 在 models.dev 的刊登价格如上。'))
|
||
assert.ok(costTip.includes('人民币按 1 元 = 0.15 美元换算。'), 'the CNY display carries the conversion footnote')
|
||
assert.ok(costTip.includes('缓存输入 ¥0.02'))
|
||
assert.ok(costTip.includes('未缓存输入 ¥1.00'))
|
||
assert.ok(costTip.includes('缓存写入 ¥1.00'))
|
||
assert.ok(costTip.includes('输出 ¥4.00'))
|
||
await m.unmount()
|
||
})
|
||
|
||
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,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
cost: COST,
|
||
locale: 'en',
|
||
}))
|
||
await flush()
|
||
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()
|
||
})
|
||
|
||
test('a failed price fetch dashes the figure and notes the outage in the tip', async () => {
|
||
setModelPricesLoader(() => Promise.reject(new Error('down')))
|
||
const m = await mount(h(StatsContext, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
cost: COST,
|
||
locale: 'en',
|
||
}))
|
||
await flush()
|
||
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'))
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a DeepSeek off bucket prices at book and the footnotes carry the peak scheme', async () => {
|
||
const split: SessionCostUsage = {
|
||
'deepseek-official': {
|
||
'deepseek-v4-flash': {
|
||
peak: { uncached: 1_000_000, cacheRead: 0, cacheWrite: 0, output: 0 },
|
||
off: { uncached: 2_000_000, cacheRead: 0, cacheWrite: 0, output: 0 },
|
||
},
|
||
},
|
||
}
|
||
const m = await mount(h(StatsContext, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
cost: split,
|
||
locale: 'en',
|
||
}))
|
||
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] === '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'))
|
||
assert.ok(costTip.includes('peak windows'), 'the peak-window footnote explains the bucket split')
|
||
assert.ok(!costTip.includes('|'))
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a session whose models the book cannot price notes the outage too', async () => {
|
||
const m = await mount(h(StatsContext, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
cost: { 'future-provider': { 'mystery-model': { peak: { uncached: 1, cacheRead: 0, cacheWrite: 0, output: 0 } } } },
|
||
locale: 'en',
|
||
}))
|
||
await flush()
|
||
assert.ok(headsOf(m.container).totals[3] === '1/—')
|
||
assert.ok(text(queryAll(m.container, '.lc-stat-tip')[1]).includes('unavailable'))
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a hostile cost branch is skipped by the tooltip rows, not fatal', async () => {
|
||
const hostile = {
|
||
junk: 5,
|
||
'zai-coding-cn': {
|
||
'glm-5.3-flash': {
|
||
peak: { uncached: 1_000_000, cacheRead: 0, cacheWrite: 0, output: 0 },
|
||
off: { uncached: 1_000_000, cacheRead: 0, cacheWrite: 0, output: 0 },
|
||
},
|
||
},
|
||
} as unknown as SessionCostUsage
|
||
const m = await mount(h(StatsContext, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
cost: hostile,
|
||
locale: 'en',
|
||
}))
|
||
await flush()
|
||
const costTip = text(queryAll(m.container, '.lc-stat-tip')[1])
|
||
assert.ok(costTip.includes('glm-5.3-flash'))
|
||
assert.ok(!costTip.includes('junk'))
|
||
// No row may show the peak|off-peak pair — that is DeepSeek's alone.
|
||
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 (only DeepSeek's peak doubles): 2M × $0.075.
|
||
assert.ok(headsOf(m.container).totals[3] === '2.0M/$0.15')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('the team card hands its jump to the caller; the price link stays out of it', async () => {
|
||
// The card this cell tallies lives on another view, and only the caller knows where it serves it.
|
||
let opened = 0
|
||
const m = await mount(h(StatsContext, {
|
||
counts: NO_COUNTS, files: NO_FILES, tools: [], cost: COST, locale: 'en',
|
||
onTeamClick: () => { opened += 1 },
|
||
}))
|
||
await flush()
|
||
const team = query(m.container, '.lc-flow-team')
|
||
assert.equal(team.getAttribute('role'), 'button')
|
||
await click(team)
|
||
assert.equal(opened, 1)
|
||
// The price link inside the same cell is a navigation, never a view switch.
|
||
await click(query(m.container, 'a.lc-flow-head'))
|
||
assert.equal(opened, 1)
|
||
// Keyboard parity: Enter/Space open, anything else stays quiet.
|
||
team.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
|
||
assert.equal(opened, 2)
|
||
team.dispatchEvent(new KeyboardEvent('keydown', { key: 'x', bubbles: true }))
|
||
assert.equal(opened, 2)
|
||
await m.unmount()
|
||
})
|
||
|
||
test('without a caller the cell is a plain figure, never a dead button', async () => {
|
||
const m = await mount(h(StatsContext, { counts: NO_COUNTS, files: NO_FILES, tools: [], cost: COST, locale: 'en' }))
|
||
const team = query(m.container, '.lc-flow-team')
|
||
assert.equal(team.getAttribute('role'), null)
|
||
assert.equal(team.getAttribute('tabindex'), null)
|
||
await click(team)
|
||
team.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
|
||
await m.unmount()
|
||
})
|
||
|
||
test('the second-row figures fire their jump on click and keyboard', async () => {
|
||
const calls: string[] = []
|
||
const m = await mount(h(StatsContext, {
|
||
counts: NO_COUNTS,
|
||
answers: 1,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
locale: 'en',
|
||
onJump: (j) => { calls.push(j) },
|
||
}))
|
||
await flush()
|
||
const cells = queryAll(m.container, '.lc-flow-kv-btn')
|
||
assert.equal(cells.length, 2)
|
||
assert.equal(cells[0].getAttribute('role'), 'button')
|
||
assert.equal(cells[0].getAttribute('title'), DICT_EN['stats.skillsJump'])
|
||
await click(cells[0])
|
||
assert.deepEqual(calls, ['skills'])
|
||
cells[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
|
||
assert.deepEqual(calls, ['skills', 'answers'])
|
||
cells[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'x', bubbles: true, cancelable: true }))
|
||
assert.deepEqual(calls, ['skills', 'answers'])
|
||
await m.unmount()
|
||
})
|
||
|
||
test('the input and file pills fire their jumps on click and keyboard', async () => {
|
||
const calls: string[] = []
|
||
const m = await mount(h(StatsContext, {
|
||
counts: NO_COUNTS,
|
||
humanInputs: 2,
|
||
files: { reads: 18, writes: 13, searches: 4, images: 9 },
|
||
tools: [],
|
||
locale: 'en',
|
||
onJump: (j) => { calls.push(j) },
|
||
}))
|
||
await flush()
|
||
// The inputs node's pills in render order, each a jump button carrying its own action tip.
|
||
const pills = queryAll(flowNodes(m.container)[0], '.lc-flow-pill')
|
||
// The ? bubble is the inputs pill's own hover hint, so only the file pills carry the native action title.
|
||
assert.deepEqual(pills.map(el => el.getAttribute('title')),
|
||
[null, DICT_EN['stats.filesJump'], DICT_EN['stats.filesJump'], DICT_EN['stats.filesJump'], DICT_EN['stats.filesJump']])
|
||
assert.ok(text(pills[0]).includes(DICT_EN['stats.humanInputsTip']), 'the metric bubble stays')
|
||
assert.ok(pills.every(el => el.getAttribute('role') === 'button'), 'every pill jumps')
|
||
await click(pills[0])
|
||
await click(pills[1])
|
||
assert.deepEqual(calls, ['inputs', 'read'])
|
||
pills[3].dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }))
|
||
assert.deepEqual(calls, ['inputs', 'read', 'search'])
|
||
pills[4].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
|
||
assert.deepEqual(calls, ['inputs', 'read', 'search', 'image'])
|
||
pills[4].dispatchEvent(new KeyboardEvent('keydown', { key: 'x', bubbles: true, cancelable: true }))
|
||
assert.deepEqual(calls, ['inputs', 'read', 'search', 'image'])
|
||
await m.unmount()
|
||
})
|
||
|
||
test('without the relay the figure cells and pills stay inert', async () => {
|
||
const m = await mount(h(StatsContext, { counts: NO_COUNTS, answers: 1, humanInputs: 1, files: NO_FILES, tools: [], locale: 'en' }))
|
||
await flush()
|
||
const cells = queryAll(m.container, '.lc-flow-kv')
|
||
assert.ok(cells.every(c => c.getAttribute('role') === null && !c.className.includes('lc-flow-kv-btn')))
|
||
const pills = queryAll(flowNodes(m.container)[0], '.lc-flow-pill')
|
||
assert.ok(pills.every(c => c.getAttribute('role') === null && !c.className.includes('lc-flow-pill-btn')))
|
||
await m.unmount()
|
||
})
|
||
})
|
||
|
||
describe('StatsContext — the subagent split (injected seat)', () => {
|
||
const SUB: SessionCostUsage = {
|
||
'zai-coding-cn': { 'glm-5.3-flash': { peak: { uncached: 2_000_000, cacheRead: 0, cacheWrite: 0, output: 0 } } },
|
||
}
|
||
|
||
test('the family figure merges the subagent usage in; the split pills price each share alone', async () => {
|
||
const Stats = makeStatsContext(kit, () => ({ usage: SUB, count: 2 }))
|
||
const m = await mount(h(Stats, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
cost: COST,
|
||
locale: 'en',
|
||
}))
|
||
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], '3.0M/$0.45')
|
||
assert.deepEqual(rowsOf(flowNodes(m.container)[4]), ['Current Agent1.0M/$0.30', 'Subagents × 2?2.0M/$0.15'])
|
||
assert.deepEqual(queryAll(flowNodes(m.container)[4], '.lc-flow-row-label').map(el => el.textContent), ['Current Agent', 'Subagents × 2?'])
|
||
const tips = queryAll(m.container, '.lc-stat-tip').map(el => text(el))
|
||
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 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()
|
||
})
|
||
|
||
test('subagent usage alone (no own cost) still prices the family and dashes the own pill', async () => {
|
||
const Stats = makeStatsContext(kit, () => ({ usage: SUB, count: 1 }))
|
||
const m = await mount(h(Stats, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
locale: 'en',
|
||
}))
|
||
await flush()
|
||
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()
|
||
})
|
||
|
||
test('a DeepSeek-billing subagent surfaces the peak/off-peak note under both tips', async () => {
|
||
const deepseekSub: SessionCostUsage = {
|
||
'deepseek-official': { 'deepseek-v4-flash': { off: { uncached: 1_000_000, cacheRead: 0, cacheWrite: 0, output: 0 } } },
|
||
}
|
||
const Stats = makeStatsContext(kit, () => ({ usage: deepseekSub, count: 1 }))
|
||
const m = await mount(h(Stats, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
locale: 'en',
|
||
}))
|
||
await flush()
|
||
// The off bucket bills at book: 1M × $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')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a sub usage the book cannot price dashes the sub pill and notes the outage in its tip', async () => {
|
||
const Stats = makeStatsContext(kit, () => ({ usage: { future: { 'mystery-model': { peak: { uncached: 1, cacheRead: 0, cacheWrite: 0, output: 0 } } } }, count: 1 }))
|
||
const m = await mount(h(Stats, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
cost: COST,
|
||
locale: 'en',
|
||
}))
|
||
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 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 the ledger zeroed and no outage notes', async () => {
|
||
const m = await mount(h(StatsContext, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
locale: 'en',
|
||
}))
|
||
await flush()
|
||
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()
|
||
})
|
||
})
|
||
|
||
describe('StatsContext — the real subagent-cost seat (makeSubagentCost)', () => {
|
||
/** A well-formed slim head with (or without) a cost usage. */
|
||
function head(cost?: SessionCostUsage): ContextTimeline {
|
||
return {
|
||
ok: true,
|
||
contextWindow: 1000,
|
||
current: { system: 10, tools: 10, user: 80, inject: 0, skill: 0, assistant: 0, tool: 0, total: 100 },
|
||
requests: [],
|
||
events: [],
|
||
nodes: [],
|
||
droppedNodes: 0,
|
||
archive: [],
|
||
...(cost !== undefined ? { cost } : {}),
|
||
}
|
||
}
|
||
|
||
/** A sessions face double with the list-feed contract (agentGraph.spec's shape). */
|
||
class FakeSessions {
|
||
private listeners = new Set<() => void>()
|
||
state: unknown
|
||
constructor(byId: Record<string, unknown>) {
|
||
this.state = { byId }
|
||
}
|
||
readonly list = {
|
||
getSnapshot: (): unknown => this.state,
|
||
subscribe: (fn: () => void): (() => void) => {
|
||
this.listeners.add(fn)
|
||
return () => { this.listeners.delete(fn) }
|
||
},
|
||
}
|
||
setState(byId: Record<string, unknown>): void {
|
||
this.state = { byId }
|
||
for (const fn of this.listeners) fn()
|
||
}
|
||
}
|
||
|
||
function makeSeat(byId: Record<string, unknown>): { seat: (sessionId: string | undefined) => SubagentStats; face: FakeSessions } {
|
||
const face = new FakeSessions(byId)
|
||
const ctx = new TestClientCtx({ services: { sessions: face } })
|
||
return { seat: makeSubagentCost(asClientCtx(ctx), makeAgentHeads()), face }
|
||
}
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals()
|
||
})
|
||
|
||
test('warm rows fold live; cold rows fetch once and land; snapshot ticks never refetch', async () => {
|
||
const calls: string[] = []
|
||
let release: ((value: unknown) => void) | undefined
|
||
vi.stubGlobal('fetch', async (_url: unknown, init: { body: string }) => {
|
||
calls.push(String((JSON.parse(String(init?.body)) as { sessionId?: unknown }).sessionId))
|
||
// Hold the read until the warm-only paint is asserted.
|
||
return await new Promise((resolve) => { release = resolve })
|
||
})
|
||
const SUB_WARM: SessionCostUsage = {
|
||
'zai-coding-cn': { 'glm-5.3-flash': { peak: { uncached: 2_000_000, cacheRead: 0, cacheWrite: 0, output: 0 } } },
|
||
}
|
||
const { seat, face } = makeSeat({
|
||
root: { running: false, updatedAt: 1 },
|
||
warm: { parentId: 'root', updatedAt: 2, projectionValues: { contextTimeline: head(SUB_WARM) } },
|
||
cold: { parentId: 'root', updatedAt: 3 },
|
||
})
|
||
const Stats = makeStatsContext(kit, seat)
|
||
const m = await mount(h(Stats, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
locale: 'en',
|
||
sessionId: 'root',
|
||
}))
|
||
await flush()
|
||
// The warm row's usage prices while the cold read is still in flight.
|
||
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 in the ledger's subtree row, cold included.
|
||
assert.deepEqual(queryAll(flowNodes(m.container)[4], '.lc-flow-row-label').map(el => el.textContent), ['Current Agent', 'Subagents × 2?'])
|
||
// A snapshot tick while the read is in flight re-attaches the SAME
|
||
// pending read; when it lands, the duplicate settle bails on identity.
|
||
await act(async () => {
|
||
face.setState({
|
||
root: { running: false, updatedAt: 1 },
|
||
warm: { parentId: 'root', updatedAt: 2, projectionValues: { contextTimeline: head(SUB_WARM) } },
|
||
cold: { parentId: 'root', updatedAt: 3 },
|
||
})
|
||
})
|
||
await flush()
|
||
// The cold read lands: its $0.15 merges into the family and the sub share.
|
||
await act(async () => {
|
||
release?.({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
ok: true,
|
||
value: {
|
||
rev: 1,
|
||
head: head({ 'zai-coding-cn': { 'glm-5.3-flash': { peak: { uncached: 2_000_000, cacheRead: 0, cacheWrite: 0, output: 0 } } } }),
|
||
requests: [],
|
||
events: [],
|
||
nodes: [],
|
||
droppedNodes: 0,
|
||
archive: [],
|
||
},
|
||
}),
|
||
})
|
||
})
|
||
await flush()
|
||
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()
|
||
})
|
||
|
||
test('a failing cold fetch degrades the card without crashing or retrying', async () => { const calls: string[] = []
|
||
vi.stubGlobal('fetch', async (_url: unknown, init: { body: string }) => {
|
||
calls.push(String((JSON.parse(String(init?.body)) as { sessionId?: unknown }).sessionId))
|
||
throw new Error('transport down')
|
||
})
|
||
const { seat, face } = makeSeat({
|
||
root: { running: false, updatedAt: 1 },
|
||
cold: { parentId: 'root', updatedAt: 2 },
|
||
})
|
||
const Stats = makeStatsContext(kit, seat)
|
||
const m = await mount(h(Stats, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
locale: 'en',
|
||
sessionId: 'root',
|
||
}))
|
||
await flush()
|
||
assert.deepEqual(calls, ['cold'])
|
||
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 in the ledger's subtree row — a subagent is one whether or not its usage priced.
|
||
assert.deepEqual(queryAll(flowNodes(m.container)[4], '.lc-flow-row-label').map(el => el.textContent), ['Current Agent', 'Subagents × 1?'])
|
||
// A later snapshot tick re-folds the subtree; the sticky failure never re-fetches.
|
||
await act(async () => {
|
||
face.setState({ root: { running: false, updatedAt: 1 }, cold: { parentId: 'root', updatedAt: 2 } })
|
||
})
|
||
await flush()
|
||
assert.deepEqual(calls, ['cold'])
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a headless detail answer (or a null value) lands nothing and never retries', async () => {
|
||
const calls: string[] = []
|
||
vi.stubGlobal('fetch', async (_url: unknown, init: { body: string }) => {
|
||
calls.push(String((JSON.parse(String(init?.body)) as { sessionId?: unknown }).sessionId))
|
||
// The head is optional on the payload; a null value answers absence.
|
||
const headless = { rev: 1, requests: [], events: [], nodes: [], droppedNodes: 0, archive: [] }
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ ok: true, value: calls.length === 1 ? headless : null }),
|
||
}
|
||
})
|
||
const { seat, face } = makeSeat({
|
||
root: { running: false, updatedAt: 1 },
|
||
headless: { parentId: 'root', updatedAt: 2 },
|
||
absent: { parentId: 'root', updatedAt: 3 },
|
||
})
|
||
const Stats = makeStatsContext(kit, seat)
|
||
const m = await mount(h(Stats, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
locale: 'en',
|
||
sessionId: 'root',
|
||
}))
|
||
await flush()
|
||
assert.deepEqual(calls, ['headless', 'absent'])
|
||
// 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({
|
||
root: { running: false, updatedAt: 1 },
|
||
headless: { parentId: 'root', updatedAt: 2 },
|
||
absent: { parentId: 'root', updatedAt: 3 },
|
||
})
|
||
})
|
||
await flush()
|
||
assert.deepEqual(calls, ['headless', 'absent'])
|
||
await m.unmount()
|
||
})
|
||
|
||
test('without the sessions face the seat prices nothing', async () => {
|
||
const ctx = new TestClientCtx()
|
||
const Stats = makeStatsContext(kit, makeSubagentCost(asClientCtx(ctx), makeAgentHeads()))
|
||
const m = await mount(h(Stats, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
locale: 'en',
|
||
sessionId: 'root',
|
||
}))
|
||
await flush()
|
||
assert.equal(headsOf(m.container).totals[3], '0/—')
|
||
assert.deepEqual(rowsOf(flowNodes(m.container)[4]), ['Current Agent0/—', 'Subagents × 0?0/—'])
|
||
await m.unmount()
|
||
})
|
||
|
||
test('an absent session id folds nothing (and fetches nothing)', async () => {
|
||
const calls: string[] = []
|
||
vi.stubGlobal('fetch', async (_url: unknown, init: { body: string }) => {
|
||
calls.push(String((JSON.parse(String(init?.body)) as { sessionId?: unknown }).sessionId))
|
||
throw new Error('must not fetch')
|
||
})
|
||
const face = new FakeSessions({ root: { running: false, updatedAt: 1 }, kid: { parentId: 'root', updatedAt: 2 } })
|
||
const ctx = new TestClientCtx({ services: { sessions: face } })
|
||
const Stats = makeStatsContext(kit, makeSubagentCost(asClientCtx(ctx), makeAgentHeads()))
|
||
const m = await mount(h(Stats, {
|
||
counts: NO_COUNTS,
|
||
files: NO_FILES,
|
||
tools: [],
|
||
locale: 'en',
|
||
}))
|
||
await flush()
|
||
assert.deepEqual(calls, [])
|
||
assert.equal(headsOf(m.container).totals[3], '0/—')
|
||
assert.deepEqual(rowsOf(flowNodes(m.container)[4]), ['Current Agent0/—', 'Subagents × 0?0/—'])
|
||
await m.unmount()
|
||
})
|
||
})
|