From c4ad2e8a9ce770bbc938cbc5735f2f2275c3a936 Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Wed, 7 Oct 2026 04:22:45 +0800 Subject: [PATCH] feat(context): rebuild the Context Stats card as a session flow diagram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six-cell grid becomes a left-to-right flow: two source cards (Input / Output — human inputs plus the whole session's file reads/writes, with searches and image reads when they happen; Context Events broken down by kind) feed the highlighted Current Session node (turns / steps / subagents — the subtree's session count, folded from the session list alongside the cost usage), which drains into the two effect cards (Tool Calls with the three most-called tools and an overflow pill; Cost with the family figure and, when a subagent subtree reports, the this-agent/subagents split). Measured bezier ribbons — a translucent band plus a traveling dash per element, not a conservative Sankey — connect the nodes and flip to a top-to-bottom stack when the card narrows past the container-query fold. --- src/client/agentTree.ts | 9 +- src/client/components/contextView.tsx | 17 +- src/client/components/statsContext.tsx | 373 +++++++++++++++--- src/client/i18n.ts | 16 +- src/client/styles/base.css | 5 +- src/client/styles/stats.css | 70 +++- tests/client/agentTree.spec.ts | 15 +- tests/client/components/contextView.spec.ts | 4 +- tests/client/components/statsContext.spec.ts | 378 ++++++++++++++----- 9 files changed, 710 insertions(+), 177 deletions(-) diff --git a/src/client/agentTree.ts b/src/client/agentTree.ts index fd5b76e..8c90b1b 100644 --- a/src/client/agentTree.ts +++ b/src/client/agentTree.ts @@ -342,6 +342,8 @@ export interface SubagentCostFold { usage: SessionCostUsage | null /** Descendants with no timeline row and no landed head — the slim-head fetch targets. */ cold: string[] + /** Descendant sessions discovered in the list snapshot (blank placeholders are not agents). */ + count: number } /** @@ -351,7 +353,8 @@ export interface SubagentCostFold { * standing in for rows that carry no timeline value. Blank placeholder rows * are not agents (the family forest's rule); a lineage cycle cannot loop the * walk (seen-set). The current session itself is never counted — the caller - * already holds its own usage. + * already holds its own usage. `count` is the discovered descendant tally + * (cold ones included — a subagent is one whether or not its usage priced). */ export function subagentCostFoldOf( snapshot: unknown, @@ -359,7 +362,7 @@ export function subagentCostFoldOf( landed?: ReadonlyMap, ): SubagentCostFold { const byId = asRecord(asRecord(snapshot)?.byId) - if (byId === null || currentId === undefined || currentId === '') return { usage: null, cold: [] } + if (byId === null || currentId === undefined || currentId === '') return { usage: null, cold: [], count: 0 } const childrenOf = new Map() for (const key of Object.keys(byId)) { const row = agentRowOf(byId[key]) @@ -392,7 +395,7 @@ export function subagentCostFoldOf( queue.push(kid) } } - return { usage: parts.length > 0 ? mergeCostUsage(...parts) : null, cold } + return { usage: parts.length > 0 ? mergeCostUsage(...parts) : null, cold, count: seen.size - 1 } } export interface AgentPoint { diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx index 7398ad5..feb48f3 100644 --- a/src/client/components/contextView.tsx +++ b/src/client/components/contextView.tsx @@ -30,7 +30,7 @@ import { makeFileCard } from './fileCard' import { makePluginInfo } from './pluginInfo' import { makeUpgradeGate } from './upgradeGate' import { makeRequestDetail } from './requestDetail' -import { countsOfRecords, makeStatsContext, makeSubagentCost } from './statsContext' +import { countsOfRecords, makeStatsContext, makeSubagentCost, toolTallyOf } from './statsContext' import { makeStatsTiming } from './statsTiming' import { makeStatsTokens } from './statsTokens' import { makeLegend, makeStackedBar } from './stackedBar' @@ -338,6 +338,20 @@ export function makeContextView( }, [data, briefList, convOf, filesBefore], ) + // The stats card's pill tallies: the WHOLE-session file-op counts (null + // bound, off the same derivations the FileCard rides) for the I/O card, + // and the live-surface per-tool tally (toolTallyOf — the head's own + // `toolCalls` predicate) for the tool card's most-called pills. + const ioTotals = useMemo( + () => { + const totals = (data !== null && data.fileOps !== undefined + ? activityOfOps(data.fileOps, data.archive, null) + : activityOf(briefList, convOf, null)).totals + return { reads: totals.read.ops, writes: totals.write.ops, searches: totals.search.ops, images: totals.image.ops } + }, + [data, briefList, convOf], + ) + const toolTally = useMemo(() => (data ? toolTallyOf(data.nodes) : []), [data]) // The session's workspace root — './'-relative row paths when known; read per // render (an observable snapshot), so the next projection push re-renders with it. const workspace = typeof ctx.get === 'function' ? workspaceOf(ctx, typeof sessionId === 'string' ? sessionId : undefined) : undefined @@ -592,6 +606,7 @@ export function makeContextView( {inSidebar ? null : (
diff --git a/src/client/components/statsContext.tsx b/src/client/components/statsContext.tsx index 223ed96..6a207b0 100644 --- a/src/client/components/statsContext.tsx +++ b/src/client/components/statsContext.tsx @@ -1,22 +1,28 @@ /** - * The Context card: what the session's context IS and how it evolved — a - * six-cell grid of the session's shape (turns / steps / human inputs / - * live tool calls) and the cost estimate at two scopes: the family total - * (the current agent plus every subagent session) and the subagents' own - * share. + * 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'). + * 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 + * stats.css folds the three zones (the component flips the geometry at the + * same width). The subagent share folds out of the session-list snapshot + * (`makeSubagentCost` below); a harness without the outward sessions service + * renders the family figure alone. + * * Count figures only: nothing here is part of a spendable whole, so no pie — - * proportions live in the composition card, the context-event tallies live - * on the events card's kind filters (contextView.tsx), and the token figures - * (the session's cache-hit share among them) live on the Token card. The - * cost cells price the host-folded cumulative billed totals (complete - * session logs, never - * trimmed; the subagents' usage folds out of the session-list snapshot, - * `makeSubagentCost` below) from the models.dev price book (modelPrices.ts) - * in the locale's currency; their hover bubbles (a '?' marker + styled DOM - * tip) explain each scope and list the per-1M-token rates of the models the - * family actually billed, straight from the same book (cost.ts), so printed - * rates can never drift from the math. A book that has not loaded (or - * failed) dashes the cells and notes the outage. + * proportions live in the composition card, the event rows themselves live + * on the events card (contextView.tsx), and the token figures (the session's + * cache-hit share among them) live on the Token card. The cost node prices + * the host-folded cumulative billed totals (complete session logs, never + * trimmed) from the models.dev price book (modelPrices.ts) in the locale's + * currency; its hover bubbles (a '?' marker + styled DOM tip) explain each + * scope and list the per-1M-token rates of the models the family actually + * billed, straight from the same book (cost.ts), so printed rates can never + * drift from the math. A book that has not loaded (or failed) dashes the + * figures and notes the outage. * * The counts arrive precomputed: the split-generation wire head carries them * (shared/types.ts `TimelineCounts` — computed over the retained records), @@ -24,8 +30,8 @@ * (`countsOfRecords`). The card itself never touches the collections. */ -import { useEffect, useMemo, useState, type ReactElement, type ReactNode } from 'react' -import type { ContextEventRecord, ContextTimeline, RequestRecord, SessionCostUsage, TimelineCounts } from '../../shared/types' +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 type { CostCurrency, ModelBook, PriceFace } from '../cost' import { sessionsFaceOf, subagentCostFoldOf } from '../agentTree' @@ -92,17 +98,26 @@ export function countsOfRecords(requests: readonly RequestRecord[], events: read } /** - * The stats board's subagent-cost seat: the merged billed-token usage of the - * current session's whole subagent subtree, folded from the session-list - * snapshot's warm rows (`subagentCostFoldOf`) with fetched slim heads - * standing in for cold relatives. Null = nothing reported yet (no - * subagents, no usage, or no sessions face on this harness). + * The stats board's subagent-subtree figures: the merged billed-token usage + * (null = nothing reported yet — no subagents, no usage, or no sessions face + * on this harness) and the discovered descendant count (cold ones included — + * a subagent is one whether or not its usage priced). + */ +export interface SubagentStats { + usage: SessionCostUsage | null + count: number +} + +/** + * The stats board's subagent seat: the subtree figures folded from the + * session-list snapshot's warm rows (`subagentCostFoldOf`) with fetched slim + * heads standing in for cold relatives. */ export function makeSubagentCost( ctx: ClientCtx, heads: AgentHeads, -): (sessionId: string | undefined) => SessionCostUsage | null { - return function useSubagentCost(sessionId: string | undefined): SessionCostUsage | null { +): (sessionId: string | undefined) => SubagentStats { + return function useSubagentCost(sessionId: string | undefined): SubagentStats { // Resolved lazily at mount: a deployment without the outward sessions // service simply prices no subagent cost. const face = useMemo(() => sessionsFaceOf(ctx), []) @@ -123,41 +138,146 @@ export function makeSubagentCost( }).catch(() => {}) } }, [fold, heads]) - return fold.usage + return { usage: fold.usage, count: fold.count } } } -export function makeStatsContext( - kit: ViewKit, - useSubagentCost: (sessionId: string | undefined) => SessionCostUsage | null, -): (props: { +/** The five flow nodes' registry keys, in render order. */ +type FlowNodeKey = 'inputs' | 'events' | 'session' | 'tools' | 'cost' + +const FLOW_NODES: readonly FlowNodeKey[] = ['inputs', 'events', 'session', 'tools', 'cost'] + +/** A node's box relative to the flow root, in px. */ +interface FlowBox { x: number; y: number; w: number; h: number } + +/** One link's rendered path and its element color. */ +interface FlowLink { d: string; color: string } + +/** An anchor on a box edge: the point plus the outward normal (the curve's tangent direction). */ +interface Anchor { x: number; y: number; dx: number; dy: number } + +/** + * The narrow-fold width, paired with the `@container lc-card (max-width: …)` + * rule in stats.css: the container's content box IS the flow root's own width + * (it fills the card's content box), so the component's `clientWidth` read and + * the query can never disagree about the mode. + */ +const FLOW_FOLD_PX = 559 + +/** + * The flow topology: the session node center stage, the two source nodes + * feeding it and the two effect nodes draining it, each link colored by its + * element (the token composition palette, categories.ts). The fractional + * attachments fan the pairs apart on the session node (⅓ and ⅔) so the + * ribbons merge without stacking; the side nodes attach at their centers. + * Non-conservative by design — every ribbon is the same width whatever the + * tally behind it. + */ +const FLOW_LINKS: readonly { from: FlowNodeKey; to: FlowNodeKey; fromAt: number; toAt: number; color: string }[] = [ + { from: 'inputs', to: 'session', fromAt: 0.5, toAt: 1 / 3, color: 'var(--color-green-500)' }, + { from: 'events', to: 'session', fromAt: 0.5, toAt: 2 / 3, color: 'var(--color-purple-500)' }, + { from: 'session', to: 'tools', fromAt: 1 / 3, toAt: 0.5, color: 'var(--color-teal-500)' }, + { from: 'session', to: 'cost', fromAt: 2 / 3, toAt: 0.5, color: 'var(--color-pink-500)' }, +] + +/** The events card's kind pills, tinted by its own kind semantics (events.css `.lc-kind-*`). */ +const EVENT_PILLS: readonly { kind: 'inject' | 'compaction' | 'prune'; tally: keyof TimelineCounts; cls: string }[] = [ + { kind: 'inject', tally: 'injects', cls: 'lc-kind-inject' }, + { kind: 'compaction', tally: 'compactions', cls: 'lc-kind-compaction' }, + { kind: 'prune', tally: 'prunes', cls: 'lc-kind-prune' }, +] + +/** + * The per-tool tally behind the tool card's pills, folded over the LIVE + * surface with the host's own predicate (fold.ts `toolCalls`: tool-result + * nodes, plus skill nodes carrying a tool) — the pills' sum matches the + * head's `toolCalls` figure by construction, minus any result the fold + * stamped no name on (such a node counts toward the head's figure but has + * no pill to ride). Sorted by count desc, then name asc for a stable + * display; hostile shapes skip rather than throw. + */ +export function toolTallyOf(nodes: readonly SurfaceNode[]): [string, number][] { + const tally = new Map() + for (const n of nodes) { + if (n.cat !== 'tool' && !(n.cat === 'skill' && n.tool !== undefined)) continue + if (typeof n.tool !== 'string' || n.tool === '') continue + tally.set(n.tool, (tally.get(n.tool) ?? 0) + 1) + } + return [...tally].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)) +} + +/** The stats card's props (the factory's return type and the inner component share them). */ +interface StatsContextProps { /** The session-shape tally (host-precomputed on the split generation). */ counts: TimelineCounts /** The whole-session human-input tally (the user's messages + question answers; absent on older hosts). */ humanInputs?: number /** Tool calls with a result live in the current context (absent on older hosts). */ toolCalls?: number + /** The whole-session file-op tallies (op counts by kind), for the I/O card's pills. */ + files: { reads: number; writes: number; searches: number; images: number } + /** The live-surface per-tool call tally (`toolTallyOf`), count-desc. */ + tools: readonly (readonly [string, number])[] cost?: SessionCostUsage locale: string /** The current session id, anchoring the subagent-cost fold (absent = nothing to fold). */ sessionId?: string -}) => ReactElement { +} + +/** The anchor at fraction `at` along one box edge, with the edge's outward normal. */ +export function flowAnchor(box: FlowBox, side: 'left' | 'right' | 'top' | 'bottom', at: number): Anchor { + switch (side) { + case 'left': return { x: box.x, y: box.y + box.h * at, dx: -1, dy: 0 } + case 'right': return { x: box.x + box.w, y: box.y + box.h * at, dx: 1, dy: 0 } + case 'top': return { x: box.x + box.w * at, y: box.y, dx: 0, dy: -1 } + case 'bottom': return { x: box.x + box.w * at, y: box.y + box.h, dx: 0, dy: 1 } + } +} + +/** + * The Sankey-flavored connector between two anchors: a cubic whose tangents + * leave along the source anchor's outward normal and enter AGAINST the + * target's (the end control point sits at `b + b.normal·k`, so the path + * arrives moving into the node), the control distance half the axis gap + * (floored so near neighbors still bow visibly). + */ +export function flowCurve(a: Anchor, b: Anchor): string { + const k = Math.max(18, (a.dx !== 0 ? Math.abs(b.x - a.x) : Math.abs(b.y - a.y)) / 2) + return `M ${a.x} ${a.y} C ${a.x + a.dx * k} ${a.y + a.dy * k} ${b.x + b.dx * k} ${b.y + b.dy * k} ${b.x} ${b.y}` +} + +/** + * All four links' paths over the measured node boxes. Horizontal mode flows + * left→right; the narrow fold stacks the three zones and flows top→bottom — + * same DOM, the stats.css container query flips the layout at FLOW_FOLD_PX + * and the caller passes the matching orientation. Sources always attach on + * their trailing edge, targets on their leading one (the session node is a + * target of the left/top pair and the source of the right/bottom pair). + */ +export function measureFlow(boxes: Record, horizontal: boolean): FlowLink[] { + const fromSide = horizontal ? 'right' as const : 'bottom' as const + const toSide = horizontal ? 'left' as const : 'top' as const + return FLOW_LINKS.map(link => ({ + d: flowCurve(flowAnchor(boxes[link.from], fromSide, link.fromAt), flowAnchor(boxes[link.to], toSide, link.toAt)), + color: link.color, + })) +} + +export function makeStatsContext( + kit: ViewKit, + useSubagentCost: (sessionId: string | undefined) => SubagentStats, +): (props: StatsContextProps) => ReactElement { const { t, fmt } = kit - return function StatsContext(props: { - counts: TimelineCounts - humanInputs?: number - toolCalls?: number - cost?: SessionCostUsage - locale: string - sessionId?: string - }): ReactElement { + return function StatsContext(props: StatsContextProps): ReactElement { const currency: CostCurrency = props.locale === 'zh' ? 'cny' : 'usd' const { book, failed } = useModelPrices() - // Both cost cells price the same host-folded cumulative totals, at one + // Both cost figures price the same host-folded cumulative totals, at one // scope each: the family total (the current agent's own usage plus every - // subagent session's) in the cost cell, the subagents' share alone in - // the subagent-cost cell. - const subUsage = useSubagentCost(props.sessionId) + // subagent session's) in the cost card's header, the subagents' share + // alone in the split pill. The subtree's session count rides the session + // node's third figure. + const sub = useSubagentCost(props.sessionId) + const subUsage = sub.usage const usage = mergeCostUsage(props.cost, subUsage) ?? undefined const cost = estimateSessionCost(usage, book, currency) const subCost = estimateSessionCost(subUsage, book, currency) @@ -218,46 +338,175 @@ export function makeStatsContext( ...notes(subDeepseek), subUnpriced ? {t('stats.costUnavailable')} : null, ] - const cell = (label: string, value: string | number, tip?: ReactNode, href?: string): ReactElement => { - // The framed cell body, 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 element). + // The five nodes' measured boxes drive the connector overlay: a no-deps + // layout effect re-measures after every render (a same-string bail-out + // keeps it loopless), and a ResizeObserver tick covers pane drags that + // change no prop. + const flowRef = useRef(null) + const nodeEls = useRef(new Map()) + const [links, setLinks] = useState([]) + const [, setResizeTick] = useState(0) + const nodeRef = (key: FlowNodeKey) => (el: HTMLDivElement | null): void => { + if (el === null) nodeEls.current.delete(key) + else nodeEls.current.set(key, el) + } + useLayoutEffect(() => { + const root = flowRef.current + /* v8 ignore next 1 -- the effect only runs while mounted, and the card always renders the flow root. */ + if (root === null) return + const rootBox = root.getBoundingClientRect() + const boxes = {} as Record + for (const key of FLOW_NODES) { + /* v8 ignore start -- the five nodes render before this layout effect, so every key resolves. */ + const el = nodeEls.current.get(key) + if (el === undefined) return + /* v8 ignore stop */ + const r = el.getBoundingClientRect() + boxes[key] = { x: r.left - rootBox.left, y: r.top - rootBox.top, w: r.width, h: r.height } + } + const next = measureFlow(boxes, root.clientWidth > FLOW_FOLD_PX) + setLinks(prev => (prev.length === next.length && prev.every((l, i) => l.d === next[i].d && l.color === next[i].color) ? prev : next)) + }) + /* v8 ignore start -- jsdom has neither ResizeObserver nor layout: tests pin the geometry through measureFlow's unit tests. */ + useEffect(() => { + const root = flowRef.current + if (root === null || typeof ResizeObserver !== 'function') return + const observer = new ResizeObserver(() => { setResizeTick(tick => tick + 1) }) + observer.observe(root) + return () => { observer.disconnect() } + }, []) + /* v8 ignore stop */ + // One card's header: the title (with the '?' marker when a tip rides) + // 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 body = ( <> - + {label} {tip !== undefined && } - {typeof value === 'number' ? fmt(value) : value} + {total} {tip !== undefined && {tip}} ) - const className = 'lc-stat' + (tip === undefined ? '' : ' lc-stat-tipped group/tip') + const className = 'lc-flow-head' + (tip === undefined ? '' : ' lc-stat-tipped group/tip') return href === undefined ?
{body}
: {body} } - // The cost cell links to the listing when ONE models.dev provider priced + // One pill: the label over the bold tally, tinted when the caller carries + // 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 => ( + + + {label} + {tip !== undefined && } + + {typeof value === 'number' ? fmt(value) : value} + {tip !== undefined && {tip}} + + ) + // The cost card links to the listing when ONE models.dev provider priced // the whole scope — the natural "check these rates" destination. A // multi-provider scope names each face in the tooltip instead and stays // unlinked. 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 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 + // as the fallback on hosts too old to carry it), the three most-called + // tools as pills, and an overflow pill counting the rest. + const toolTotal = props.toolCalls ?? props.tools.reduce((sum, [, n]) => sum + n, 0) + const topTools = props.tools.slice(0, 3) + const moreTools = props.tools.length - topTools.length return (
{t('stats.title')}
- {/* The count grid: auto-fit keeps every cell ≥108px (the floor where the longest - English label still fits), so cells fill the card — 6 across at the card's - three-quarter head-row share, 2 on a phone-width one. */} -
- {cell(t('stats.turns'), props.counts.turns)} - {cell(t('stats.steps'), props.counts.steps)} - {cell(t('stats.humanInputs'), props.humanInputs ?? 0, t('stats.humanInputsTip'))} - {cell(t('stats.toolCalls'), props.toolCalls ?? 0)} - {cell(t('stats.cost'), cost === null ? '—' : formatCost(cost, currency), costTip, costHref)} - {cell(t('stats.subCost'), subCost === null ? '—' : formatCost(subCost, currency), subTip)} +
+ {/* The connector overlay: under the nodes (they carry z-index), in + flow order so the traveling dashes read left→right / top→bottom. */} + +
+
+ {head(t('stats.io'), fmt(ioTotal))} + {/* Searches and image reads pill only when they happened — two + permanently dimmed pills are noise, these three zero honestly. */} +
+ {pill(t('stats.humanInputs'), props.humanInputs ?? 0, '', t('stats.humanInputsTip'))} + {pill(t('files.kind.read'), props.files.reads)} + {pill(t('files.kind.write'), props.files.writes)} + {props.files.searches > 0 ? pill(t('files.kind.search'), props.files.searches) : null} + {props.files.images > 0 ? pill(t('files.kind.image'), props.files.images) : null} +
+
+
+ {head(t('stats.events'), fmt(props.counts.injects + props.counts.compactions + props.counts.prunes))} +
+ {EVENT_PILLS.map(p => pill(t('kind.' + p.kind), props.counts[p.tally], ' lc-flow-pill-tint ' + p.cls))} +
+
+
+
+
+ {t('stats.session')} + + {fmt(props.counts.turns)}{t('stats.turns')} + {fmt(props.counts.steps)}{t('stats.steps')} + {fmt(sub.count)}{t('stats.subagents')} + +
+
+
+
+ {head(t('stats.toolCalls'), fmt(toolTotal))} + {topTools.length > 0 + ? ( +
+ {topTools.map(([name, n]) => ( + + {name} + {fmt(n)} + + ))} + {moreTools > 0 + ? {t('stats.toolsMore', { n: moreTools })} + : null} +
+ ) + : 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} +
+
) diff --git a/src/client/i18n.ts b/src/client/i18n.ts index 6d3c4e7..38f762f 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -22,18 +22,24 @@ export const DICT_ZH: Record = { 'overview.ofUsed': '占已用上下文', 'overview.compactReserve': '自动压缩预留:占用达 {pct}% 窗口时触发压缩,此区域一般不实际占用', 'stats.title': '上下文统计', + 'stats.session': '当前会话', 'stats.turns': '轮次', 'stats.steps': '步数', + 'stats.subagents': '子 Agent', 'stats.humanInputs': '用户输入', 'stats.humanInputsTip': '当前会话中的用户消息与问题回答次数。', + 'stats.io': '输入输出', + 'stats.events': '上下文事件', 'stats.toolCalls': '工具调用', + 'stats.toolsMore': '其他 +{n}', 'stats.cacheHit': '缓存命中', 'stats.cacheHitTip': '整个会话累计的缓存读取占计费输入的比例。', 'stats.cost': '费用', 'stats.costTip': '估算的费用,含当前智能体及所有子智能体,仅供参考。', 'stats.costTipDeepseek': 'DeepSeek 高峰时段(北京时间工作日 9:00–12:00、14:00–18:00)按挂牌价计费,其余时段半价。', 'stats.costTipCny': '人民币按 1 元 = 0.15 美元换算。', - 'stats.subCost': '子 Agent 费用', + 'stats.ownCost': '本 Agent', + 'stats.subCost': '子 Agent', 'stats.subCostTip': '所有子 Agent 会话各自累计费用(同口径估算)的合计,不含当前 Agent 自身。', 'stats.costPriceHead': '每百万 Token 价格:', 'stats.costPriceBy': '按 {p} · {m} 在 models.dev 的刊登价格如上。', @@ -430,18 +436,24 @@ export const DICT_EN: Record = { 'overview.ofUsed': 'of used context', 'overview.compactReserve': 'Auto-compaction reserve: auto-compaction triggers at {pct}% of the window — this area is normally kept as headroom, not actually used', 'stats.title': 'Context Stats', + 'stats.session': 'Current Session', 'stats.turns': 'Turns', 'stats.steps': 'Steps', + 'stats.subagents': 'Subagents', 'stats.humanInputs': 'Human Inputs', 'stats.humanInputsTip': 'User messages and question answerings in current session.', + 'stats.io': 'Input / Output', + 'stats.events': 'Context Events', 'stats.toolCalls': 'Tool Calls', + 'stats.toolsMore': '+{n} more', '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.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.subCost': 'Subagent Cost', + '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.costPriceHead': 'Per-1M-token rates:', 'stats.costPriceBy': 'Priced as listed on models.dev for {p} · {m}.', diff --git a/src/client/styles/base.css b/src/client/styles/base.css index 96e73a8..501b96d 100644 --- a/src/client/styles/base.css +++ b/src/client/styles/base.css @@ -125,10 +125,9 @@ drops the context stats / plugin info row — see contextView.tsx); the same wrap then stacks the pair in the narrow pane. */ -/* The context card's cell grid stretches to the plugin card's height, so the row reads as one level band. */ +/* The context card's flow diagram stretches to the plugin card's height, so the row reads as one level band. */ .lc-head > .lc-card:has(.lc-stats) { display: flex; flex-direction: column; } -.lc-head .lc-stats { flex: 1; align-content: stretch; } -.lc-head .lc-stat { justify-content: center; } +.lc-head .lc-stats { flex: 1; } .lc-empty { color: var(--dsw-alias-label-secondary); padding: 18px 0; text-align: center; } .lc-error { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 40px 16px; } .lc-error-msg { font-family: var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 12px; color: var(--dsw-alias-state-error-primary); background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 4px 8px; max-width: 100%; overflow-wrap: anywhere; } diff --git a/src/client/styles/stats.css b/src/client/styles/stats.css index 926b869..89c5475 100644 --- a/src/client/styles/stats.css +++ b/src/client/styles/stats.css @@ -10,9 +10,6 @@ card it visually is, with the pointer as the only affordance. */ a.lc-stat { color: inherit; text-decoration: none; cursor: pointer; } a.lc-stat:hover { border-color: var(--dsw-alias-label-secondary); } -/* The stats grid shares a half-width head-row card with a sibling, so its cells are moderate: tighter padding plus a corner-pinned '?' (out of the label's line) keep the longest labels on one line. */ -.lc-stats .lc-stat { padding: 6px 3px; } -.lc-stats .lc-stat-q { position: absolute; top: 3px; right: 4px; margin: 0; } .lc-stat-label { color: var(--dsw-alias-label-secondary); font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .lc-stat-value { color: var(--dsw-alias-label-primary); font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: center; } .lc-stat-sub { color: var(--dsw-alias-label-secondary); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } @@ -114,9 +111,70 @@ a.lc-stat:hover { border-color: var(--dsw-alias-label-secondary); } an ellipsis; without it a narrow card pushes the value over the label. */ .lc-pi-value { min-width: 0; color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: right; } -/* The stats grid's cell count is intrinsic (`repeat(auto-fit, minmax(108px,1fr))` on the element, see - statsContext.tsx); the donut row's 320px gap fold and 240px stack still ride - `@max-[…]/lc-card` utilities — statsTokens.tsx / statsTiming.tsx / sliceList.tsx / donut.tsx. */ +/* + ---- Context stats flow (statsContext.tsx): the session as a flow diagram. + The two source nodes (left) feed the session node, which drains into the two + effect nodes (right); measured bezier ribbons connect them — the component + measures the node boxes and flips the geometry at the same width the + container query below folds the three zones into a top→bottom stack. ---- + */ +.lc-flow { position: relative; display: flex; align-items: stretch; } +.lc-flow-svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; } +/* Two layers per link: a wide, near-transparent ribbon carries the Sankey + mass, and a dashed hairline of the same hue travels along it — the agent + network's flow pulse (`animate-lc-agent-flow`; the element sets + `motion-reduce:animate-none`) — so the direction of travel reads without + arrowheads. The link color rides the `stroke` attribute per element. */ +.lc-flow-ribbon { fill: none; stroke-width: 14px; stroke-opacity: 0.13; stroke-linecap: round; } +.lc-flow-dash { fill: none; stroke-width: 1.5px; stroke-opacity: 0.55; stroke-linecap: round; stroke-dasharray: 2 7; } +/* The three zones sit above the connector overlay; the mid zone's side + margins are the horizontal gutters the ribbons travel through. */ +.lc-flow-col { position: relative; z-index: 1; display: flex; flex-direction: column; justify-content: center; gap: 8px; flex: 1 1 0; min-width: 0; } +.lc-flow-mid { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; margin: 0 30px; } +.lc-flow-node { background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; padding: 6px 10px; display: flex; flex-direction: column; gap: 2px; min-width: 0; } +/* A corner card's header: the title left, the bold total right (tabular so a + ticking count never jiggles the row). */ +.lc-flow-head { display: flex; align-items: baseline; gap: 6px; min-width: 0; } +/* The clickable cost header: the same row as an anchor opening the models.dev + listing in a new tab — reset the link chrome so it reads as the text it + visually is; the label's brighten is the only affordance. */ +a.lc-flow-head { color: inherit; text-decoration: none; cursor: pointer; } +a.lc-flow-head:hover .lc-flow-label { color: var(--dsw-alias-label-primary); } +.lc-flow-label { color: var(--dsw-alias-label-secondary); font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.lc-flow-total { margin-left: auto; flex: none; color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; } +/* The pills: a wrapping breadcrumb of rounded chips under the header, each a + muted label with a bold tally. A zero pill dims (the card's total already + reads); a tinted pill (the events card's kind colors) keeps its tally on + the tint's own hue. */ +.lc-flow-pills { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; } +.lc-flow-pill { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: 999px; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); color: var(--dsw-alias-label-secondary); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; } +.lc-flow-pill-label { min-width: 0; max-width: 140px; overflow: hidden; text-overflow: ellipsis; } +.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 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; } +.lc-flow-self-stats { display: flex; gap: 18px; } +.lc-flow-kv { display: flex; flex-direction: column; align-items: center; } +.lc-flow-kv b { font-size: 17px; font-weight: 700; line-height: 1.2; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; } +.lc-flow-kv i { font-style: normal; font-size: 11px; color: var(--dsw-alias-label-secondary); } +/* Right-column tips open leftward so the wide bubble never spills past the card edge. */ +.lc-flow .lc-flow-col-r .lc-stat-tip { left: auto; right: 0; } +/* The narrow fold — paired with the component's FLOW_FOLD_PX (the container's + content box is exactly the flow root's width): the zones stack sources → + session → effects and the ribbons run top to bottom; the side nodes pair up + on one row and the session node centers between the two ribbon gutters. */ +@container lc-card (max-width: 559px) { + .lc-flow { flex-direction: column; } + .lc-flow-col { flex-direction: row; align-items: stretch; } + .lc-flow-col > .lc-flow-node { flex: 1 1 0; } + .lc-flow-mid { margin: 26px 0; } +} +/* + The donut row's 320px gap fold and 240px stack still ride `@max-[…]/lc-card` + utilities — statsTokens.tsx / statsTiming.tsx / sliceList.tsx / donut.tsx. + */ /* ---- Timing card's session-time strip (timingStrip.tsx): the fold's painted spans packed gapless in occurrence order — each band's width is its share of diff --git a/tests/client/agentTree.spec.ts b/tests/client/agentTree.spec.ts index db4267a..5ab54f3 100644 --- a/tests/client/agentTree.spec.ts +++ b/tests/client/agentTree.spec.ts @@ -367,10 +367,10 @@ describe('subagentCostFoldOf', () => { } test('absent snapshot or anchor folds nothing', () => { - assert.deepEqual(subagentCostFoldOf(undefined, 's1'), { usage: null, cold: [] }) - assert.deepEqual(subagentCostFoldOf(snap({}), 's1'), { usage: null, cold: [] }) - assert.deepEqual(subagentCostFoldOf(snap({ s1: row({}) }), undefined), { usage: null, cold: [] }) - assert.deepEqual(subagentCostFoldOf(snap({ s1: row({}) }), ''), { usage: null, cold: [] }) + assert.deepEqual(subagentCostFoldOf(undefined, 's1'), { usage: null, cold: [], count: 0 }) + assert.deepEqual(subagentCostFoldOf(snap({}), 's1'), { usage: null, cold: [], count: 0 }) + assert.deepEqual(subagentCostFoldOf(snap({ s1: row({}) }), undefined), { usage: null, cold: [], count: 0 }) + assert.deepEqual(subagentCostFoldOf(snap({ s1: row({}) }), ''), { usage: null, cold: [], count: 0 }) }) test('merges the whole descendant subtree, never the current session itself', () => { @@ -386,6 +386,8 @@ describe('subagentCostFoldOf', () => { // root's own usage stays out; a timeline row without cost contributes nothing. assert.deepEqual(fold.usage, mergeCostUsage(COST_B, COST_A)) assert.deepEqual(fold.cold, []) + // kid + grand + kid2 — the stranger and the detached orphan are no descendants. + assert.equal(fold.count, 3) }) test('a descendant without a timeline row is a cold fetch target until its head lands', () => { @@ -397,6 +399,7 @@ describe('subagentCostFoldOf', () => { const fold = subagentCostFoldOf(snap(byId), 'root') assert.equal(fold.usage, null, 'nothing warm or landed yet') assert.deepEqual(fold.cold, ['cold', 'bare']) + assert.equal(fold.count, 2, 'a cold descendant is a subagent too') // The landed head injects the cold relative's usage and empties the fetch list. const landed = new Map([['cold', { ...timeline(50), cost: COST_B }]]) assert.deepEqual(subagentCostFoldOf(snap(byId), 'root', landed).usage, COST_B) @@ -411,6 +414,7 @@ describe('subagentCostFoldOf', () => { // current session's own usage never counts in the subtree fold. assert.deepEqual(fold.usage, null) assert.deepEqual(fold.cold, []) + assert.equal(fold.count, 1) }) test('blank placeholders are not agents and a lineage cycle cannot loop the walk', () => { @@ -423,6 +427,7 @@ describe('subagentCostFoldOf', () => { const fold = subagentCostFoldOf(snap(byId), 'root') assert.deepEqual(fold.usage, null, 'the blank usage and the detached cycle stay out (root\'s own cost never counts)') assert.deepEqual(fold.cold, []) + assert.equal(fold.count, 0, 'the blank placeholder is no subagent either') }) test('a cycle reachable from the current session is cut at the revisited id', () => { @@ -435,6 +440,7 @@ describe('subagentCostFoldOf', () => { const fold = subagentCostFoldOf(snap(byId), 'root') assert.deepEqual(fold.usage, COST_B, 'p counts once; the current session never counts as its own child') assert.deepEqual(fold.cold, []) + assert.equal(fold.count, 1) }) test('hostile rows and timeline values degrade to fewer contributions', () => { @@ -452,6 +458,7 @@ describe('subagentCostFoldOf', () => { // throw); the hostile cost loses its junk branch and coerces its fields. assert.deepEqual(fold.usage, { p: { m: { peak: { uncached: 0, cacheRead: 7, cacheWrite: 0, output: 0 } } } }) assert.deepEqual(fold.cold, []) + assert.equal(fold.count, 2, 'the non-row never joined the walk') }) }) diff --git a/tests/client/components/contextView.spec.ts b/tests/client/components/contextView.spec.ts index d603a75..da819e1 100644 --- a/tests/client/components/contextView.spec.ts +++ b/tests/client/components/contextView.spec.ts @@ -1401,8 +1401,8 @@ describe('ContextView — the split generation (slim head + detail channel)', () const m = await mount(h(View, { sessionId: 'sv-slim', useProjection: projectionsFor(slimHead()) })) // First paint: the counters are real (no detail needed), the detail cards name the pending read. - const values = queryAll(m.container, '.lc-stat-value').map(el => text(el)) - assert.deepEqual(values.slice(0, 2), ['1', '3'], 'turns/steps from the head counters') + const values = queryAll(m.container, '.lc-flow-kv b').map(el => text(el)) + assert.deepEqual(values, ['1', '3', '0'], 'turns/steps from the head counters; no subagents without the sessions face') assert.ok(text(m.container).includes(DICT_EN['detail.loading'])) assert.equal(queryAll(m.container, '.lc-bar').length, 0, 'the chart waits for the detail') assert.equal(queryAll(m.container, '.lc-br-pick option').length, 1, 'the picker holds only the live row') diff --git a/tests/client/components/statsContext.spec.ts b/tests/client/components/statsContext.spec.ts index 118fd7b..d2915db 100644 --- a/tests/client/components/statsContext.spec.ts +++ b/tests/client/components/statsContext.spec.ts @@ -1,26 +1,31 @@ // StatsContext (src/client/components/statsContext.tsx) rendered with real -// React: the six-cell grid — session shape with the whole-session -// human-input tally, the family-scope priced cost cell (current agent + -// subagents) with its per-model rate tooltip, and the subagents' own share — -// in both locales, against an injected model-price -// book (the store never reaches the network). The context-event tallies live -// on the events card's kind filters (contextView.spec.ts); `countsOfRecords` -// still derives every count the split generation's wire head carries, pinned -// here. +// 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. import { act, createElement as h } from 'react' import assert from 'node:assert/strict' import { afterEach, describe, test, vi, beforeEach } from 'vitest' -import { countsOfRecords, makeStatsContext, makeSubagentCost } from '../../../src/client/components/statsContext' +import { countsOfRecords, flowCurve, makeStatsContext, makeSubagentCost, measureFlow, toolTallyOf } from '../../../src/client/components/statsContext' +import type { SubagentStats } from '../../../src/client/components/statsContext' import { makeAgentHeads } from '../../../src/client/agentHeads' import { resetModelPrices, setModelPricesLoader } from '../../../src/client/modelPrices' -import type { ContextEventRecord, ContextTimeline, RequestRecord, SessionCostUsage } from '../../../src/shared/types' +import type { ContextEventRecord, ContextTimeline, RequestRecord, SessionCostUsage, SurfaceNode } from '../../../src/shared/types' import { TestClientCtx, asClientCtx } from '../helpers/harness' import { flush, makeKit, mount, queryAll, text } from '../helpers/kit' const kit = makeKit() const kitZh = makeKit('zh') -const NO_SUB = (): SessionCostUsage | null => null +const NO_SUB = (): SubagentStats => ({ usage: null, count: 0 }) const StatsContext = makeStatsContext(kit, NO_SUB) const StatsContextZh = makeStatsContext(kitZh, NO_SUB) @@ -34,6 +39,9 @@ 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, @@ -45,11 +53,22 @@ function ev(kind: ContextEventRecord['kind']): ContextEventRecord { return { seq: 0, time: 0, kind } } -function cells(container: HTMLElement): { labels: string[]; values: string[] } { - const grid = queryAll(container, '.lc-stat') +/** 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 pills as 'label+figure' strings (tips excluded). */ +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 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') return { - labels: grid.map(el => el.querySelector('.lc-stat-label')?.textContent ?? ''), - values: grid.map(el => el.querySelector('.lc-stat-value')?.textContent ?? ''), + labels: heads.map(el => el.querySelector('.lc-flow-label')?.textContent ?? ''), + totals: heads.map(el => el.querySelector('.lc-flow-total')?.textContent ?? ''), } } @@ -78,45 +97,156 @@ describe('countsOfRecords (the inline generation derivation)', () => { }) }) +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', () => { - test('folds the six-cell grid: the shape stats and the two cost cells', async () => { + 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 }, humanInputs: 7, - toolCalls: 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 { labels, values } = cells(m.container) - assert.equal(labels.length, 6) - assert.deepEqual(labels, ['Turns', 'Steps', 'Human Inputs?', 'Tool Calls', 'Cost?', 'Subagent Cost?']) - // 1M uncached input at the doubled peak miss rate (2 × $0.15); no subagent usage → the sub cell dashes. - assert.deepEqual(values, ['3', '4', '7', '3', '$0.30', '—']) + 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(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 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']) + assert.deepEqual(queryAll(nodes[2], '.lc-flow-kv i').map(el => el.textContent), ['Turns', 'Steps', 'Subagents']) + // 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') + // 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 and the dash', async () => { + test('absent counters and cost degrade to zeros, dimmed pills, and the dash', async () => { const m = await mount(h(StatsContext, { - counts: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + counts: NO_COUNTS, + files: NO_FILES, + tools: [], locale: 'en', })) await flush() - assert.deepEqual(cells(m.container).values, ['0', '0', '0', '0', '—', '—']) + assert.deepEqual(headsOf(m.container).totals, ['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.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'], 'two tools pill out, no overflow') await m.unmount() }) test('the cost bubble lists the billed models with their billed rates', async () => { const m = await mount(h(StatsContext, { - counts: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + counts: NO_COUNTS, + files: NO_FILES, + tools: [], cost: COST, locale: 'en', })) await flush() - assert.equal(queryAll(m.container, '.lc-stat-tip').length, 3) - assert.equal(queryAll(m.container, '.lc-stat-q').length, 3) const tips = queryAll(m.container, '.lc-stat-tip').map(el => text(el)) + assert.equal(tips.length, 2) 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') @@ -129,13 +259,14 @@ describe('StatsContext', () => { 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('|'), 'no peak|off pairs — the footnotes carry the scheme') - assert.ok(tips[2].includes('every subagent session'), 'the sub cell explains its own scope') await m.unmount() }) test('a non-DeepSeek session never sees DeepSeek-specific notes', async () => { const m = await mount(h(StatsContext, { - counts: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + 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', })) @@ -149,34 +280,38 @@ describe('StatsContext', () => { await m.unmount() }) - test('the cost cell links to the models.dev provider listing when one provider priced the scope', async () => { + 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: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + counts: NO_COUNTS, + files: NO_FILES, + tools: [], cost: COST, locale: 'en', })) await flush() - const links = queryAll(m.container, 'a.lc-stat') - assert.equal(links.length, 1, 'the cost cell is the only link') + 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 framed cell body') - assert.ok(queryAll(m.container, '.lc-stat').length > links.length, 'the sibling cells stay plain') + 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 cell unlinked', async () => { + 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: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + counts: NO_COUNTS, + files: NO_FILES, + tools: [], cost: two, locale: 'en', })) await flush() - assert.equal(queryAll(m.container, 'a.lc-stat').length, 0, 'two providers name their faces in the tooltip instead') + assert.equal(queryAll(m.container, 'a.lc-flow-head').length, 0, 'two providers name their faces in the tooltip instead') await m.unmount() }) @@ -186,7 +321,9 @@ describe('StatsContext', () => { '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: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + counts: NO_COUNTS, + files: NO_FILES, + tools: [], cost: two, locale: 'en', })) @@ -195,22 +332,30 @@ describe('StatsContext', () => { 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(cells(m.container).values.at(-2) === '$0.45') + assert.ok(headsOf(m.container).totals[3] === '$0.45') await m.unmount() }) - test('the zh locale localizes labels and prices the cost in CNY at 1 CNY = 0.15 USD', async () => { + 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 }, + 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 { labels, values } = cells(m.container) - assert.deepEqual(labels, ['轮次', '步数', '用户输入?', '工具调用', '费用?', '子 Agent 费用?']) + const nodes = flowNodes(m.container) + const { labels, totals } = headsOf(m.container) + assert.deepEqual(labels, ['输入输出', '上下文事件', '工具调用', '费用?']) // $0.30 / 0.15 = ¥2; the rates convert through the same fixed rate. - assert.deepEqual(values, ['1', '1', '0', '0', '¥2.00', '—']) + assert.deepEqual(totals, ['3', '1', '2', '¥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.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('每百万 Token 价格')) @@ -221,32 +366,35 @@ describe('StatsContext', () => { assert.ok(costTip.includes('未缓存输入 ¥1.00')) assert.ok(costTip.includes('缓存写入 ¥1.00')) assert.ok(costTip.includes('输出 ¥4.00')) - assert.ok(text(queryAll(m.container, '.lc-stat-tip')[2]).includes('子 Agent 会话'), 'the sub tip localizes too') await m.unmount() }) - test('a book that has not landed yet keeps the cost cell dashed', async () => { + test('a book that has not landed yet keeps the cost figure dashed', async () => { setModelPricesLoader(() => new Promise(() => {})) const m = await mount(h(StatsContext, { - counts: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + counts: NO_COUNTS, + files: NO_FILES, + tools: [], cost: COST, locale: 'en', })) await flush() - assert.ok(cells(m.container).values.at(-2) === '—') + assert.ok(headsOf(m.container).totals[3] === '—') assert.ok(!text(m.container).includes('unavailable'), 'a pending fetch is not a failure') await m.unmount() }) - test('a failed price fetch dashes the cell and notes the outage in the tip', async () => { + 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: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + counts: NO_COUNTS, + files: NO_FILES, + tools: [], cost: COST, locale: 'en', })) await flush() - assert.ok(cells(m.container).values.at(-2) === '—') + assert.ok(headsOf(m.container).totals[3] === '—') const costTip = text(queryAll(m.container, '.lc-stat-tip')[1]) assert.ok(costTip.includes('unavailable')) assert.ok(!costTip.includes('Per-1M-token rates')) @@ -263,13 +411,15 @@ describe('StatsContext', () => { }, } const m = await mount(h(StatsContext, { - counts: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + 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(cells(m.container).values.at(-2) === '$0.60') + assert.ok(headsOf(m.container).totals[3] === '$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')) @@ -280,12 +430,14 @@ describe('StatsContext', () => { test('a session whose models the book cannot price notes the outage too', async () => { const m = await mount(h(StatsContext, { - counts: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + 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(cells(m.container).values.at(-2) === '—') + assert.ok(headsOf(m.container).totals[3] === '—') assert.ok(text(queryAll(m.container, '.lc-stat-tip')[1]).includes('unavailable')) await m.unmount() }) @@ -301,7 +453,9 @@ describe('StatsContext', () => { }, } as unknown as SessionCostUsage const m = await mount(h(StatsContext, { - counts: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + counts: NO_COUNTS, + files: NO_FILES, + tools: [], cost: hostile, locale: 'en', })) @@ -313,26 +467,31 @@ 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(cells(m.container).values.at(-2) === '$0.15') + assert.ok(headsOf(m.container).totals[3] === '$0.15') await m.unmount() }) }) -describe('StatsContext — the subagent-cost cell (injected seat)', () => { +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 cost cell merges the subagent usage in; the sub cell prices the share alone', async () => { - const Stats = makeStatsContext(kit, () => SUB) + 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: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + counts: NO_COUNTS, + files: NO_FILES, + tools: [], cost: COST, locale: 'en', })) await flush() - // Family total: 1M × $0.30 (the doubled peak) + 2M × $0.075; the subagents' share: $0.15. - assert.deepEqual(cells(m.container).values.slice(-2), ['$0.45', '$0.15']) + // 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']) + // 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.')) @@ -342,14 +501,17 @@ describe('StatsContext — the subagent-cost cell (injected seat)', () => { await m.unmount() }) - test('subagent usage alone (no own cost) still prices both cells', async () => { - const Stats = makeStatsContext(kit, () => SUB) + 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: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + counts: NO_COUNTS, + files: NO_FILES, + tools: [], locale: 'en', })) await flush() - assert.deepEqual(cells(m.container).values.slice(-2), ['$0.15', '$0.15']) + assert.equal(headsOf(m.container).totals[3], '$0.15') + assert.deepEqual(pillsOf(flowNodes(m.container)[4]), ['This Agent—', 'Subagents?$0.15']) await m.unmount() }) @@ -357,44 +519,52 @@ describe('StatsContext — the subagent-cost cell (injected seat)', () => { const deepseekSub: SessionCostUsage = { 'deepseek-official': { 'deepseek-v4-flash': { off: { uncached: 1_000_000, cacheRead: 0, cacheWrite: 0, output: 0 } } }, } - const Stats = makeStatsContext(kit, () => deepseekSub) + const Stats = makeStatsContext(kit, () => ({ usage: deepseekSub, count: 1 })) const m = await mount(h(Stats, { - counts: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + counts: NO_COUNTS, + files: NO_FILES, + tools: [], locale: 'en', })) await flush() // The off bucket bills at book: 1M × $0.15. - assert.deepEqual(cells(m.container).values.slice(-2), ['$0.15', '$0.15']) + assert.equal(headsOf(m.container).totals[3], '$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 cell and notes the outage in its tip', async () => { - const Stats = makeStatsContext(kit, () => ({ future: { 'mystery-model': { peak: { uncached: 1, cacheRead: 0, cacheWrite: 0, output: 0 } } } })) + 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: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + 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 cell dashes. - assert.deepEqual(cells(m.container).values.slice(-2), ['$0.30', '—']) + // 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?—']) 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 both cells dashed with no outage notes', async () => { + test('a null seat keeps the figure dashed with no split pills and no outage notes', async () => { const m = await mount(h(StatsContext, { - counts: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + counts: NO_COUNTS, + files: NO_FILES, + tools: [], locale: 'en', })) await flush() - assert.deepEqual(cells(m.container).values.slice(-2), ['—', '—']) + assert.equal(headsOf(m.container).totals[3], '—') + assert.deepEqual(pillsOf(flowNodes(m.container)[4]), []) assert.ok(!text(m.container).includes('unavailable')) await m.unmount() }) @@ -436,7 +606,7 @@ describe('StatsContext — the real subagent-cost seat (makeSubagentCost)', () = } } - function makeSeat(byId: Record): { seat: (sessionId: string | undefined) => SessionCostUsage | null; face: FakeSessions } { + function makeSeat(byId: Record): { 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(asClientCtx(ctx))), face } @@ -464,14 +634,19 @@ describe('StatsContext — the real subagent-cost seat (makeSubagentCost)', () = }) const Stats = makeStatsContext(kit, seat) const m = await mount(h(Stats, { - counts: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + 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.deepEqual(cells(m.container).values.slice(-2), ['$0.15', '$0.15']) + assert.equal(headsOf(m.container).totals[3], '$0.15') + assert.deepEqual(pillsOf(flowNodes(m.container)[4]), ['This Agent—', 'Subagents?$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']) // 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 () => { @@ -482,7 +657,7 @@ describe('StatsContext — the real subagent-cost seat (makeSubagentCost)', () = }) }) await flush() - // The cold read lands: its $0.15 merges into both cells. + // The cold read lands: its $0.15 merges into the family and the sub share. await act(async () => { release?.({ ok: true, @@ -502,11 +677,12 @@ describe('StatsContext — the real subagent-cost seat (makeSubagentCost)', () = }) }) await flush() - assert.deepEqual(cells(m.container).values.slice(-2), ['$0.30', '$0.30']) + assert.equal(headsOf(m.container).totals[3], '$0.30') + assert.deepEqual(pillsOf(flowNodes(m.container)[4]), ['This Agent—', 'Subagents?$0.30']) await m.unmount() }) - test('a failing cold fetch degrades the cell without crashing or retrying', async () => { const calls: string[] = [] + 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') @@ -517,13 +693,18 @@ describe('StatsContext — the real subagent-cost seat (makeSubagentCost)', () = }) const Stats = makeStatsContext(kit, seat) const m = await mount(h(Stats, { - counts: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + counts: NO_COUNTS, + files: NO_FILES, + tools: [], locale: 'en', sessionId: 'root', })) await flush() assert.deepEqual(calls, ['cold']) - assert.ok(cells(m.container).values.at(-2) === '—') + assert.equal(headsOf(m.container).totals[3], '—') + assert.deepEqual(pillsOf(flowNodes(m.container)[4]), [], 'nothing reported → no split pills') + // 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. await act(async () => { face.setState({ root: { running: false, updatedAt: 1 }, cold: { parentId: 'root', updatedAt: 2 } }) @@ -552,14 +733,17 @@ describe('StatsContext — the real subagent-cost seat (makeSubagentCost)', () = }) const Stats = makeStatsContext(kit, seat) const m = await mount(h(Stats, { - counts: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + 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 cells stay dashes, no throw. - assert.ok(cells(m.container).values.slice(-2).every(v => v === '—')) + // 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]), []) // A later tick re-attaches to the settled nulls without re-fetching. await act(async () => { face.setState({ @@ -577,12 +761,15 @@ describe('StatsContext — the real subagent-cost seat (makeSubagentCost)', () = const ctx = new TestClientCtx() const Stats = makeStatsContext(kit, makeSubagentCost(asClientCtx(ctx), makeAgentHeads(asClientCtx(ctx)))) const m = await mount(h(Stats, { - counts: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + counts: NO_COUNTS, + files: NO_FILES, + tools: [], locale: 'en', sessionId: 'root', })) await flush() - assert.deepEqual(cells(m.container).values.slice(-2), ['—', '—']) + assert.equal(headsOf(m.container).totals[3], '—') + assert.deepEqual(pillsOf(flowNodes(m.container)[4]), []) await m.unmount() }) @@ -596,12 +783,15 @@ describe('StatsContext — the real subagent-cost seat (makeSubagentCost)', () = const ctx = new TestClientCtx({ services: { sessions: face } }) const Stats = makeStatsContext(kit, makeSubagentCost(asClientCtx(ctx), makeAgentHeads(asClientCtx(ctx)))) const m = await mount(h(Stats, { - counts: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 }, + counts: NO_COUNTS, + files: NO_FILES, + tools: [], locale: 'en', })) await flush() assert.deepEqual(calls, []) - assert.deepEqual(cells(m.container).values.slice(-2), ['—', '—']) + assert.equal(headsOf(m.container).totals[3], '—') + assert.deepEqual(pillsOf(flowNodes(m.container)[4]), []) await m.unmount() }) })