feat(context): rebuild the Context Stats card as a session flow diagram
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.
This commit is contained in:
1 parent
168051de00
commit
c4ad2e8a9c
9 files changed
+710
-177
No files matched your search
@@ -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<string, ContextTimeline>,
|
||||
): 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<string, string[]>()
|
||||
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 {
|
||||
|
||||
@@ -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 : (
|
||||
<div className="lc-cols lc-head">
|
||||
<StatsContext counts={counts} humanInputs={data.humanInputs} toolCalls={data.toolCalls}
|
||||
files={ioTotals} tools={toolTally}
|
||||
cost={data.cost} locale={activeLocale} sessionId={typeof sessionId === 'string' ? sessionId : undefined} />
|
||||
<PluginInfo />
|
||||
</div>
|
||||
|
||||
@@ -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<string, number>()
|
||||
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<FlowNodeKey, FlowBox>, 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 ? <span key="unavailable">{t('stats.costUnavailable')}</span> : 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<HTMLDivElement | null>(null)
|
||||
const nodeEls = useRef(new Map<FlowNodeKey, HTMLDivElement>())
|
||||
const [links, setLinks] = useState<FlowLink[]>([])
|
||||
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<FlowNodeKey, FlowBox>
|
||||
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 = (
|
||||
<>
|
||||
<span className="lc-stat-label">
|
||||
<span className="lc-flow-label">
|
||||
{label}
|
||||
{tip !== undefined && <i className="lc-stat-q group-hover/tip:text-(--dsw-alias-label-primary) group-hover/tip:border-(--dsw-alias-label-primary)" aria-hidden="true">?</i>}
|
||||
</span>
|
||||
<b className="lc-stat-value">{typeof value === 'number' ? fmt(value) : value}</b>
|
||||
<b className="lc-flow-total">{total}</b>
|
||||
{tip !== undefined && <span className="lc-tip lc-stat-tip group-hover/tip:opacity-100" role="tooltip">{tip}</span>}
|
||||
</>
|
||||
)
|
||||
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
|
||||
? <div className={className}>{body}</div>
|
||||
: <a className={className} href={href} target="_blank" rel="noreferrer noopener">{body}</a>
|
||||
}
|
||||
// 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 => (
|
||||
<span key={label} className={'lc-flow-pill' + cls + (value === 0 ? ' lc-flow-pill-dim' : '') + (tip === undefined ? '' : ' lc-stat-tipped group/tip')}>
|
||||
<span className="lc-flow-pill-label">
|
||||
{label}
|
||||
{tip !== undefined && <i className="lc-stat-q group-hover/tip:text-(--dsw-alias-label-primary) group-hover/tip:border-(--dsw-alias-label-primary)" aria-hidden="true">?</i>}
|
||||
</span>
|
||||
<b>{typeof value === 'number' ? fmt(value) : value}</b>
|
||||
{tip !== undefined && <span className="lc-tip lc-stat-tip group-hover/tip:opacity-100" role="tooltip">{tip}</span>}
|
||||
</span>
|
||||
)
|
||||
// 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 (
|
||||
<div className="lc-card lc-col-stats flex-[3] min-w-[min(360px,100%)]">
|
||||
<div className="lc-card-title">
|
||||
<span className="lc-card-title-text">{t('stats.title')}</span>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<div className="lc-stats grid grid-cols-[repeat(auto-fit,minmax(108px,1fr))] gap-1.5">
|
||||
{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)}
|
||||
<div className="lc-stats lc-flow" ref={flowRef}>
|
||||
{/* The connector overlay: under the nodes (they carry z-index), in
|
||||
flow order so the traveling dashes read left→right / top→bottom. */}
|
||||
<svg className="lc-flow-svg" aria-hidden="true">
|
||||
{links.map((l, i) => (
|
||||
// Fixed four-link set in a fixed order: the index IS the identity
|
||||
// (zero-measured boxes produce identical d strings — jsdom, SSR).
|
||||
<g key={i}>
|
||||
<path className="lc-flow-ribbon" d={l.d} stroke={l.color} />
|
||||
<path className="lc-flow-dash animate-lc-agent-flow motion-reduce:animate-none" d={l.d} stroke={l.color} />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
<div className="lc-flow-col">
|
||||
<div className="lc-flow-node" ref={nodeRef('inputs')}>
|
||||
{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. */}
|
||||
<div className="lc-flow-pills">
|
||||
{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}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lc-flow-node" ref={nodeRef('events')}>
|
||||
{head(t('stats.events'), fmt(props.counts.injects + props.counts.compactions + props.counts.prunes))}
|
||||
<div className="lc-flow-pills">
|
||||
{EVENT_PILLS.map(p => pill(t('kind.' + p.kind), props.counts[p.tally], ' lc-flow-pill-tint ' + p.cls))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lc-flow-mid">
|
||||
<div className="lc-flow-node lc-flow-self" ref={nodeRef('session')}>
|
||||
<span className="lc-flow-label">{t('stats.session')}</span>
|
||||
<span className="lc-flow-self-stats">
|
||||
<span className="lc-flow-kv"><b>{fmt(props.counts.turns)}</b><i>{t('stats.turns')}</i></span>
|
||||
<span className="lc-flow-kv"><b>{fmt(props.counts.steps)}</b><i>{t('stats.steps')}</i></span>
|
||||
<span className="lc-flow-kv"><b>{fmt(sub.count)}</b><i>{t('stats.subagents')}</i></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lc-flow-col lc-flow-col-r">
|
||||
<div className="lc-flow-node" ref={nodeRef('tools')}>
|
||||
{head(t('stats.toolCalls'), fmt(toolTotal))}
|
||||
{topTools.length > 0
|
||||
? (
|
||||
<div className="lc-flow-pills">
|
||||
{topTools.map(([name, n]) => (
|
||||
<span key={name} className="lc-flow-pill" title={name}>
|
||||
<span className="lc-flow-pill-label">{name}</span>
|
||||
<b>{fmt(n)}</b>
|
||||
</span>
|
||||
))}
|
||||
{moreTools > 0
|
||||
? <span className="lc-flow-pill lc-flow-pill-dim"><span className="lc-flow-pill-label">{t('stats.toolsMore', { n: moreTools })}</span></span>
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
<div className="lc-flow-node" ref={nodeRef('cost')}>
|
||||
{head(t('stats.cost'), cost === null ? '—' : formatCost(cost, currency), costTip, costHref)}
|
||||
{/* The split rides only when a subagent subtree actually reported:
|
||||
without one the family figure IS the agent's own and two pills
|
||||
would repeat it. */}
|
||||
{subUsage !== null
|
||||
? (
|
||||
<div className="lc-flow-pills">
|
||||
{pill(t('stats.ownCost'), ownCost === null ? '—' : formatCost(ownCost, currency))}
|
||||
{pill(t('stats.subCost'), subCost === null ? '—' : formatCost(subCost, currency), '', subTip)}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+14
-2
@@ -22,18 +22,24 @@ export const DICT_ZH: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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}.',
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, ContextTimeline>([['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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<string, unknown>): { seat: (sessionId: string | undefined) => SessionCostUsage | null; face: FakeSessions } {
|
||||
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(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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user