From ef8e1d24dddade1d3a9130dacd9877c19f205354 Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Wed, 7 Oct 2026 13:12:52 +0800 Subject: [PATCH] feat(insights): read every card and aggregate at the team scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard's row IS the team now: each listed session folds its WHOLE subagent subtree into itself (all levels, mirroring the Context tab's cost fold) — subagent-origin sessions never list, so nothing counts twice. The session card's billed tokens and priced cost (now labeled 团队用量 / 团队费用), the KPI band's volume and spend, the last-7-days usage chart, and the heatmap all read the family scope: the subtree's cost buckets merge per row, the daily ledgers merge day by day, and the composition split proportions each member's OWN composition instead of the root's. --- src/client/components/overviewCard.tsx | 9 +- src/client/components/overviewPanel.tsx | 6 +- src/client/i18n.ts | 8 +- src/client/overview.ts | 166 ++++++++++++++----- tests/client/components/overviewCard.spec.ts | 16 +- tests/client/overview.spec.ts | 119 ++++++++++++- 6 files changed, 264 insertions(+), 60 deletions(-) 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)