diff --git a/src/client/components/overviewCard.tsx b/src/client/components/overviewCard.tsx
index 3ed088a..1a4ff84 100644
--- a/src/client/components/overviewCard.tsx
+++ b/src/client/components/overviewCard.tsx
@@ -32,7 +32,10 @@ export function makeOverviewCard(kit: ViewKit): (props: OverviewCardProps) => Re
return function OverviewCard(props: OverviewCardProps): ReactElement {
const { row } = props
const timeline = row.timeline
- const billed = billedOf(timeline)
+ // The card's two money figures ride the team scope: the whole subtree's
+ // billed tokens and priced cost (the donut and the turns stay the
+ // session's own — the ring reads the session's CURRENT context).
+ const billed = billedOf(row)
const occupancy = timeline !== null && typeof timeline.contextWindow === 'number' && timeline.contextWindow > 0
? fmtShare(timeline.current.total, timeline.contextWindow)
: null
@@ -91,11 +94,11 @@ export function makeOverviewCard(kit: ViewKit): (props: OverviewCardProps) => Re
{turnsLabel}
- {t('tokens.total')}
+ {t('ov.card.teamUsage')}
{billed === null ? '—' : fmt(billed)}
- {t('stats.cost')}
+ {t('ov.card.teamCost')}
{props.costLabel}
diff --git a/src/client/components/overviewPanel.tsx b/src/client/components/overviewPanel.tsx
index 8fb051a..23f11dd 100644
--- a/src/client/components/overviewPanel.tsx
+++ b/src/client/components/overviewPanel.tsx
@@ -355,9 +355,9 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
}
}
-/** One card's priced cost label, or the dash (no book yet, nothing billed, unpriceable model). */
+/** One card's priced cost label at the team scope, or the dash (no book yet, nothing billed, unpriceable models). */
function cardCostOf(row: OverviewRow, book: ModelBook | null, currency: CostCurrency): string {
- if (row.timeline?.cost === undefined) return '—'
- const cost = estimateSessionCost(row.timeline.cost, book, currency)
+ if (row.familyCost === null) return '—'
+ const cost = estimateSessionCost(row.familyCost, book, currency)
return cost === null ? '—' : formatCost(cost, currency)
}
diff --git a/src/client/i18n.ts b/src/client/i18n.ts
index 29aa852..68181d7 100644
--- a/src/client/i18n.ts
+++ b/src/client/i18n.ts
@@ -398,7 +398,7 @@ export const DICT_ZH: Record = {
'ov.list.search': '搜索标题 / 目录 / 最后消息…',
'ov.list.sortLabel': '排序',
'ov.list.sort.recent': '最近更新',
- 'ov.list.sort.tokens': 'Token 总用量',
+ 'ov.list.sort.tokens': '团队用量',
'ov.list.sort.context': '当前 Context',
'ov.list.empty': '还没有会话 — 发起一轮对话后,这里会列出每个会话的上下文画像',
'ov.list.noMatch': '没有匹配当前筛选条件的会话',
@@ -414,6 +414,8 @@ export const DICT_ZH: Record = {
'ov.running': '运行中',
'ov.current': '当前',
'ov.card.turns': '{n} 轮 {s} 步',
+ 'ov.card.teamUsage': '团队用量',
+ 'ov.card.teamCost': '团队费用',
'ov.card.lastUser': '最后消息',
'ov.time.now': '刚刚',
'ov.time.m': '{n} 分钟前',
@@ -812,7 +814,7 @@ export const DICT_EN: Record = {
'ov.list.search': 'Search title / path / last message…',
'ov.list.sortLabel': 'Sort',
'ov.list.sort.recent': 'Last updated',
- 'ov.list.sort.tokens': 'Total Tokens',
+ 'ov.list.sort.tokens': 'Team Usage',
'ov.list.sort.context': 'Current Context',
'ov.list.empty': 'No sessions yet — each session’s context profile appears here once you start one',
'ov.list.noMatch': 'No sessions match the current filters',
@@ -828,6 +830,8 @@ export const DICT_EN: Record = {
'ov.running': 'Running',
'ov.current': 'current',
'ov.card.turns': '{n} turns · {s} steps',
+ 'ov.card.teamUsage': 'Team Usage',
+ 'ov.card.teamCost': 'Team Cost',
'ov.card.lastUser': 'Last message',
'ov.time.now': 'just now',
'ov.time.m': '{n}m ago',
diff --git a/src/client/overview.ts b/src/client/overview.ts
index 8363332..b58d11f 100644
--- a/src/client/overview.ts
+++ b/src/client/overview.ts
@@ -6,6 +6,12 @@
* daily ledger) — so the overview joins every session's insight WITHOUT
* opening a single session log.
*
+ * The row IS the team: subagent-origin sessions never list (the workspace
+ * browser's own exclusion), so every row folds its WHOLE descendant subtree
+ * into itself — the card's billed tokens and cost, the KPI band, the usage
+ * chart, and the heatmap all read the family scope, each member contributing
+ * its own composition and daily ledger, nothing counted twice.
+ *
* The list is harness data (untrusted at the boundary): the snapshot is
* re-proved field by field, every projection value passes its services.ts
* sanitizer, and each row's derivation is isolated — one hostile row drops
@@ -19,6 +25,7 @@ import { billedParts } from './categories'
import { cacheHitPercent } from './format'
import { estimateSessionCost, mergeCostUsage } from './cost'
import type { CostCurrency, ModelBook } from './cost'
+import { agentRowOf } from './agentTree'
import { activityOf, asRecord, timelineOf, type ClientCtx, type SessionsFace } from './services'
import type { ContextActivity, ContextTimeline, SessionCostUsage, TimingTotals, TokenUsage, ToolTimingTotals } from '../shared/types'
@@ -34,8 +41,17 @@ export interface OverviewRow {
current: boolean
/** The sanitized timeline head, or null when the host folded nothing for this session yet. */
timeline: ContextTimeline | null
- /** The sanitized daily ledger, or null on an older host (the heatmap's empty note). */
+ /**
+ * The TEAM's merged daily ledger (this session's own plus every descendant
+ * subagent's, day by day), or null when no member carries one. Subagent-origin
+ * sessions never list (the workspace browser's exclusion, see rowsOfSnapshot),
+ * so each family folds into exactly one row and nothing counts twice.
+ */
activity: ContextActivity | null
+ /** Every subtree member's sanitized head, self first, ALL levels — the parts split proportions each member's OWN composition. */
+ family: (ContextTimeline | null)[]
+ /** The subtree's merged billed usage — the card stat, KPI band, and token sort's family figure (null: nothing billed anywhere). */
+ familyCost: SessionCostUsage | null
}
/**
@@ -217,14 +233,85 @@ export function rowsOfSnapshot(snapshot: unknown, workspaces?: unknown): Overvie
current: id === current,
timeline: timelineOf(values?.contextTimeline),
activity: activityOf(values?.contextActivity),
+ family: [],
+ familyCost: null,
})
} catch {
// A hostile row (throwing accessor) drops whole; the list keeps working.
}
}
+ // The family fold: every listed row owns its WHOLE subagent subtree (all
+ // levels — the walk mirrors the Context tab's cost fold, agentTree
+ // subagentCostFoldOf: childrenOf off non-blank rows' parentId, BFS with a
+ // seen-set so a lineage cycle cannot loop). Each member's own sanitized
+ // timeline and ledger join the row's family; the row's own `activity` then
+ // becomes the team's merged ledger.
+ const childrenOf = new Map()
+ for (const id of Object.keys(byId)) {
+ // The row loop's own isolation, mirrored: a hostile (throwing) row drops
+ // out of the lineage walk the same way it dropped out of the list.
+ try {
+ const row = agentRowOf(byId[id])
+ if (row === null || row.blank || row.parentId === undefined) continue
+ const list = childrenOf.get(row.parentId) ?? []
+ list.push(id)
+ childrenOf.set(row.parentId, list)
+ } catch { /* the hostile row links nobody */ }
+ }
+ for (const row of rows) {
+ const timelines: (ContextTimeline | null)[] = [row.timeline]
+ const activities: (ContextActivity | null)[] = [row.activity]
+ const seen = new Set([row.id])
+ const queue = [row.id]
+ for (let i = 0; i < queue.length; i++) {
+ for (const kid of childrenOf.get(queue[i]) ?? []) {
+ if (seen.has(kid)) continue
+ seen.add(kid)
+ const values = asRecord(asRecord(byId[kid])?.projectionValues)
+ timelines.push(timelineOf(values?.contextTimeline))
+ activities.push(activityOf(values?.contextActivity))
+ queue.push(kid)
+ }
+ }
+ row.family = timelines
+ row.familyCost = mergeCostUsage(...timelines.map(t => t?.cost))
+ row.activity = mergeActivity(activities)
+ }
return rows
}
+/**
+ * Merge several sessions' daily ledgers into one family's: per day the tokens
+ * and requests sum and the pricing records merge (each member's own entry,
+ * same day — the fee prices off the same book downstream either way). Null
+ * when no member carries a ledger, matching a lone session's day-less state.
+ */
+function mergeActivity(members: (ContextActivity | null)[]): ContextActivity | null {
+ const days: ContextActivity['days'] = {}
+ // Widened honestly: a Record index read can miss at runtime.
+ const byKey: Record = days
+ let any = false
+ for (const activity of members) {
+ if (activity === null) continue
+ any = true
+ for (const key of Object.keys(activity.days)) {
+ const entry = activity.days[key]
+ const prev = byKey[key]
+ if (prev === undefined) {
+ days[key] = { tokens: entry.tokens, requests: entry.requests, ...(entry.cost !== undefined ? { cost: entry.cost } : {}) }
+ } else {
+ prev.tokens += entry.tokens
+ prev.requests += entry.requests
+ if (entry.cost !== undefined) {
+ /* v8 ignore next 1 -- a merge with a defined input never comes back empty. */
+ prev.cost = mergeCostUsage(prev.cost, entry.cost) ?? entry.cost
+ }
+ }
+ }
+ }
+ return any ? { days } : null
+}
+
// ---- range / filter / sort -------------------------------------------------
export type OverviewRange = '24h' | '7d' | '30d' | 'all'
@@ -240,12 +327,12 @@ export function rangeStartOf(range: OverviewRange, now: number): number | null {
export type OverviewSort = 'recent' | 'tokens' | 'context'
/**
- * The session's cumulative billed tokens (the host-folded cost buckets'
- * sum), or null when nothing was billed yet — the sort and the card stat
- * share this one figure.
+ * The team card's cumulative billed tokens (the subtree's merged cost
+ * buckets' sum), or null when nothing was billed anywhere — the sort and the
+ * card stat share this one figure.
*/
-export function billedOf(timeline: ContextTimeline | null): number | null {
- const totals = usageTotalsOf(timeline?.cost)
+export function billedOf(row: OverviewRow): number | null {
+ const totals = usageTotalsOf(row.familyCost)
return totals?.total ?? null
}
@@ -304,7 +391,7 @@ export function filterRows(
/** Order the filtered rows; the input array is never mutated. */
export function sortRows(rows: readonly OverviewRow[], sort: OverviewSort): OverviewRow[] {
const copy = [...rows]
- if (sort === 'tokens') copy.sort((a, b) => (billedOf(b.timeline) ?? -1) - (billedOf(a.timeline) ?? -1))
+ if (sort === 'tokens') copy.sort((a, b) => (billedOf(b) ?? -1) - (billedOf(a) ?? -1))
else if (sort === 'context') copy.sort((a, b) => (b.timeline?.current.total ?? -1) - (a.timeline?.current.total ?? -1))
else copy.sort((a, b) => b.updatedAt - a.updatedAt)
return copy
@@ -430,13 +517,13 @@ export interface TokenPartTotal {
/**
* The range's billed tokens split by WHAT they are — the Context tab's Token
- * card categorization, folded across sessions: each session's
- * `billedParts` estimate (the composition ratios proportioning that
- * session's provider-reported prompt total, output exact) sums by category,
- * and every session's parts total its own billed figure, so the aggregate's
+ * card categorization, folded across the rows' FAMILIES: every subtree
+ * member's `billedParts` estimate (its OWN composition ratios proportioning
+ * its own provider-reported prompt total, output exact) sums by category,
+ * and every member's parts total its own billed figure, so the aggregate's
* total stays the exact merged billed volume while the split inherits the
- * per-session card's `≈` estimate convention. Null when no session billed
- * anything. A non-finite part estimate (a hostile fast-path composition)
+ * per-session card's `≈` estimate convention. Null when nothing billed
+ * anywhere. A non-finite part estimate (a hostile fast-path composition)
* drops whole instead of poisoning the sums.
*/
export function tokenPartsOf(rows: readonly OverviewRow[]): { parts: TokenPartTotal[]; total: number } | null {
@@ -444,26 +531,27 @@ export function tokenPartsOf(rows: readonly OverviewRow[]): { parts: TokenPartTo
let total = 0
let any = false
for (const row of rows) {
- const timeline = row.timeline
- if (timeline === null) continue
- const totals = usageTotalsOf(timeline.cost)
- if (totals === null) continue
- any = true
- const usage: TokenUsage = {
- uncachedInputTokens: totals.input,
- cacheReadTokens: totals.cacheRead,
- cacheWriteTokens: totals.cacheWrite,
- outputTokens: totals.output,
- }
- for (const part of billedParts(timeline.current, null, usage)) {
- if (!Number.isFinite(part.value) || part.value <= 0) continue
- total += part.value
- // Widened honestly: a Map get can miss at runtime.
- const sumsGet: Map = sums
- const prev = sumsGet.get(part.key)
- sums.set(part.key, prev === undefined
- ? { key: part.key, color: part.color, value: part.value }
- : { ...prev, value: prev.value + part.value })
+ for (const member of row.family) {
+ if (member === null) continue
+ const totals = usageTotalsOf(member.cost)
+ if (totals === null) continue
+ any = true
+ const usage: TokenUsage = {
+ uncachedInputTokens: totals.input,
+ cacheReadTokens: totals.cacheRead,
+ cacheWriteTokens: totals.cacheWrite,
+ outputTokens: totals.output,
+ }
+ for (const part of billedParts(member.current, null, usage)) {
+ if (!Number.isFinite(part.value) || part.value <= 0) continue
+ total += part.value
+ // Widened honestly: a Map get can miss at runtime.
+ const sumsGet: Map = sums
+ const prev = sumsGet.get(part.key)
+ sums.set(part.key, prev === undefined
+ ? { key: part.key, color: part.color, value: part.value }
+ : { ...prev, value: prev.value + part.value })
+ }
}
}
if (!any) return null
@@ -508,7 +596,9 @@ export function kpisOf(
book: ModelBook | null | undefined,
currency: CostCurrency,
): OverviewKpis {
- const usage = mergeCostUsage(...rows.map(row => row.timeline?.cost))
+ // The KPI band's billed volume and spend ride the same family scope as the
+ // cards and the chart: each row's subtree-merged usage.
+ const usage = mergeCostUsage(...rows.map(row => row.familyCost))
const totals = usageTotalsOf(usage)
let turns = 0
let toolCalls = 0
@@ -518,12 +608,11 @@ export function kpisOf(
let costSessions = 0
let usageSessions = 0
for (const row of rows) {
- const cost = row.timeline?.cost
// Each qualifying sub-line counts the sessions its own figure covers: a
// session with usage but no book rates feeds the cache-hit rate while
// pricing to nothing.
- if (estimateSessionCost(cost, book, currency) !== null) costSessions++
- if (usageTotalsOf(cost) !== null) usageSessions++
+ if (estimateSessionCost(row.familyCost, book, currency) !== null) costSessions++
+ if (usageTotalsOf(row.familyCost) !== null) usageSessions++
turns += turnsOf(row.timeline)
const timing = row.timeline?.timing
toolCalls += timing?.toolCalls ?? 0
@@ -563,7 +652,8 @@ export interface DayTotals {
/**
* Merge every row's daily ledger into one — the heatmap's and the usage
- * chart's data. A session counts toward a day only when its own entry
+ * chart's data (each row's ledger is already its family's merge, see
+ * rowsOfSnapshot). A row counts toward a day only when its family entry
* carries activity, mirroring the day filter's predicate, so the cell's
* tooltip previews the click; a zeroed entry is skipped whole. Each day's
* pricing records merge into one SessionCostUsage and price off the SAME
diff --git a/tests/client/components/overviewCard.spec.ts b/tests/client/components/overviewCard.spec.ts
index a563014..ad74205 100644
--- a/tests/client/components/overviewCard.spec.ts
+++ b/tests/client/components/overviewCard.spec.ts
@@ -7,7 +7,7 @@ import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { makeOverviewCard } from '../../../src/client/components/overviewCard'
import type { OverviewRow } from '../../../src/client/overview'
-import type { ContextActivity, ContextTimeline } from '../../../src/shared/types'
+import type { ContextActivity, ContextTimeline, SessionCostUsage } from '../../../src/shared/types'
import { click, makeKit, mount, query, queryAll, text } from '../helpers/kit'
const kit = makeKit()
@@ -23,21 +23,25 @@ function rowOf(over: Partial = {}): OverviewRow {
current: false,
timeline: null,
activity: null,
+ family: [over.timeline ?? null],
+ familyCost: null,
...over,
}
}
+const FAMILY_COST: SessionCostUsage = { deepseek: { m: { peak: { uncached: 1000, cacheRead: 500, cacheWrite: 0, output: 250 } } } }
+
const TIMELINE = {
current: { system: 100, tools: 50, user: 30, inject: 10, skill: 10, assistant: 200, tool: 100, total: 500 },
contextWindow: 1000,
requests: [{}, {}],
counts: { turns: 9, steps: 12, injects: 1, compactions: 0, prunes: 0 },
- cost: { deepseek: { m: { peak: { uncached: 1000, cacheRead: 500, cacheWrite: 0, output: 250 } } } },
+ cost: FAMILY_COST,
} as unknown as ContextTimeline
describe('OverviewCard', () => {
test('a folded session renders the donut, occupancy, and the three figures', async () => {
- const m = await mount(h(Card, { row: rowOf({ timeline: TIMELINE }), costLabel: '$0.02', now: NOW, onOpen: () => {} }))
+ const m = await mount(h(Card, { row: rowOf({ timeline: TIMELINE, familyCost: FAMILY_COST }), costLabel: '$0.02', now: NOW, onOpen: () => {} }))
const card = query(m.container, 'button.lc-ov-session')
assert.ok(!card.className.includes('lc-ov-session-current'))
assert.equal(query(m.container, '.lc-ov-session-title').textContent, 'refactor the parser')
@@ -45,10 +49,10 @@ describe('OverviewCard', () => {
// The mini donut: center carries the current total and the window share.
assert.equal(query(m.container, '.lc-donut-center b').textContent, '500')
assert.equal(query(m.container, '.lc-donut-center span').textContent, '50.0%')
- // The three mini stats: turns+steps lead, then total billed, then cost.
+ // The three mini stats: turns+steps lead, then the team's billed, then its cost.
const labels = queryAll(m.container, '.lc-ov-mini-label').map(el => el.textContent)
const values = queryAll(m.container, '.lc-ov-mini-value').map(el => el.textContent)
- assert.deepEqual(labels, ['Turns', 'Total', 'Cost'])
+ assert.deepEqual(labels, ['Turns', 'Team Usage', 'Team Cost'])
assert.deepEqual(values, ['9 turns · 12 steps', '1.8k', '$0.02'])
await m.unmount()
})
@@ -170,7 +174,7 @@ describe('OverviewCard', () => {
const ZhCard = makeOverviewCard(makeKit('zh'))
const m = await mount(h(ZhCard, { row: rowOf({ timeline: TIMELINE }), costLabel: '¥0.13', now: NOW, onOpen: () => {} }))
const labels = queryAll(m.container, '.lc-ov-mini-label').map(el => el.textContent)
- assert.deepEqual(labels, ['轮次', '总用量', '费用'])
+ assert.deepEqual(labels, ['轮次', '团队用量', '团队费用'])
const values = queryAll(m.container, '.lc-ov-mini-value').map(el => el.textContent)
assert.equal(values[0], '9 轮 12 步')
assert.equal(query(m.container, '.lc-ov-session-time').textContent, '3 小时前')
diff --git a/tests/client/overview.spec.ts b/tests/client/overview.spec.ts
index 7501509..1440833 100644
--- a/tests/client/overview.spec.ts
+++ b/tests/client/overview.spec.ts
@@ -32,7 +32,7 @@ import {
} from '../../src/client/overview'
import type { ClientCtx } from '../../src/client/services'
import type { ContextActivity, ContextTimeline, SessionCostUsage } from '../../src/shared/types'
-import { priceIndexOf } from '../../src/client/cost'
+import { mergeCostUsage, priceIndexOf } from '../../src/client/cost'
/** The minimal wire-valid timeline head, overridable per case. */
function timelineOf(over: Record = {}): Record {
@@ -71,6 +71,8 @@ function rowOf(over: Partial = {}): OverviewRow {
current: false,
timeline: null,
activity: null,
+ family: [over.timeline ?? null],
+ familyCost: null,
...over,
}
}
@@ -118,7 +120,7 @@ describe('rowsOfSnapshot', () => {
const [b, c, d, e] = rows
assert.deepEqual(b, {
id: 'b', title: 'shown', updatedAt: 5, running: true, current: true,
- timeline: null, activity: null,
+ timeline: null, activity: null, family: [null], familyCost: null,
})
assert.equal(c.title, 'titled')
assert.equal(c.current, false)
@@ -176,10 +178,9 @@ describe('rowsOfSnapshot', () => {
})
describe('billedOf / turnsOf', () => {
- test('billed sums the cost buckets; absent cost reads null', () => {
- assert.equal(billedOf(null), null)
- assert.equal(billedOf({ current: 1 } as never), null)
- assert.equal(billedOf({ cost: COST } as never), 200)
+ test('billed sums the team’s merged cost buckets; absent cost reads null', () => {
+ assert.equal(billedOf(rowOf()), null)
+ assert.equal(billedOf(rowOf({ familyCost: COST })), 200)
})
test('turns prefer the precomputed count and fall back to the records', () => {
@@ -246,7 +247,7 @@ describe('filterRows', () => {
})
describe('sortRows', () => {
- const cheap = rowOf({ id: 'cheap', updatedAt: 1, timeline: { current: { total: 50 }, cost: COST } as unknown as ContextTimeline })
+ const cheap = rowOf({ id: 'cheap', updatedAt: 1, timeline: { current: { total: 50 }, cost: COST } as unknown as ContextTimeline, familyCost: COST })
const dear = rowOf({
id: 'dear',
updatedAt: 2,
@@ -254,6 +255,7 @@ describe('sortRows', () => {
current: { total: 900 },
cost: { deepseek: { m: { peak: { uncached: 1000, cacheRead: 0, cacheWrite: 0, output: 0 } } } },
} as unknown as ContextTimeline,
+ familyCost: { deepseek: { m: { peak: { uncached: 1000, cacheRead: 0, cacheWrite: 0, output: 0 } } } },
})
const plain = rowOf({ id: 'plain', updatedAt: 3 })
@@ -446,6 +448,7 @@ describe('kpisOf', () => {
requests: [],
timing: { wallMs: 90_000, ttftMs: 1_000, genMs: 30_000, calls: 4, toolsMs: 20_000, toolCalls: 7, tools: {} },
} as unknown as ContextTimeline,
+ familyCost: COST,
}),
rowOf({
timeline: {
@@ -476,13 +479,14 @@ describe('kpisOf', () => {
test('a session with usage the book cannot price feeds the cache-hit rate but prices to nothing', () => {
const rows = [
- rowOf({ timeline: { cost: COST, current: { system: 100, tools: 50, user: 30, inject: 10, skill: 10, assistant: 200, tool: 100, total: 500 }, requests: [] } as unknown as ContextTimeline }),
+ rowOf({ timeline: { cost: COST, current: { system: 100, tools: 50, user: 30, inject: 10, skill: 10, assistant: 200, tool: 100, total: 500 }, requests: [] } as unknown as ContextTimeline, familyCost: COST }),
rowOf({
timeline: {
cost: { openai: { 'gpt-5': { peak: { uncached: 10, cacheRead: 5, cacheWrite: 1, output: 2 } } } },
current: { system: 10, tools: 0, user: 0, inject: 0, skill: 0, assistant: 0, tool: 0, total: 10 },
requests: [],
} as unknown as ContextTimeline,
+ familyCost: { openai: { 'gpt-5': { peak: { uncached: 10, cacheRead: 5, cacheWrite: 1, output: 2 } } } },
}),
rowOf(),
]
@@ -593,6 +597,105 @@ describe('rowsOfSnapshot: subagent rows', () => {
})
})
+describe('rowsOfSnapshot: the family fold', () => {
+ const COST2: SessionCostUsage = { zhipuai: { m: { peak: { uncached: 300, cacheRead: 0, cacheWrite: 0, output: 0 } } } }
+ /** A minimal head carrying the given billed usage (and a user-only composition). */
+ function billedHead(tokens: number, cost?: SessionCostUsage): Record {
+ return timelineOf({
+ current: { system: 0, tools: 0, user: tokens, inject: 0, skill: 0, assistant: 0, tool: 0, total: tokens },
+ ...(cost !== undefined ? { cost } : {}),
+ })
+ }
+
+ test('every descendant level folds into the root row’s family — members, cost, and days merge', () => {
+ const rows = rowsOfSnapshot(snapshotOf([
+ ['root', {
+ displayTitle: 'root',
+ projectionValues: {
+ contextTimeline: billedHead(100, COST),
+ contextActivity: { days: { '2026-09-16': { tokens: 5, requests: 1, cost: COST } } },
+ },
+ }],
+ ['kid', {
+ origin: 'subagent', parentId: 'root',
+ projectionValues: {
+ contextTimeline: billedHead(50, COST2),
+ contextActivity: { days: { '2026-09-16': { tokens: 7, requests: 2, cost: COST2 } } },
+ },
+ }],
+ ['grand', {
+ origin: 'subagent', parentId: 'kid',
+ projectionValues: {
+ contextTimeline: billedHead(25),
+ contextActivity: { days: { '2026-09-16': { tokens: 4, requests: 1 }, '2026-09-15': { tokens: 3, requests: 1 } } },
+ },
+ }],
+ ['stranger', { displayTitle: 'unrelated' }],
+ ]))
+ assert.ok(rows !== null)
+ assert.deepEqual(rows.map(r => r.id), ['root', 'stranger'], 'subagent-origin rows stay unlisted')
+ const [root, stranger] = rows
+ assert.equal(root.family.length, 3, 'self + kid + grand — all levels')
+ // COST (200) + COST2 (300) merge; the grandchild carried no cost.
+ assert.equal(billedOf(root), 500)
+ assert.equal(billedOf(stranger), null)
+ // The daily ledgers merge across the subtree, pricing records included.
+ assert.equal(root.activity?.days['2026-09-16'].tokens, 16)
+ assert.equal(root.activity?.days['2026-09-16'].requests, 4)
+ assert.deepEqual(root.activity?.days['2026-09-16'].cost, mergeCostUsage(COST, COST2))
+ assert.equal(root.activity?.days['2026-09-15'].tokens, 3)
+ assert.equal(root.activity?.days['2026-09-15'].cost, undefined)
+ // The composed split proportions each member's OWN composition: the root's
+ // all-user 160 billed input + output 40, the kid's all-user 300 — user 460 / output 40.
+ const parts = tokenPartsOf([root])
+ assert.deepEqual(parts, {
+ total: 500,
+ parts: [
+ { key: 'user', color: 'var(--color-green-500)', value: 460 },
+ { key: 'output', color: 'var(--color-pink-500)', value: 40 },
+ ],
+ })
+ })
+
+ test('an unlisted subtree (a subagent whose root never lists) leaks nowhere', () => {
+ const rows = rowsOfSnapshot(snapshotOf([
+ ['root', { displayTitle: 'root', projectionValues: { contextTimeline: billedHead(1, COST) } }],
+ ['ghost', { origin: 'subagent', parentId: 'missing', projectionValues: { contextTimeline: billedHead(999, COST2) } }],
+ ]))
+ assert.ok(rows !== null)
+ assert.equal(rows.length, 1)
+ assert.equal(billedOf(rows[0]), 200, 'the ghost subtree folds into no row')
+ })
+
+ test('a blank placeholder’s subtree stays out, and a lineage cycle cannot loop the walk', () => {
+ const rows = rowsOfSnapshot(snapshotOf([
+ // The reachable cycle: root points at p and p points back — the walk
+ // must cut at the revisited id (root counts its family, never itself twice).
+ ['root', { displayTitle: 'root', parentId: 'p', projectionValues: { contextTimeline: billedHead(1, COST) } }],
+ ['p', { origin: 'subagent', parentId: 'root', projectionValues: { contextTimeline: billedHead(2, COST2) } }],
+ ['blank', { blank: true, parentId: 'root', projectionValues: { contextTimeline: billedHead(9, COST2) } }],
+ ['under-blank', { origin: 'subagent', parentId: 'blank', projectionValues: { contextTimeline: billedHead(9, COST2) } }],
+ ]))
+ assert.ok(rows !== null)
+ assert.equal(rows.length, 1)
+ assert.equal(rows[0].family.length, 2, 'root + p once; the blank is no agent and its child never links through')
+ assert.equal(billedOf(rows[0]), 500, 'COST + COST2 — the cycle’s revisit adds nothing twice')
+ })
+
+ test('hostile descendant projections degrade to null members, never a throw', () => {
+ const rows = rowsOfSnapshot(snapshotOf([
+ ['root', { displayTitle: 'root', projectionValues: { contextTimeline: billedHead(1, COST) } }],
+ ['kid', { origin: 'subagent', parentId: 'root', projectionValues: { contextTimeline: 7, contextActivity: 'x' } }],
+ ['junk', 5 as unknown as Record],
+ ]))
+ assert.ok(rows !== null)
+ assert.equal(rows[0].family.length, 2)
+ assert.equal(rows[0].family[1], null, 'the hostile head sanitizes to null in place')
+ assert.equal(rows[0].activity, null, 'no member carried a ledger → the family has none')
+ assert.equal(billedOf(rows[0]), 200)
+ })
+})
+
describe('workspacesSnapshotOf', () => {
test('a non-function or throwing seat reads null; the raw snapshot passes through', () => {
assert.equal(workspacesSnapshotOf({}), null)