feat(context): fold the range off the step-start daily ledger
Context Insights now reads contextActivity for every range-scoped figure: a step's settlement, tool run, and wall time book on the local day its step/start fired, so a step crossing midnight stays on its start day. The KPI band, Token Stats, and session selection share the daily ledger; turns, tool calls, and step wall time join each day, and contextActivity's stateVersion moves to 5 so cached rows cold-refold. The Activity heatmap drops the Steps metric for daily token volume; Timing Stats stays session-lifetime and is labeled as such. Refs #172
This commit is contained in:
1 parent
16ecd2af38
commit
026ffca599
19 files changed
+644
-124
No files matched your search
@@ -63,11 +63,11 @@ Click **Context Insights / 上下文洞察** in the sidebar's panel list — the
|
||||
|
||||
| Section | The question it answers |
|
||||
| --- | --- |
|
||||
| **KPI band** | How much am I using — sessions, billed tokens, estimated cost, and cache-hit rate over the picked range (7d / 30d / all). |
|
||||
| **KPI band** | How much am I using — sessions, billed tokens, estimated cost, cache-hit rate, turns, tool runs, and active time over the picked range (7d / 30d / all), each figure folded by the day its step started. |
|
||||
| **7-day usage chart** | When did I spend what — each of the last 7 days' billed tokens and estimated cost as a bar pair, folded per turn off the sessions' own request times (not the session's last-active day). |
|
||||
| **Activity heatmap** | When do I actually work — the last 8 weeks of daily billed tokens; click a day to filter the sessions that were active on it. |
|
||||
| **Settings entry** | One row under the heatmap — the same guarded jump to this plugin's preferences as the Context tab's Plugin Info card. |
|
||||
| **Context Composition** | Where the context windows went, summed over the range's sessions. |
|
||||
| **Context Composition** | Where the picked range's billed tokens went, split by the Context tab's composition categories. |
|
||||
| **Session cards** | Each session's profile: composition ring, billed tokens, turns, cost, and its workspace-group / project breadcrumb — sorted by recency, tokens, or context size, searchable, grouped by workspace. A card click opens the session. |
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -63,11 +63,11 @@ dsh plugin --profile web update dsh-context@latest
|
||||
|
||||
| 区块 | 解答的问题 |
|
||||
| --- | --- |
|
||||
| **KPI 栏** | 我的使用量如何——所选时间范围(7 天 / 30 天 / 全部)内的会话数、计费 Token、预估成本和缓存命中率。 |
|
||||
| **KPI 栏** | 我的使用量如何——所选时间范围(7 天 / 30 天 / 全部)内的会话数、计费 Token、预估成本、缓存命中率、轮次、工具执行和活跃时长,均按 step 发起日归因。 |
|
||||
| **7 天使用量图表** | 每天的花费和用量如何——最近 7 天每天的计费 Token 和预估成本以成对柱状图展示,按会话自身各请求的时间汇总(而不是按会话最后活跃日期)。 |
|
||||
| **活动热力图** | 我实际在什么时候工作——最近 8 周每日的计费 Token;点击某天即可筛选当天活跃的会话。 |
|
||||
| **设置入口** | 热力图下方的一行快捷入口——与 Context 标签页 Plugin Info 卡片中通往此插件偏好设置的受保护跳转相同。 |
|
||||
| **Context Composition** | 所选时间范围内各会话的上下文窗口都由什么组成。 |
|
||||
| **Context Composition** | 所选时间范围内的计费 Token 按 Context 标签页的组成分类拆分。 |
|
||||
| **会话卡片** | 每个会话的概况:组成环形图、计费 Token、轮次、成本以及工作区分组 / 项目路径;可按最近活动、Token 数或上下文大小排序、搜索、按工作区分组。点击卡片即可打开会话。 |
|
||||
|
||||
## 📊 Context 标签页
|
||||
|
||||
@@ -59,6 +59,7 @@ Last verified: **2026-10-20** (dsh-context `0.73.3`, dsh `0.1.7-rc.2` / `0.2.0-r
|
||||
| `src/client/reserve.ts` | `packages/compaction/compaction-basic/src/config.ts` — only the shipped defaults (`thresholdRatio` 0.8, `headroomTokens` 65536): preset realms make a deployment's policy unreadable; when `contextWindow − reservedOutput − headroomTokens` is non-positive the backend refuses (`TargetPressureConfigError`), so the bar paints no masks |
|
||||
|
||||
- **Limitation — declared divergence:** the timing card's wait/generation boundary opens at the first OBSERVABLE instant (the first token, or an earlier `block-start` marker the token packing could not stamp), not at the meter's first-token anchor — so marker-tiled decode buckets and the split never double-count one window.
|
||||
- **Limitation — daily-ledger attribution:** `contextTimeline.cost` prices at the settlement instant while the daily ledger prices at the step-initiation instant, so a tariff-boundary step can split its peak/off buckets differently between the lifetime total and the KPI band; the ledger also retains 400 days (`MAX_KEPT_DAYS` in `src/host/activity.ts`).
|
||||
|
||||
## 5 · Client surface
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
* column where a month begins — the window's opening column always labeled —
|
||||
* and a Less→More key for the depth steps) over the merged daily
|
||||
* ledger (overview.ts). Cell depth is the selected metric's share of the
|
||||
* window's maximum — distinct active sessions or settlement steps; a session
|
||||
* can spread its steps thinly across many days while another day stacks a
|
||||
* run, so each metric scales on its OWN maximum — in four steps; a day with data is a button whose click
|
||||
* window's maximum — distinct active sessions or billed token volume; a day
|
||||
* can run one long session while another stacks many, so each metric scales
|
||||
* on its OWN maximum — in four steps; a day with data is a button whose click
|
||||
* pins the session list to that day (click again to release). Cells carry
|
||||
* their tip (the date, the day's active sessions, and its steps) in
|
||||
* their tip (the date, the day's active sessions, and its tokens) in
|
||||
* `data-lc-tip`, served by the page's portaled hover tip (hoverTip.tsx) —
|
||||
* instant on hover, where a native `title` lags a second behind. The grid is computed from the injected `today` key, so
|
||||
* the layout is deterministic in tests and follows the browser's local calendar at runtime. */
|
||||
@@ -17,15 +17,15 @@ import { dayKeyOf, shiftDayKey, sundayOfWeek } from '../../shared/days'
|
||||
import { type DayTotals } from '../overview'
|
||||
import type { ViewKit } from '../viewkit'
|
||||
|
||||
/** The heatmap's depth metric: the day's distinct active sessions or its settlement steps. */
|
||||
export type HeatMetric = 'sessions' | 'steps'
|
||||
/** The heatmap's depth metric: the day's distinct active sessions or its billed tokens. */
|
||||
export type HeatMetric = 'sessions' | 'tokens'
|
||||
|
||||
export interface HeatmapProps {
|
||||
/** Merged ledger (day key → that day's figures). */
|
||||
days: Record<string, DayTotals>
|
||||
/** Columns to draw (default 8 — two months). */
|
||||
weeks?: number
|
||||
/** The metric pricing the cells' depth (default steps). */
|
||||
/** The metric pricing the cells' depth (default tokens). */
|
||||
metric?: HeatMetric
|
||||
/** The pinned day key, when the list is filtered to a day. */
|
||||
selected?: string | null
|
||||
@@ -79,16 +79,16 @@ function levelOf(tokens: number, max: number): number {
|
||||
}
|
||||
|
||||
export function makeHeatmap(kit: ViewKit): (props: HeatmapProps) => ReactElement {
|
||||
const { t } = kit
|
||||
const { t, fmt } = kit
|
||||
return function Heatmap(props: HeatmapProps): ReactElement {
|
||||
const weeks = props.weeks ?? 8
|
||||
const metric = props.metric ?? 'steps'
|
||||
const metric = props.metric ?? 'tokens'
|
||||
const columns = gridOf(props.today, weeks)
|
||||
if (columns === null) return <div className="lc-empty">{t('ov.heat.empty')}</div>
|
||||
// Join the ledger onto the grid and price the depth scale. Widened: a day-key read can miss at runtime.
|
||||
const byKey: Record<string, DayTotals | undefined> = props.days
|
||||
let maxSessions = 0
|
||||
let maxSteps = 0
|
||||
let maxTokens = 0
|
||||
let any = false
|
||||
for (const column of columns) {
|
||||
for (const cell of column) {
|
||||
@@ -96,16 +96,16 @@ export function makeHeatmap(kit: ViewKit): (props: HeatmapProps) => ReactElement
|
||||
if (entry === undefined || (entry.tokens <= 0 && entry.requests <= 0)) continue
|
||||
cell.entry = entry
|
||||
any = true
|
||||
// Independent maxima: sessions and steps shape the window's days
|
||||
// differently (one long session drops a step a day; another day
|
||||
// stacks a run), so neither metric scales off the other's peak.
|
||||
// Independent maxima: sessions and tokens shape the window's days
|
||||
// differently (one long session drops a block of tokens a day; another
|
||||
// day stacks many sessions), so neither metric scales off the other's peak.
|
||||
if (entry.sessions > maxSessions) maxSessions = entry.sessions
|
||||
if (entry.requests > maxSteps) maxSteps = entry.requests
|
||||
if (entry.tokens > maxTokens) maxTokens = entry.tokens
|
||||
}
|
||||
}
|
||||
if (!any) return <div className="lc-empty">{t('ov.heat.empty')}</div>
|
||||
const max = metric === 'sessions' ? maxSessions : maxSteps
|
||||
const metricValueOf = (entry: DayTotals): number => (metric === 'sessions' ? entry.sessions : entry.requests)
|
||||
const max = metric === 'sessions' ? maxSessions : maxTokens
|
||||
const metricValueOf = (entry: DayTotals): number => (metric === 'sessions' ? entry.sessions : entry.tokens)
|
||||
// Keyed BY the Sunday-first row index (0=Sun..6=Sat) — the label sits on
|
||||
// the row it names, and no row arithmetic can shift it.
|
||||
const weekdayLabels: Record<number, string> = { 1: t('ov.heat.wd.1'), 3: t('ov.heat.wd.3'), 5: t('ov.heat.wd.5') }
|
||||
@@ -135,7 +135,7 @@ export function makeHeatmap(kit: ViewKit): (props: HeatmapProps) => ReactElement
|
||||
return <span key={cell.key} className="lc-heat-cell lc-heat-0" data-lc-tip={cell.key} />
|
||||
}
|
||||
const picked = props.selected === cell.key
|
||||
const label = `${cell.key}\n${t('ov.heat.sessions', { n: cell.entry.sessions })}\n${t('ov.heat.steps', { n: cell.entry.requests })}`
|
||||
const label = `${cell.key}\n${t('ov.heat.sessions', { n: cell.entry.sessions })}\n${t('ov.heat.tokens', { n: fmt(cell.entry.tokens) })}`
|
||||
return (
|
||||
<button
|
||||
key={cell.key}
|
||||
|
||||
@@ -57,8 +57,8 @@ const RANGES: readonly OverviewRange[] = ['today', '7d', '30d', 'all']
|
||||
const SORTS: readonly OverviewSort[] = ['recent', 'tokens', 'context']
|
||||
/** The coverage scopes the sessions feed serves, in menu order. */
|
||||
const COVERAGE: readonly OverviewCoverage[] = ['all', 'active', 'archived']
|
||||
/** The heatmap's depth metrics, in toggle order (steps is the default). */
|
||||
const METRICS: readonly HeatMetric[] = ['sessions', 'steps']
|
||||
/** The heatmap's depth metrics, in toggle order (tokens is the default). */
|
||||
const METRICS: readonly HeatMetric[] = ['sessions', 'tokens']
|
||||
|
||||
export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: OverviewPanelProps) => ReactElement | null {
|
||||
const { t, fmtDuration } = kit
|
||||
@@ -97,7 +97,7 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
|
||||
const [skill, setSkill] = useState<string | null>(null)
|
||||
const [skillSort, setSkillSort] = useState<SkillSort>('loads')
|
||||
const [sort, setSort] = useState<OverviewSort>('recent')
|
||||
const [metric, setMetric] = useState<HeatMetric>('steps')
|
||||
const [metric, setMetric] = useState<HeatMetric>('tokens')
|
||||
const [page, setPage] = useState(0)
|
||||
const { zoneProps, bubble } = useHoverTip()
|
||||
|
||||
@@ -163,7 +163,7 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
|
||||
sort,
|
||||
)
|
||||
const paged = pageOf(visible, page)
|
||||
const kpi = kpisOf(ranged, book, currency)
|
||||
const kpi = kpisOf(ranged, book, currency, scope)
|
||||
const days = aggregateDays(covered, book, currency)
|
||||
const openOne = (id: string): void => {
|
||||
// The view owner's verb navigates the center column back to the
|
||||
|
||||
@@ -180,6 +180,7 @@ export function makeStatsTiming(kit: ViewKit, Donut: (props: DonutProps) => Reac
|
||||
<div className="lc-card lc-col-donut flex-1 min-w-[min(360px,100%)]">
|
||||
<div className="lc-card-title">
|
||||
<span className="lc-card-title-text">{t('timing.title')}</span>
|
||||
<span className="lc-card-sub">{t('timing.scope')}</span>
|
||||
{tps !== null && (
|
||||
<span className="lc-card-rate" title={t('timing.tpsTip')}>{t('timing.tps', { tps })}</span>
|
||||
)}
|
||||
|
||||
@@ -60,6 +60,7 @@ export const DICT_EN = {
|
||||
'stats.costOut': 'Output',
|
||||
'stats.costUnavailable': 'Model prices are unavailable (retrying automatically) — the cost cannot be estimated yet.',
|
||||
'timing.title': 'Timing Stats',
|
||||
'timing.scope': 'session lifetime',
|
||||
'timing.total': 'Active Time',
|
||||
'timing.ttft': 'TTFT',
|
||||
'timing.gen': 'LLM Gen',
|
||||
@@ -524,8 +525,8 @@ export const DICT_EN = {
|
||||
'ov.heat.more': 'More',
|
||||
'ov.heat.metric': 'Metric',
|
||||
'ov.heat.metric.sessions': 'Sessions',
|
||||
'ov.heat.metric.steps': 'Steps',
|
||||
'ov.heat.steps': '{n} steps',
|
||||
'ov.heat.metric.tokens': 'Tokens',
|
||||
'ov.heat.tokens': '{n} tokens',
|
||||
'ov.heat.wd.1': 'Mon',
|
||||
'ov.heat.wd.3': 'Wed',
|
||||
'ov.heat.wd.5': 'Fri',
|
||||
|
||||
@@ -57,6 +57,7 @@ export const DICT_ZH = {
|
||||
'stats.costOut': '输出',
|
||||
'stats.costUnavailable': '模型价格暂不可用(自动重试中),费用暂无法估算。',
|
||||
'timing.title': '耗时统计',
|
||||
'timing.scope': '会话累计',
|
||||
'timing.total': '活跃时长',
|
||||
'timing.ttft': '模型等待',
|
||||
'timing.gen': '模型生成',
|
||||
@@ -499,7 +500,7 @@ export const DICT_ZH = {
|
||||
'ov.kpi.archived': '已归档会话',
|
||||
'ov.kpi.ofTotal': '共 {n} 个',
|
||||
'ov.kpi.inclArchived': '其中已归档 {n} 个',
|
||||
'ov.kpi.tokens': '累计消耗',
|
||||
'ov.kpi.tokens': 'Token 消耗',
|
||||
'ov.kpi.sessionsSub': '含 {n} 个会话',
|
||||
'ov.kpi.pricedSub': '已计价 {n}/{total} 个会话',
|
||||
'ov.kpi.toolSub': '工具执行 {dur}',
|
||||
@@ -523,8 +524,8 @@ export const DICT_ZH = {
|
||||
'ov.heat.more': '多',
|
||||
'ov.heat.metric': '指标',
|
||||
'ov.heat.metric.sessions': '会话',
|
||||
'ov.heat.metric.steps': '步数',
|
||||
'ov.heat.steps': '{n} 步',
|
||||
'ov.heat.metric.tokens': 'Token 消耗',
|
||||
'ov.heat.tokens': '{n} token',
|
||||
'ov.heat.wd.1': '星期一',
|
||||
'ov.heat.wd.3': '星期三',
|
||||
'ov.heat.wd.5': '星期五',
|
||||
|
||||
+131
-24
@@ -34,6 +34,8 @@ export interface OverviewRow {
|
||||
activity: ContextActivity | null
|
||||
/** Every subtree member's sanitized head, self first; each part split proportions its own composition. */
|
||||
family: (ContextTimeline | null)[]
|
||||
/** Every subtree member's daily ledger, self first and aligned with {@link family}. */
|
||||
familyActivity?: (ContextActivity | null)[]
|
||||
/** The subtree's merged billed usage; null when nothing was billed. */
|
||||
familyCost: SessionCostUsage | null
|
||||
}
|
||||
@@ -217,6 +219,7 @@ export function rowsOfSnapshot(snapshot: unknown, workspaces?: unknown): Overvie
|
||||
}
|
||||
}
|
||||
row.family = timelines
|
||||
row.familyActivity = activities
|
||||
row.familyCost = mergeCostUsage(...timelines.map(t => t?.cost))
|
||||
row.activity = mergeActivity(activities)
|
||||
}
|
||||
@@ -233,8 +236,13 @@ export function coveredRowsOf(rows: readonly OverviewRow[], coverage: OverviewCo
|
||||
return coverage === 'all' ? rows : rows.filter(row => row.archived === (coverage === 'archived'))
|
||||
}
|
||||
|
||||
/** Merge several sessions' daily ledgers into one family's: per day the tokens
|
||||
* and requests sum, the pricing records merge, and the skill tables merge name by name (loads sum, last-load takes the max). */
|
||||
/** Sum a pair of additive-optional day figures; both absent stays absent. */
|
||||
function addDayField(a: number | undefined, b: number | undefined): number | undefined {
|
||||
return a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0)
|
||||
}
|
||||
|
||||
/** Merge several sessions' daily ledgers into one family's: per day the counters and timing
|
||||
* totals sum, the pricing records merge, and the skill tables merge name by name. */
|
||||
function mergeActivity(members: (ContextActivity | null)[]): ContextActivity | null {
|
||||
const days: ContextActivity['days'] = {}
|
||||
const byKey: Record<string, ContextActivity['days'][string] | undefined> = days
|
||||
@@ -250,11 +258,23 @@ function mergeActivity(members: (ContextActivity | null)[]): ContextActivity | n
|
||||
tokens: entry.tokens,
|
||||
requests: entry.requests,
|
||||
...(entry.cost !== undefined ? { cost: entry.cost } : {}),
|
||||
...(entry.turns !== undefined ? { turns: entry.turns } : {}),
|
||||
...(entry.toolCalls !== undefined ? { toolCalls: entry.toolCalls } : {}),
|
||||
...(entry.toolsMs !== undefined ? { toolsMs: entry.toolsMs } : {}),
|
||||
...(entry.wallMs !== undefined ? { wallMs: entry.wallMs } : {}),
|
||||
...(entry.skills !== undefined ? { skills: entry.skills } : {}),
|
||||
}
|
||||
} else {
|
||||
prev.tokens += entry.tokens
|
||||
prev.requests += entry.requests
|
||||
const turns = addDayField(prev.turns, entry.turns)
|
||||
const toolCalls = addDayField(prev.toolCalls, entry.toolCalls)
|
||||
const toolsMs = addDayField(prev.toolsMs, entry.toolsMs)
|
||||
const wallMs = addDayField(prev.wallMs, entry.wallMs)
|
||||
if (turns !== undefined) prev.turns = turns
|
||||
if (toolCalls !== undefined) prev.toolCalls = toolCalls
|
||||
if (toolsMs !== undefined) prev.toolsMs = toolsMs
|
||||
if (wallMs !== undefined) prev.wallMs = wallMs
|
||||
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
|
||||
@@ -329,6 +349,21 @@ export function dayRangeWindow(range: DayRange): RangeWindow | null {
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
/** Whether a ledger day key falls inside a resolved window. Day-granular on purpose: a preset's
|
||||
* rolling floor admits its whole local day, because the ledger cannot split one day. */
|
||||
export function dayInScope(key: string, scope: RangeWindow): boolean {
|
||||
const floor = scope.start === null ? null : dayKeyOf(scope.start)
|
||||
const ceiling = scope.end === null ? null : dayKeyOf(scope.end)
|
||||
return (floor === null || key >= floor) && (ceiling === null || key <= ceiling)
|
||||
}
|
||||
|
||||
/** A day that contributed anything: billed tokens, a settlement, a turn, a tool run, step time,
|
||||
* or a skill load. The range selection admits a session on any of these. */
|
||||
function hasActivity(entry: ActivityDay): boolean {
|
||||
return entry.tokens > 0 || entry.requests > 0 || (entry.turns ?? 0) > 0
|
||||
|| (entry.toolCalls ?? 0) > 0 || (entry.wallMs ?? 0) > 0 || entry.skills !== undefined
|
||||
}
|
||||
|
||||
export type OverviewSort = 'recent' | 'tokens' | 'context'
|
||||
|
||||
/** The team card's cumulative billed tokens, or null when nothing was billed; the sort and the card stat share this figure. */
|
||||
@@ -359,9 +394,27 @@ export function filterRows(
|
||||
opts: { scope: RangeWindow; day: string | null; query: string },
|
||||
): OverviewRow[] {
|
||||
const query = opts.query.trim().toLowerCase()
|
||||
const bounded = opts.scope.start !== null || opts.scope.end !== null
|
||||
return rows.filter((row) => {
|
||||
if (opts.scope.start !== null && row.updatedAt < opts.scope.start) return false
|
||||
if (opts.scope.end !== null && row.updatedAt > opts.scope.end) return false
|
||||
// A bounded scope admits a session on either signal: a daily-ledger day inside the window
|
||||
// (a long run with no new prompt still counts), or a last user prompt inside it (a session
|
||||
// touched in the window but billing nothing). The KPI later sums only the ledger days.
|
||||
if (bounded) {
|
||||
let inScope = (opts.scope.start === null || row.updatedAt >= opts.scope.start)
|
||||
&& (opts.scope.end === null || row.updatedAt <= opts.scope.end)
|
||||
if (!inScope) {
|
||||
const days = row.activity?.days
|
||||
if (days !== undefined) {
|
||||
for (const key of Object.keys(days)) {
|
||||
if (dayInScope(key, opts.scope) && hasActivity(days[key])) {
|
||||
inScope = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!inScope) return false
|
||||
}
|
||||
if (opts.day !== null) {
|
||||
const entry = row.activity?.days[opts.day]
|
||||
if (entry === undefined || (entry.tokens <= 0 && entry.requests <= 0)) return false
|
||||
@@ -409,10 +462,7 @@ export type SkillSort = 'loads' | 'recent'
|
||||
* that day, else the scope window admits its days whole. */
|
||||
function skillDayPredicate(opts: { scope: RangeWindow; day: string | null }): (key: string) => boolean {
|
||||
if (opts.day !== null) return key => key === opts.day
|
||||
const { start, end } = opts.scope
|
||||
const floor = start === null ? null : dayKeyOf(start)
|
||||
const ceiling = end === null ? null : dayKeyOf(end)
|
||||
return key => (floor === null || key >= floor) && (ceiling === null || key <= ceiling)
|
||||
return key => dayInScope(key, opts.scope)
|
||||
}
|
||||
|
||||
/** The Insights page's skill card data: every scoped row's family ledger folded
|
||||
@@ -505,6 +555,38 @@ export function usageTotalsOf(usage: SessionCostUsage | null | undefined): Usage
|
||||
return totals
|
||||
}
|
||||
|
||||
export interface WindowTotals {
|
||||
requests: number
|
||||
turns: number
|
||||
toolCalls: number
|
||||
toolsMs: number
|
||||
wallMs: number
|
||||
/** The window's merged pricing records; null when no day carried any. */
|
||||
usage: SessionCostUsage | null
|
||||
}
|
||||
|
||||
/** Fold one daily ledger over a window; every figure rides the ledger's own day keys. */
|
||||
export function windowTotalsOf(activity: ContextActivity | null, scope: RangeWindow): WindowTotals {
|
||||
let requests = 0
|
||||
let turns = 0
|
||||
let toolCalls = 0
|
||||
let toolsMs = 0
|
||||
let wallMs = 0
|
||||
let usage: SessionCostUsage | null = null
|
||||
if (activity !== null) {
|
||||
for (const [key, day] of Object.entries(activity.days)) {
|
||||
if (!dayInScope(key, scope)) continue
|
||||
requests += day.requests
|
||||
turns += day.turns ?? 0
|
||||
toolCalls += day.toolCalls ?? 0
|
||||
toolsMs += day.toolsMs ?? 0
|
||||
wallMs += day.wallMs ?? 0
|
||||
if (day.cost !== undefined) usage = mergeCostUsage(usage, day.cost)
|
||||
}
|
||||
}
|
||||
return { requests, turns, toolCalls, toolsMs, wallMs, usage }
|
||||
}
|
||||
|
||||
/** The range's summed timing totals, folded from the rows' host-folded
|
||||
* whole-session totals exactly as the KPI band folds the scalar figures. The
|
||||
* additive-optional fields sum their carriers only, so a field no row carries stays absent instead of materializing zeros. */
|
||||
@@ -560,15 +642,26 @@ export interface TokenPartTotal {
|
||||
/** The range's billed tokens split by category, folded across the rows'
|
||||
* families: each member's `billedParts` sum by category while its parts total
|
||||
* its own billed figure, so the aggregate total stays the exact merged billed
|
||||
* volume. Null when nothing billed; a non-finite part drops whole. */
|
||||
export function tokenPartsOf(rows: readonly OverviewRow[]): { parts: TokenPartTotal[]; total: number } | null {
|
||||
* volume. With a scope, each member's usage comes from its daily ledger's
|
||||
* admitted days; without one, the lifetime head. Null when nothing billed; a
|
||||
* non-finite part drops whole. */
|
||||
export function tokenPartsOf(
|
||||
rows: readonly OverviewRow[],
|
||||
scope?: RangeWindow,
|
||||
): { parts: TokenPartTotal[]; total: number } | null {
|
||||
const sums = new Map<string, TokenPartTotal>()
|
||||
let total = 0
|
||||
let any = false
|
||||
for (const row of rows) {
|
||||
for (const member of row.family) {
|
||||
for (let index = 0; index < row.family.length; index++) {
|
||||
const member = row.family[index]
|
||||
if (member === null) continue
|
||||
const totals = usageTotalsOf(member.cost)
|
||||
const memberUsage = scope === undefined
|
||||
? member.cost
|
||||
: row.familyActivity === undefined
|
||||
? null
|
||||
: windowTotalsOf(row.familyActivity[index] ?? null, scope).usage
|
||||
const totals = usageTotalsOf(memberUsage)
|
||||
if (totals === null) continue
|
||||
any = true
|
||||
const usage: TokenUsage = {
|
||||
@@ -612,10 +705,11 @@ export function kpisOf(
|
||||
rows: readonly OverviewRow[],
|
||||
book: ModelBook | null | undefined,
|
||||
currency: CostCurrency,
|
||||
scope?: RangeWindow,
|
||||
): OverviewKpis {
|
||||
// Billed volume and spend ride the same family scope as the cards and chart.
|
||||
const usage = mergeCostUsage(...rows.map(row => row.familyCost))
|
||||
const totals = usageTotalsOf(usage)
|
||||
// Every figure folds the SAME daily ledger over the window; the lifetime heads serve only
|
||||
// callers that pass no scope (or rows with no ledger).
|
||||
let usage: SessionCostUsage | null = null
|
||||
let turns = 0
|
||||
let toolCalls = 0
|
||||
let toolsMs = 0
|
||||
@@ -623,17 +717,30 @@ export function kpisOf(
|
||||
let wallMs = 0
|
||||
let costSessions = 0
|
||||
let usageSessions = 0
|
||||
const open = scope === undefined || (scope.start === null && scope.end === null)
|
||||
for (const row of rows) {
|
||||
const windowTotals = scope !== undefined && row.activity !== null ? windowTotalsOf(row.activity, scope) : null
|
||||
const rowUsage = windowTotals !== null ? windowTotals.usage : open ? row.familyCost : null
|
||||
usage = mergeCostUsage(usage, rowUsage)
|
||||
if (windowTotals !== null) {
|
||||
turns += windowTotals.turns
|
||||
toolCalls += windowTotals.toolCalls
|
||||
toolsMs += windowTotals.toolsMs
|
||||
calls += windowTotals.requests
|
||||
wallMs += windowTotals.wallMs
|
||||
} else if (open) {
|
||||
const timing = row.timeline?.timing
|
||||
turns += turnsOf(row.timeline)
|
||||
toolCalls += timing?.toolCalls ?? 0
|
||||
toolsMs += timing?.toolsMs ?? 0
|
||||
calls += timing?.calls ?? 0
|
||||
wallMs += timing?.wallMs ?? 0
|
||||
}
|
||||
// Each sub-line counts the sessions its own figure covers: a session with usage but no book rates feeds the cache-hit rate.
|
||||
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
|
||||
toolsMs += timing?.toolsMs ?? 0
|
||||
calls += timing?.calls ?? 0
|
||||
wallMs += timing?.wallMs ?? 0
|
||||
if (estimateSessionCost(rowUsage, book, currency) !== null) costSessions++
|
||||
if (usageTotalsOf(rowUsage) !== null) usageSessions++
|
||||
}
|
||||
const totals = usageTotalsOf(usage)
|
||||
return {
|
||||
sessions: rows.length,
|
||||
tokens: totals?.total ?? 0,
|
||||
@@ -646,7 +753,7 @@ export function kpisOf(
|
||||
toolsMs,
|
||||
calls,
|
||||
wallMs,
|
||||
tokenParts: tokenPartsOf(rows),
|
||||
tokenParts: tokenPartsOf(rows, scope),
|
||||
timing: timingSumOf(rows),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,10 +590,23 @@ export function activityOf(value: unknown): ContextActivity | null {
|
||||
// The day's skill table (additive-optional): a malformed tally drops just that name.
|
||||
const skills = skillTalliesOf(entry.skills)
|
||||
if (entry.skills !== undefined && skills === undefined) dirty = true
|
||||
// The per-day activity tallies (additive-optional); a malformed figure drops just that field.
|
||||
const turns = nonNegativeOf(entry.turns)
|
||||
const toolCalls = nonNegativeOf(entry.toolCalls)
|
||||
const toolsMs = nonNegativeOf(entry.toolsMs)
|
||||
const wallMs = nonNegativeOf(entry.wallMs)
|
||||
if ((entry.turns !== undefined && turns === undefined)
|
||||
|| (entry.toolCalls !== undefined && toolCalls === undefined)
|
||||
|| (entry.toolsMs !== undefined && toolsMs === undefined)
|
||||
|| (entry.wallMs !== undefined && wallMs === undefined)) dirty = true
|
||||
days[key] = {
|
||||
tokens,
|
||||
requests,
|
||||
...(cost !== undefined ? { cost } : {}),
|
||||
...(turns !== undefined ? { turns } : {}),
|
||||
...(toolCalls !== undefined ? { toolCalls } : {}),
|
||||
...(toolsMs !== undefined ? { toolsMs } : {}),
|
||||
...(wallMs !== undefined ? { wallMs } : {}),
|
||||
...(skills !== undefined ? { skills } : {}),
|
||||
}
|
||||
}
|
||||
@@ -601,6 +614,11 @@ export function activityOf(value: unknown): ContextActivity | null {
|
||||
return dirty ? { days } : data as unknown as ContextActivity
|
||||
}
|
||||
|
||||
/** A readable non-negative figure; anything else is absent, never coerced to zero. */
|
||||
function nonNegativeOf(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
|
||||
}
|
||||
|
||||
/** One day's skill-load table; a name needs a finite count and load instant, and an empty-after-scrubbing record drops whole. */
|
||||
function skillTalliesOf(value: unknown): ActivityDay['skills'] | undefined {
|
||||
const data = asRecord(value)
|
||||
|
||||
+130
-34
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* The `contextActivity` session projection unit — the per-day activity ledger behind the
|
||||
* Context Insights page's heatmap and its last-7-days usage chart.
|
||||
* Context Insights page's range-scoped KPIs, its heatmap, and its last-7-days usage chart.
|
||||
*
|
||||
* The timeline unit (fold.ts) prices the context as it stands NOW, which cannot draw a per-day
|
||||
* chart, so this unit folds the committed event stream into a ledger keyed by local calendar day:
|
||||
* an assistant settlement adds one completed request, its billed buckets (uncached input + cache
|
||||
* read + cache write + output), and its per-(provider, model, period) pricing record. Buckets
|
||||
* attribute to the day the open STEP STARTED, falling back to the settlement instant; the
|
||||
* peak/off split prices the same instant. Each day also tallies its skill loads.
|
||||
* peak/off split prices the same instant. A step's tool runs and wall time attach to that same
|
||||
* initiation day, and a turn books on the day its first settlement lands. Each day also tallies
|
||||
* its skill loads.
|
||||
*
|
||||
* The ledger is bounded by {@link MAX_KEPT_DAYS} keys and never materializes an `undefined`-valued
|
||||
* property.
|
||||
@@ -17,7 +19,7 @@ import { z } from 'zod'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { ProjectionDefinition } from './compat'
|
||||
import { skillNameOf } from './fold'
|
||||
import { addBilledUsage, copyCostUsage, tokenCountOf } from './foldMetering'
|
||||
import { addBilledUsage, copyCostUsage, durOf, tokenCountOf } from './foldMetering'
|
||||
import type { BilledUsage, UsageLike } from './foldMetering'
|
||||
import { costUsageSchema } from './timeline'
|
||||
import { dayKeyOf } from '../shared/days'
|
||||
@@ -32,6 +34,15 @@ const MAX_SKILLS_PER_DAY = 100
|
||||
/** Bound on the pending `skill`-call id list (see ActivityState.skillCalls). */
|
||||
const MAX_PENDING_SKILL_CALLS = 100
|
||||
|
||||
/** Bound on the pending tool-call start table (see ActivityState.toolStarts). */
|
||||
const MAX_PENDING_TOOL_CALLS = 100
|
||||
|
||||
/** A pending tool run's start instant and the step-initiation day it prices. */
|
||||
interface ToolStart {
|
||||
start: number
|
||||
day: string
|
||||
}
|
||||
|
||||
export interface ActivityState {
|
||||
days: Record<string, ActivityDay>
|
||||
/** The route in force (the last `request/header`'s config wins) — prices each settlement. */
|
||||
@@ -39,6 +50,10 @@ export interface ActivityState {
|
||||
provider?: string
|
||||
/** The open step's start instant (armed by `step/start`, cleared by `step/end`). */
|
||||
stepStart?: number
|
||||
/** The last turn whose first settlement was booked, so a multi-step turn counts once. */
|
||||
lastTurn?: number
|
||||
/** Pending tool runs (call id → start + initiation day), consumed by the pairing `tool/result`. */
|
||||
toolStarts?: Record<string, ToolStart | undefined>
|
||||
/** Pending `skill`-tool call ids (armed by `tool/call`, consumed by the pairing `tool/result`):
|
||||
* the wrapper match alone is not trusted, or a `read` of source text quoting
|
||||
* `<skill_content name="…">` would book a load. Bounded at {@link MAX_PENDING_SKILL_CALLS}. */
|
||||
@@ -50,10 +65,19 @@ const activitySkillSchema = z.object({
|
||||
last: z.number(),
|
||||
}).strict()
|
||||
|
||||
const toolStartSchema = z.object({
|
||||
start: z.number().nonnegative(),
|
||||
day: z.string(),
|
||||
}).strict()
|
||||
|
||||
const activityDaySchema = z.object({
|
||||
tokens: z.number().int().nonnegative(),
|
||||
requests: z.number().int().nonnegative(),
|
||||
cost: costUsageSchema.optional(),
|
||||
turns: z.number().int().nonnegative().optional(),
|
||||
toolCalls: z.number().int().nonnegative().optional(),
|
||||
toolsMs: z.number().nonnegative().optional(),
|
||||
wallMs: z.number().nonnegative().optional(),
|
||||
skills: z.record(z.string(), activitySkillSchema).optional(),
|
||||
}).strict()
|
||||
/** Validate the wire payload before it leaves the host (strict: no drift). */
|
||||
@@ -67,6 +91,8 @@ const activityStateSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
stepStart: z.number().optional(),
|
||||
lastTurn: z.number().optional(),
|
||||
toolStarts: z.record(z.string(), toolStartSchema).optional(),
|
||||
skillCalls: z.array(z.string()).optional(),
|
||||
}) as unknown as z.ZodType<ActivityState>
|
||||
|
||||
@@ -97,6 +123,59 @@ function withDayEntry(state: ActivityState, key: string, entry: ActivityDay): Ac
|
||||
return { ...state, days }
|
||||
}
|
||||
|
||||
/** A day entry's existing fields with the required counters filled, ready for a targeted patch.
|
||||
* Nested records ride by reference (the fold never mutates them in place). */
|
||||
function copyDay(prev: ActivityDay | undefined): ActivityDay {
|
||||
return {
|
||||
tokens: prev?.tokens ?? 0,
|
||||
requests: prev?.requests ?? 0,
|
||||
...(prev?.cost !== undefined ? { cost: prev.cost } : {}),
|
||||
...(prev?.turns !== undefined ? { turns: prev.turns } : {}),
|
||||
...(prev?.toolCalls !== undefined ? { toolCalls: prev.toolCalls } : {}),
|
||||
...(prev?.toolsMs !== undefined ? { toolsMs: prev.toolsMs } : {}),
|
||||
...(prev?.wallMs !== undefined ? { wallMs: prev.wallMs } : {}),
|
||||
...(prev?.skills !== undefined ? { skills: prev.skills } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Patch one day entry, preserving every other field. */
|
||||
function withDayPatch(state: ActivityState, key: string, patch: Partial<ActivityDay>): ActivityState {
|
||||
return withDayEntry(state, key, { ...copyDay(state.days[key]), ...patch })
|
||||
}
|
||||
|
||||
/** Remember a tool run under its initiation day; a hostile unsettled stream is bounded like the skill claims. */
|
||||
function withToolStart(state: ActivityState, callId: string, start: number): ActivityState {
|
||||
if (!Number.isFinite(start) || start < 0) return state
|
||||
const day = dayKeyOf(state.stepStart ?? start)
|
||||
if (day === null) return state
|
||||
const prev = state.toolStarts?.[callId]
|
||||
if (prev !== undefined && prev.start === start && prev.day === day) return state
|
||||
const starts: Record<string, ToolStart | undefined> = { ...state.toolStarts, [callId]: { start, day } }
|
||||
const entries = Object.entries(starts)
|
||||
const toolStarts = entries.length > MAX_PENDING_TOOL_CALLS
|
||||
? Object.fromEntries(entries.slice(entries.length - MAX_PENDING_TOOL_CALLS))
|
||||
: starts
|
||||
return { ...state, toolStarts }
|
||||
}
|
||||
|
||||
/** Price a paired tool run into its step's initiation day, then drain the pending entry. */
|
||||
function bookToolResult(state: ActivityState, callId: string, end: number): ActivityState {
|
||||
const starts = state.toolStarts
|
||||
const start = starts?.[callId]
|
||||
if (starts === undefined || start === undefined) return state
|
||||
const byKey: Record<string, ActivityDay | undefined> = state.days
|
||||
const day = byKey[start.day]
|
||||
const timed = withDayPatch(state, start.day, {
|
||||
toolCalls: (day?.toolCalls ?? 0) + 1,
|
||||
toolsMs: (day?.toolsMs ?? 0) + durOf(start.start, end),
|
||||
})
|
||||
const kept = Object.fromEntries(Object.entries(starts).filter(([id]) => id !== callId))
|
||||
const next = { ...timed }
|
||||
if (Object.keys(kept).length > 0) next.toolStarts = kept
|
||||
else delete next.toolStarts
|
||||
return next
|
||||
}
|
||||
|
||||
/** Book one skill load against its day: the tally grows and `last` takes the max, so a disordered
|
||||
* replay never walks it back. A day at the name cap keeps its booked names and drops the newcomer. */
|
||||
function bookSkillLoad(state: ActivityState, name: string, time: number): ActivityState {
|
||||
@@ -110,13 +189,7 @@ function bookSkillLoad(state: ActivityState, name: string, time: number): Activi
|
||||
...prev?.skills,
|
||||
[name]: { n: (existing?.n ?? 0) + 1, last: existing === undefined ? time : Math.max(existing.last, time) },
|
||||
}
|
||||
const entry: ActivityDay = {
|
||||
tokens: prev?.tokens ?? 0,
|
||||
requests: prev?.requests ?? 0,
|
||||
...(prev?.cost !== undefined ? { cost: prev.cost } : {}),
|
||||
skills,
|
||||
}
|
||||
return withDayEntry(state, key, entry)
|
||||
return withDayEntry(state, key, { ...copyDay(prev), skills })
|
||||
}
|
||||
|
||||
/** The skill name a `user/message` (or `developer/message`) load gesture carries: a user-explicit
|
||||
@@ -165,9 +238,17 @@ export function applyActivity(state: ActivityState, event: SessionEvent): Activi
|
||||
return state.stepStart === event.time ? state : { ...state, stepStart: event.time }
|
||||
}
|
||||
if (event.type === 'step/end') {
|
||||
if (state.stepStart === undefined) return state
|
||||
const start = state.stepStart
|
||||
if (start === undefined) return state
|
||||
// The step's wall time is charged to its START day, so a step crossing midnight keeps its
|
||||
// whole window on the day it began. A zero-length step books no entry.
|
||||
const key = dayKeyOf(start)
|
||||
const dur = durOf(start, event.time)
|
||||
const timed = key === null || dur <= 0
|
||||
? state
|
||||
: withDayPatch(state, key, { wallMs: (state.days[key]?.wallMs ?? 0) + dur })
|
||||
// DELETE the field — assigning `undefined` would break the plain-JSON precondition.
|
||||
const next = { ...state }
|
||||
const next = { ...timed }
|
||||
delete next.stepStart
|
||||
return next
|
||||
}
|
||||
@@ -184,29 +265,32 @@ export function applyActivity(state: ActivityState, event: SessionEvent): Activi
|
||||
return name === '' ? state : bookSkillLoad(state, name, event.time)
|
||||
}
|
||||
if (event.type === 'tool/call') {
|
||||
// Arm the pairing claim for a `skill`-tool dispatch; the wrapper match in the result is
|
||||
// trusted only under this pairing (see ActivityState.skillCalls).
|
||||
const data = event.data as { callId?: unknown; name?: unknown } | null | undefined
|
||||
const callId = data?.callId
|
||||
if (data?.name !== 'skill' || typeof callId !== 'string' || callId === '') return state
|
||||
const pending = state.skillCalls ?? []
|
||||
if (pending.includes(callId)) return state
|
||||
return { ...state, skillCalls: [...pending, callId].slice(-MAX_PENDING_SKILL_CALLS) }
|
||||
if (typeof callId !== 'string' || callId === '') return state
|
||||
// Every tool run is timed against its step's initiation day; the skill tool additionally arms
|
||||
// its pairing claim, trusted only when the result carries the matching call id.
|
||||
const next = withToolStart(state, callId, event.time)
|
||||
if (data?.name !== 'skill') return next
|
||||
const pending = next.skillCalls ?? []
|
||||
if (pending.includes(callId)) return next
|
||||
return { ...next, skillCalls: [...pending, callId].slice(-MAX_PENDING_SKILL_CALLS) }
|
||||
}
|
||||
if (event.type === 'tool/result') {
|
||||
// The model-initiated load path: the `skill` tool's result renders the instructions behind
|
||||
// the `<skill_content name="…">` wrapper. An unpaired result books nothing; the claim consumes once either way.
|
||||
// Price the paired run first, then consume the `skill`-tool load claim (if any): the
|
||||
// `<skill_content name="…">` wrapper is trusted only under the matching pending call.
|
||||
const message = (event.data as { message?: unknown } | null | undefined)?.message
|
||||
const callId = toolResultCallIdOf(message)
|
||||
const pending = state.skillCalls
|
||||
if (callId === null || pending === undefined || !pending.includes(callId)) return state
|
||||
const next = callId === null ? state : bookToolResult(state, callId, event.time)
|
||||
const pending = next.skillCalls
|
||||
if (callId === null || pending === undefined || !pending.includes(callId)) return next
|
||||
const skillCalls = pending.filter(id => id !== callId)
|
||||
const next: ActivityState = { ...state }
|
||||
const consumed: ActivityState = { ...next }
|
||||
// DELETE the drained field — an undefined-valued property would break the plain-JSON state.
|
||||
if (skillCalls.length > 0) next.skillCalls = skillCalls
|
||||
else delete next.skillCalls
|
||||
if (skillCalls.length > 0) consumed.skillCalls = skillCalls
|
||||
else delete consumed.skillCalls
|
||||
const name = skillNameOf(message)
|
||||
return name === '' ? next : bookSkillLoad(next, name, event.time)
|
||||
return name === '' ? consumed : bookSkillLoad(consumed, name, event.time)
|
||||
}
|
||||
if (event.type !== 'assistant/message') return state
|
||||
// Attribute the settlement to the open step's initiation instant, falling back to its own; the
|
||||
@@ -214,7 +298,7 @@ export function applyActivity(state: ActivityState, event: SessionEvent): Activi
|
||||
const initiated = state.stepStart !== undefined ? state.stepStart : event.time
|
||||
const key = dayKeyOf(initiated)
|
||||
if (key === null) return state
|
||||
const data = event.data as { usage?: unknown } | undefined
|
||||
const data = event.data as { usage?: unknown; turn?: unknown } | undefined
|
||||
const buckets = billedBucketsOf(data?.usage)
|
||||
const byKey: Record<string, ActivityDay | undefined> = state.days
|
||||
const prev = byKey[key]
|
||||
@@ -224,21 +308,33 @@ export function applyActivity(state: ActivityState, event: SessionEvent): Activi
|
||||
if (buckets !== null && state.model !== undefined) {
|
||||
cost = addBilledUsage(cost, state.provider ?? '', state.model, initiated, buckets)
|
||||
}
|
||||
const entry = {
|
||||
// A turn books once, on the initiation day of its FIRST settlement; later steps of the same
|
||||
// turn keep the tally. The cursor survives across steps and dies at a tagged seed cut.
|
||||
const turn = typeof data?.turn === 'number' && Number.isFinite(data.turn) ? data.turn : undefined
|
||||
const newTurn = turn !== undefined && turn !== state.lastTurn
|
||||
const turns = (prev?.turns ?? 0) + (newTurn ? 1 : 0)
|
||||
const entry: ActivityDay = {
|
||||
tokens: (prev === undefined ? 0 : prev.tokens)
|
||||
+ (buckets === null ? 0 : buckets.uncached + buckets.cacheRead + buckets.cacheWrite + buckets.output),
|
||||
requests: (prev === undefined ? 0 : prev.requests) + 1,
|
||||
...(cost !== undefined ? { cost } : {}),
|
||||
// The day's skill tallies ride the entry by reference; every mutation clones along its path.
|
||||
...(turns > 0 ? { turns } : {}),
|
||||
// Tool runs and wall time already booked to this day ride along by reference; every mutation
|
||||
// clones along its path.
|
||||
...(prev?.toolCalls !== undefined ? { toolCalls: prev.toolCalls } : {}),
|
||||
...(prev?.toolsMs !== undefined ? { toolsMs: prev.toolsMs } : {}),
|
||||
...(prev?.wallMs !== undefined ? { wallMs: prev.wallMs } : {}),
|
||||
...(prev?.skills !== undefined ? { skills: prev.skills } : {}),
|
||||
}
|
||||
return withDayEntry(state, key, entry)
|
||||
const next = withDayEntry(state, key, entry)
|
||||
return newTurn ? { ...next, lastTurn: turn } : next
|
||||
}
|
||||
|
||||
/** The daily-activity projection unit, registered alongside the timeline and headers units
|
||||
* (host/index.ts). `stateVersion` 4: the day entry gained the skill table, which later events
|
||||
* cannot backfill for already-folded days, so cached rows read version-stale on upgrade and the
|
||||
* Context Insights warm-up cold-refolds them. On the wire the field is additive-optional. */
|
||||
* (host/index.ts). `stateVersion` 5: the day entry gained turns, tool-run timing, and step wall
|
||||
* time, and the state gained the turn cursor and pending tool starts. Later events cannot
|
||||
* backfill those for already-folded days, so cached rows read version-stale on upgrade and the
|
||||
* Context Insights warm-up cold-refolds them. On the wire the day fields are additive-optional. */
|
||||
export function createContextActivityDefinition(): ProjectionDefinition<'contextActivity', ActivityState> {
|
||||
return {
|
||||
key: 'contextActivity',
|
||||
@@ -260,6 +356,6 @@ export function createContextActivityDefinition(): ProjectionDefinition<'context
|
||||
}])),
|
||||
}),
|
||||
},
|
||||
stateVersion: 4,
|
||||
stateVersion: 5,
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,14 @@ export interface ActivityDay {
|
||||
/** The day's billed buckets keyed provider → model → pricing period, scoped to the day the
|
||||
* requests INITIATED in. Absent = tokens-only bars. */
|
||||
cost?: SessionCostUsage
|
||||
/** Turns whose FIRST settlement landed on this (step-initiation) day. */
|
||||
turns?: number
|
||||
/** Completed tool runs whose step STARTED that day. */
|
||||
toolCalls?: number
|
||||
/** Summed tool-run durations for those runs, in ms. */
|
||||
toolsMs?: number
|
||||
/** Summed wall time of steps that ENDED, charged to the day their step STARTED, in ms. */
|
||||
wallMs?: number
|
||||
/** Skill name → `{ n: loads, last: last-load instant in epoch ms }`; absent = an empty day. */
|
||||
skills?: Record<string, { n: number; last: number }>
|
||||
}
|
||||
|
||||
@@ -65,14 +65,14 @@ describe('Heatmap', () => {
|
||||
assert.ok(byKey.get('2026-09-16')?.className.includes('lc-heat-4'), 'the maximum day is deepest')
|
||||
assert.ok(byKey.get('2026-09-15')?.className.includes('lc-heat-2'))
|
||||
assert.ok(byKey.get('2026-09-14')?.className.includes('lc-heat-1'))
|
||||
assert.ok(byKey.get('2026-09-10')?.className.includes('lc-heat-0'), 'a day billed without a settled step stays gray')
|
||||
assert.ok(byKey.get('2026-09-10')?.className.includes('lc-heat-2'), '30 of the 100-token peak draws level 2')
|
||||
assert.ok(byKey.get('2026-09-09')?.className.includes('lc-heat-3'))
|
||||
assert.ok(byKey.get('2026-09-08')?.className.includes('lc-heat-1'), 'a crumb is never invisible')
|
||||
// The zero-token day counts as activity (requests > 0); depths ride the
|
||||
// steps metric, so its pair of steps draws half the peak.
|
||||
assert.ok(byKey.get('2026-09-07')?.className.includes('lc-heat-2'))
|
||||
assert.equal(byKey.get('2026-09-16')?.getAttribute('aria-label'), '2026-09-16\n2 active sessions\n4 steps')
|
||||
assert.equal(byKey.get('2026-09-10')?.getAttribute('aria-label'), '2026-09-10\n1 active sessions\n0 steps')
|
||||
// A zero-token day stays as an activity button (its requests still count),
|
||||
// but the token metric depths it at level 0.
|
||||
assert.ok(byKey.get('2026-09-07')?.className.includes('lc-heat-0'))
|
||||
assert.equal(byKey.get('2026-09-16')?.getAttribute('aria-label'), '2026-09-16\n2 active sessions\n100 tokens')
|
||||
assert.equal(byKey.get('2026-09-10')?.getAttribute('aria-label'), '2026-09-10\n1 active sessions\n30 tokens')
|
||||
// Inert cells: data-less days and future days draw as plain spans.
|
||||
const spans = queryAll(m.container, 'span.lc-heat-cell')
|
||||
assert.ok(spans.length > 0)
|
||||
@@ -116,7 +116,7 @@ describe('Heatmap', () => {
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('the sessions metric depths on each day’s distinct sessions, independently of steps', async () => {
|
||||
test('the sessions metric depths on each day’s distinct sessions, independently of tokens', async () => {
|
||||
const days = {
|
||||
'2026-09-16': { tokens: 100, requests: 4, sessions: 2, cost: null },
|
||||
'2026-09-09': { tokens: 75, requests: 1, sessions: 3, cost: null },
|
||||
@@ -124,11 +124,11 @@ describe('Heatmap', () => {
|
||||
const m = await mount(h(Heatmap, { days, today: TODAY, weeks: 2, metric: 'sessions' }))
|
||||
const byKey = new Map(queryAll<HTMLButtonElement>(m.container, 'button.lc-heat-cell').map(b => [b.getAttribute('aria-label')?.split('\n')[0], b]))
|
||||
assert.ok(byKey.get('2026-09-16')?.className.includes('lc-heat-3'), 'two of the three-session peak draws level 3')
|
||||
assert.ok(byKey.get('2026-09-09')?.className.includes('lc-heat-4'), 'the sessions peak depths level 4 that its thin steps never would')
|
||||
assert.ok(byKey.get('2026-09-09')?.className.includes('lc-heat-4'), 'the sessions peak depths level 4 that its smaller token share never would')
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('cells carry the page hover tip in data attributes (date, sessions, steps; an empty day the bare date)', async () => {
|
||||
test('cells carry the page hover tip in data attributes (date, sessions, tokens; an empty day the bare date)', async () => {
|
||||
const m = await mount(h(Heatmap, {
|
||||
days: { '2026-09-16': { tokens: 10, requests: 1, sessions: 3, cost: null } },
|
||||
today: TODAY,
|
||||
@@ -136,7 +136,7 @@ describe('Heatmap', () => {
|
||||
}))
|
||||
assert.equal(
|
||||
query<HTMLButtonElement>(m.container, 'button.lc-heat-cell').getAttribute('data-lc-tip'),
|
||||
'2026-09-16\n3 active sessions\n1 steps',
|
||||
'2026-09-16\n3 active sessions\n10 tokens',
|
||||
)
|
||||
assert.match(query(m.container, 'span.lc-heat-0').getAttribute('data-lc-tip') ?? '', /^\d{4}-\d{2}-\d{2}$/)
|
||||
await m.unmount()
|
||||
|
||||
@@ -50,7 +50,20 @@ function sessionsSnapshot(): { ids: string[]; byId: Record<string, Record<string
|
||||
running: true,
|
||||
projectionValues: {
|
||||
contextTimeline: timeline(),
|
||||
contextActivity: { days: { [TODAY]: { tokens: 100, requests: 2 }, [YESTERDAY]: { tokens: 50, requests: 1 } } },
|
||||
contextActivity: {
|
||||
days: {
|
||||
[TODAY]: {
|
||||
tokens: 100,
|
||||
requests: 2,
|
||||
turns: 2,
|
||||
toolCalls: 4,
|
||||
toolsMs: 10_000,
|
||||
wallMs: 45_000,
|
||||
cost: { deepseek: { 'deepseek-v4-flash': { peak: { uncached: 1000, cacheRead: 500, cacheWrite: 0, output: 250 } } } },
|
||||
},
|
||||
[YESTERDAY]: { tokens: 50, requests: 1, turns: 1, toolCalls: 3, toolsMs: 10_000, wallMs: 45_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
b: {
|
||||
@@ -59,7 +72,7 @@ function sessionsSnapshot(): { ids: string[]; byId: Record<string, Record<string
|
||||
updatedAt: NOW - 10 * 86_400_000,
|
||||
projectionValues: {
|
||||
contextTimeline: timeline({ current: { system: 10, tools: 0, user: 0, inject: 0, skill: 0, assistant: 0, tool: 0, total: 10 }, cost: undefined }),
|
||||
contextActivity: { days: { [YESTERDAY]: { tokens: 25, requests: 1 } } },
|
||||
contextActivity: { days: { [YESTERDAY]: { tokens: 25, requests: 1, turns: 1, toolCalls: 7, toolsMs: 20_000, wallMs: 90_000 } } },
|
||||
},
|
||||
},
|
||||
c: {
|
||||
@@ -161,7 +174,7 @@ describe('OverviewPanel', () => {
|
||||
assert.equal(values[4], '14')
|
||||
assert.equal(values[5], '3m0s')
|
||||
assert.ok(text(m.container).includes('tool runs 40.0s'), 'the tool calls cell qualifies with the summed run time')
|
||||
assert.ok(text(m.container).includes('8 model calls'), 'the active-time cell qualifies with the model-call count')
|
||||
assert.ok(text(m.container).includes('4 model calls'), 'the active-time cell qualifies with the settled model-call count')
|
||||
// Cost and cache hit qualify with the session count each figure covers
|
||||
// (only session a carries usage; b folds no cost at all).
|
||||
const subs = queryAll(m.container, '.lc-stat-sub').map(el => el.textContent)
|
||||
@@ -256,7 +269,7 @@ describe('OverviewPanel', () => {
|
||||
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 1, "only today's session stays")
|
||||
await click(rangeButtons[1]) // Last 7 days
|
||||
assert.deepEqual(span(), [dayKeyOf(NOW - 7 * 86_400_000), TODAY])
|
||||
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 1, 'only the freshest stays')
|
||||
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 2, 'the yesterday-active session joins the prompt-fresh one')
|
||||
await click(rangeButtons[2]) // back to 30d
|
||||
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 2)
|
||||
await m.unmount()
|
||||
@@ -275,9 +288,9 @@ describe('OverviewPanel', () => {
|
||||
[YESTERDAY, TODAY],
|
||||
'the head names both ends, start first',
|
||||
)
|
||||
// The window closes at both ends: only the session last touched inside it stays.
|
||||
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 1)
|
||||
assert.equal(queryAll(m.container, '.lc-stat-value')[0].textContent, '1', 'the KPI band narrowed with it')
|
||||
// The window closes at both ends: the prompt-fresh session and the yesterday-active one stay.
|
||||
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 2)
|
||||
assert.equal(queryAll(m.container, '.lc-stat-value')[0].textContent, '2', 'the KPI band narrowed with it')
|
||||
assert.ok(
|
||||
rangeButtons.every(b => !b.className.includes('lc-gran-on')),
|
||||
'a picked range and a preset are never both lit',
|
||||
@@ -301,7 +314,7 @@ describe('OverviewPanel', () => {
|
||||
[YESTERDAY, TODAY],
|
||||
'the picked pair is sorted, start day first',
|
||||
)
|
||||
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 1, 'the same window a forward pick would have left')
|
||||
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 2, 'the same window a forward pick would have left')
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
@@ -370,16 +383,16 @@ describe('OverviewPanel', () => {
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('the metric toggle re-prices the heatmap between steps and sessions', async () => {
|
||||
test('the metric toggle re-prices the heatmap between tokens and sessions', async () => {
|
||||
const ctx = makeCtx()
|
||||
const { m } = await openPanel(ctx)
|
||||
const todayCellClass = (): string | undefined =>
|
||||
queryAll<HTMLButtonElement>(m.container, 'button.lc-heat-cell')
|
||||
.find(c => c.getAttribute('aria-label')?.startsWith(TODAY))?.className
|
||||
assert.ok(todayCellClass()?.includes('lc-heat-4'), 'steps mode (default): today holds the steps peak')
|
||||
assert.ok(todayCellClass()?.includes('lc-heat-4'), 'tokens mode (default): today holds the token peak')
|
||||
const metricButtons = queryAll<HTMLButtonElement>(m.container, '.lc-heat-ctl .lc-gran-btn')
|
||||
assert.deepEqual(metricButtons.map(b => b.textContent), ['Sessions', 'Steps'])
|
||||
assert.ok(metricButtons[1].className.includes('lc-gran-on'), 'steps starts selected')
|
||||
assert.deepEqual(metricButtons.map(b => b.textContent), ['Sessions', 'Tokens'])
|
||||
assert.ok(metricButtons[1].className.includes('lc-gran-on'), 'tokens starts selected')
|
||||
await click(metricButtons[0])
|
||||
const after = queryAll<HTMLButtonElement>(m.container, '.lc-heat-ctl .lc-gran-btn')
|
||||
assert.ok(after[0].className.includes('lc-gran-on'), 'the click selects sessions')
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('filterRows', () => {
|
||||
}),
|
||||
]
|
||||
|
||||
test('the range window filters by last activity', () => {
|
||||
test('the range window admits a last prompt or a daily activity day', () => {
|
||||
assert.deepEqual(filterRows(rows, { scope: win('today', now), day: null, query: '' }).map(r => r.id), ['new', 'active'])
|
||||
assert.deepEqual(filterRows(rows, { scope: win('7d', now), day: null, query: '' }).map(r => r.id), ['new', 'active'])
|
||||
assert.deepEqual(filterRows(rows, { scope: win('all', now), day: null, query: '' }).map(r => r.id), ['old', 'new', 'active'])
|
||||
@@ -85,6 +85,39 @@ describe('filterRows', () => {
|
||||
assert.deepEqual(filterRows(dRows, { scope: win('7d', now), day: null, query: '' }).map(r => r.id), ['in', 'out'], 'last night is still inside the week')
|
||||
})
|
||||
|
||||
test('a bounded window also admits a ledger-active session whose last prompt is older', () => {
|
||||
const rows = [
|
||||
// The long run started today: no new user prompt, but its ledger day is in the window.
|
||||
rowOf({ id: 'long-run', updatedAt: midnight - 3 * 86_400_000, activity: { days: { '2026-09-11': { tokens: 5, requests: 1 } } } }),
|
||||
// A prompt inside the window with no billing still counts as touched.
|
||||
rowOf({ id: 'prompt-only', updatedAt: now }),
|
||||
// Neither signal is inside the window.
|
||||
rowOf({ id: 'stale', updatedAt: midnight - 3 * 86_400_000 }),
|
||||
]
|
||||
assert.deepEqual(filterRows(rows, { scope: win('today', now), day: null, query: '' }).map(r => r.id), ['long-run', 'prompt-only'])
|
||||
})
|
||||
|
||||
test('each daily tally is enough to admit a session to a bounded window', () => {
|
||||
const dayRow = (id: string, day: Record<string, unknown>) => rowOf({
|
||||
id,
|
||||
updatedAt: midnight - 3 * 86_400_000,
|
||||
activity: { days: { '2026-09-11': day } } as never,
|
||||
})
|
||||
const rows = [
|
||||
dayRow('tokens', { tokens: 1, requests: 0 }),
|
||||
dayRow('requests', { tokens: 0, requests: 1 }),
|
||||
dayRow('turns', { tokens: 0, requests: 0, turns: 1 }),
|
||||
dayRow('toolCalls', { tokens: 0, requests: 0, toolCalls: 1 }),
|
||||
dayRow('wallMs', { tokens: 0, requests: 0, wallMs: 1 }),
|
||||
dayRow('skills', { tokens: 0, requests: 0, skills: { tdd: { n: 1, last: 1 } } }),
|
||||
dayRow('empty', { tokens: 0, requests: 0 }),
|
||||
]
|
||||
assert.deepEqual(
|
||||
filterRows(rows, { scope: win('today', now), day: null, query: '' }).map(r => r.id),
|
||||
['tokens', 'requests', 'turns', 'toolCalls', 'wallMs', 'skills'],
|
||||
)
|
||||
})
|
||||
|
||||
test('a picked range closes both ends of the window', () => {
|
||||
// A two-day window: the day before its start and the day after its end are
|
||||
// both out, and the end day's last millisecond is still in.
|
||||
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
aggregateDays,
|
||||
billedOf,
|
||||
createdDayOf,
|
||||
dayRangeWindow,
|
||||
kpisOf,
|
||||
timingSumOf,
|
||||
tokenPartsOf,
|
||||
turnsOf,
|
||||
usageTotalsOf,
|
||||
windowTotalsOf,
|
||||
type OverviewRow,
|
||||
} from '../../src/client/overview'
|
||||
import { priceIndexOf } from '../../src/client/cost'
|
||||
@@ -172,6 +174,46 @@ describe('tokenPartsOf', () => {
|
||||
parts: [{ key: 'system', color: 'var(--color-indigo-500)', value: 10 }],
|
||||
})
|
||||
})
|
||||
|
||||
test('a scoped split folds only the daily ledger days inside the window', () => {
|
||||
const member = { current: STANDARD_CURRENT, cost: COST, requests: [] } as unknown as ContextTimeline
|
||||
const activity: ContextActivity = {
|
||||
days: {
|
||||
'2026-09-16': { tokens: 5, requests: 1, cost: COST },
|
||||
'2026-09-17': { tokens: 9, requests: 1, cost: COST },
|
||||
},
|
||||
}
|
||||
const row = rowOf({ timeline: member, family: [member], familyActivity: [activity] })
|
||||
const scope = dayRangeWindow({ from: '2026-09-16', to: '2026-09-16' })
|
||||
assert.ok(scope !== null)
|
||||
assert.equal(tokenPartsOf([row], scope)?.total, 200, 'one admitted day of COST, not both')
|
||||
const orphan = rowOf({ timeline: { current: STANDARD_CURRENT, cost: COST, requests: [] } as unknown as ContextTimeline, family: [member], familyActivity: [null] })
|
||||
assert.equal(tokenPartsOf([orphan], scope), null, 'a member without a ledger contributes no scoped usage')
|
||||
const noLedger = rowOf({ timeline: member })
|
||||
assert.equal(tokenPartsOf([noLedger], scope), null, 'a row without a family ledger contributes nothing to a scoped split')
|
||||
})
|
||||
})
|
||||
|
||||
describe('windowTotalsOf', () => {
|
||||
test('folds only admitted days; a cost-less day leaves the usage null', () => {
|
||||
const scope = dayRangeWindow({ from: '2026-09-16', to: '2026-09-16' })
|
||||
assert.ok(scope !== null)
|
||||
assert.deepEqual(windowTotalsOf(null, scope), { requests: 0, turns: 0, toolCalls: 0, toolsMs: 0, wallMs: 0, usage: null })
|
||||
assert.deepEqual(
|
||||
windowTotalsOf({
|
||||
days: {
|
||||
'2026-09-16': { tokens: 1, requests: 1, turns: 1, toolCalls: 2, toolsMs: 3, wallMs: 4 },
|
||||
'2026-09-17': { tokens: 9, requests: 9, cost: COST },
|
||||
},
|
||||
}, scope),
|
||||
{ requests: 1, turns: 1, toolCalls: 2, toolsMs: 3, wallMs: 4, usage: null },
|
||||
'the day after the window contributes nothing',
|
||||
)
|
||||
assert.deepEqual(
|
||||
windowTotalsOf({ days: { '2026-09-16': { tokens: 1, requests: 1, cost: COST } } }, scope).usage,
|
||||
COST,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('kpisOf', () => {
|
||||
@@ -215,6 +257,36 @@ describe('kpisOf', () => {
|
||||
assert.deepEqual(kpi.timing, { wallMs: 120_000, ttftMs: 1_000, genMs: 30_000, calls: 6, toolsMs: 20_000, toolCalls: 7, tools: {} })
|
||||
})
|
||||
|
||||
test('a bounded scope folds only the daily ledger days inside the window', () => {
|
||||
const member = {
|
||||
cost: COST,
|
||||
counts: { turns: 9 },
|
||||
current: { system: 100, tools: 50, user: 30, inject: 10, skill: 10, assistant: 200, tool: 100, total: 500 },
|
||||
requests: [],
|
||||
} as unknown as ContextTimeline
|
||||
const activity: ContextActivity = {
|
||||
days: {
|
||||
'2026-09-16': { tokens: 5, requests: 1, cost: COST, turns: 1, toolCalls: 2, toolsMs: 30, wallMs: 100 },
|
||||
'2026-09-17': { tokens: 7, requests: 3, cost: COST, turns: 1, toolCalls: 4, toolsMs: 50, wallMs: 300 },
|
||||
},
|
||||
}
|
||||
const row = rowOf({ timeline: member, family: [member], familyActivity: [activity], activity, familyCost: COST })
|
||||
const bare = rowOf({ activity: { days: { '2026-09-16': { tokens: 1, requests: 1 } } } })
|
||||
const scope = dayRangeWindow({ from: '2026-09-16', to: '2026-09-16' })
|
||||
assert.ok(scope !== null)
|
||||
const kpi = kpisOf([row, bare], prices, 'usd', scope)
|
||||
assert.equal(kpi.sessions, 2)
|
||||
assert.equal(kpi.tokens, 200, 'one admitted COST, not two')
|
||||
assert.equal(kpi.costSessions, 1)
|
||||
assert.equal(kpi.usageSessions, 1)
|
||||
assert.equal(kpi.turns, 1, 'the out-of-window turn is not counted')
|
||||
assert.equal(kpi.toolCalls, 2)
|
||||
assert.equal(kpi.toolsMs, 30)
|
||||
assert.equal(kpi.calls, 2, 'settled model calls follow the admitted days')
|
||||
assert.equal(kpi.wallMs, 100)
|
||||
assert.equal(kpi.tokenParts?.total, 200)
|
||||
})
|
||||
|
||||
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, familyCost: COST }),
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('rowsOfSnapshot', () => {
|
||||
const [b, c, d, e] = rows
|
||||
assert.deepEqual(b, {
|
||||
id: 'b', title: 'shown', updatedAt: 5, running: true, current: true, archived: false,
|
||||
timeline: null, activity: null, family: [null], familyCost: null,
|
||||
timeline: null, activity: null, family: [null], familyActivity: [null], familyCost: null,
|
||||
})
|
||||
assert.equal(c.title, 'titled')
|
||||
assert.equal(c.current, false)
|
||||
@@ -158,21 +158,26 @@ describe('rowsOfSnapshot: the family fold', () => {
|
||||
displayTitle: 'root',
|
||||
projectionValues: {
|
||||
contextTimeline: billedHead(100, COST),
|
||||
contextActivity: { days: { '2026-09-16': { tokens: 5, requests: 1, cost: COST } } },
|
||||
contextActivity: { days: { '2026-09-16': { tokens: 5, requests: 1, cost: COST, turns: 1, toolCalls: 1, toolsMs: 100, wallMs: 1_000 } } },
|
||||
},
|
||||
}],
|
||||
['kid', {
|
||||
origin: 'subagent', parentId: 'root',
|
||||
projectionValues: {
|
||||
contextTimeline: billedHead(50, COST2),
|
||||
contextActivity: { days: { '2026-09-16': { tokens: 7, requests: 2, cost: COST2 } } },
|
||||
contextActivity: { days: { '2026-09-16': { tokens: 7, requests: 2, cost: COST2, turns: 1, toolCalls: 2, toolsMs: 200, wallMs: 2_000 } } },
|
||||
},
|
||||
}],
|
||||
['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 } } },
|
||||
contextActivity: {
|
||||
days: {
|
||||
'2026-09-16': { tokens: 4, requests: 1, turns: 1, toolCalls: 3, toolsMs: 300, wallMs: 3_000 },
|
||||
'2026-09-15': { tokens: 3, requests: 1, turns: 1, toolCalls: 4, toolsMs: 400, wallMs: 4_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}],
|
||||
['stranger', { displayTitle: 'unrelated' }],
|
||||
@@ -181,6 +186,7 @@ describe('rowsOfSnapshot: the family fold', () => {
|
||||
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')
|
||||
assert.equal(root.familyActivity?.length, 3, 'the per-member ledgers align with the family')
|
||||
// COST (200) + COST2 (300) merge; the grandchild carried no cost.
|
||||
assert.equal(billedOf(root), 500)
|
||||
assert.equal(billedOf(stranger), null)
|
||||
@@ -188,7 +194,15 @@ describe('rowsOfSnapshot: the family fold', () => {
|
||||
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-16'].turns, 3)
|
||||
assert.equal(root.activity?.days['2026-09-16'].toolCalls, 6)
|
||||
assert.equal(root.activity?.days['2026-09-16'].toolsMs, 600)
|
||||
assert.equal(root.activity?.days['2026-09-16'].wallMs, 6_000)
|
||||
assert.equal(root.activity?.days['2026-09-15'].tokens, 3)
|
||||
assert.equal(root.activity?.days['2026-09-15'].turns, 1)
|
||||
assert.equal(root.activity?.days['2026-09-15'].toolCalls, 4)
|
||||
assert.equal(root.activity?.days['2026-09-15'].toolsMs, 400)
|
||||
assert.equal(root.activity?.days['2026-09-15'].wallMs, 4_000)
|
||||
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.
|
||||
@@ -202,6 +216,58 @@ describe('rowsOfSnapshot: the family fold', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('the daily tallies merge additively; a day none carried leaves them absent', () => {
|
||||
const rows = rowsOfSnapshot(snapshotOf([
|
||||
['root', {
|
||||
displayTitle: 'root',
|
||||
projectionValues: {
|
||||
contextTimeline: billedHead(1),
|
||||
contextActivity: {
|
||||
days: {
|
||||
'2026-09-14': { tokens: 1, requests: 1 },
|
||||
'2026-09-13': { tokens: 1, requests: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}],
|
||||
['kid', {
|
||||
origin: 'subagent', parentId: 'root',
|
||||
projectionValues: {
|
||||
contextTimeline: billedHead(1),
|
||||
contextActivity: {
|
||||
days: {
|
||||
'2026-09-14': { tokens: 1, requests: 1, turns: 1, toolCalls: 1, toolsMs: 1, wallMs: 1 },
|
||||
'2026-09-13': { tokens: 1, requests: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}],
|
||||
['grand', {
|
||||
origin: 'subagent', parentId: 'kid',
|
||||
projectionValues: {
|
||||
contextTimeline: billedHead(1),
|
||||
contextActivity: {
|
||||
days: {
|
||||
'2026-09-14': { tokens: 1, requests: 1 },
|
||||
'2026-09-13': { tokens: 1, requests: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}],
|
||||
]))
|
||||
assert.ok(rows !== null)
|
||||
const day = rows[0].activity?.days['2026-09-14']
|
||||
assert.equal(day?.turns, 1)
|
||||
assert.equal(day?.toolCalls, 1)
|
||||
assert.equal(day?.toolsMs, 1)
|
||||
assert.equal(day?.wallMs, 1)
|
||||
const bare = rows[0].activity?.days['2026-09-13']
|
||||
assert.equal(bare?.turns, undefined)
|
||||
assert.equal(bare?.toolCalls, undefined)
|
||||
assert.equal(bare?.toolsMs, undefined)
|
||||
assert.equal(bare?.wallMs, undefined)
|
||||
})
|
||||
|
||||
test('an unlisted subtree (a subagent whose root never lists) leaks nowhere', () => {
|
||||
const rows = rowsOfSnapshot(snapshotOf([
|
||||
['root', { displayTitle: 'root', projectionValues: { contextTimeline: billedHead(1, COST) } }],
|
||||
|
||||
@@ -845,6 +845,18 @@ describe('activityOf', () => {
|
||||
},
|
||||
}, 'non-record and empty-after-scrubbing tables drop whole; the days survive')
|
||||
})
|
||||
|
||||
test('the daily tallies ride the pass-through and the sanitized copy; malformed figures drop alone', () => {
|
||||
const tallies = { turns: 2, toolCalls: 3, toolsMs: 4.5, wallMs: 5 }
|
||||
const wire = { days: { '2026-09-16': { tokens: 15, requests: 1, ...tallies } } }
|
||||
assert.ok(activityOf(wire) === wire, 'a clean payload keeps its reference')
|
||||
const dirty = { days: { '2026-09-16': { tokens: 15, requests: 1, ...tallies }, bad: { tokens: 1, requests: 1 } } }
|
||||
assert.deepEqual(activityOf(dirty), { days: { '2026-09-16': { tokens: 15, requests: 1, ...tallies } } })
|
||||
const out = activityOf({
|
||||
days: { '2026-09-16': { tokens: 15, requests: 1, turns: 'x', toolCalls: -1, toolsMs: Number.NaN, wallMs: Number.POSITIVE_INFINITY } },
|
||||
})
|
||||
assert.deepEqual(out, { days: { '2026-09-16': { tokens: 15, requests: 1 } } }, 'each malformed tally drops, the day survives')
|
||||
})
|
||||
})
|
||||
|
||||
describe('entrySeated — the slot-ledger second-mount guard', () => {
|
||||
|
||||
+100
-9
@@ -19,11 +19,20 @@ function assistantMessage(time: number, usage?: unknown): SessionEvent {
|
||||
} as never
|
||||
}
|
||||
|
||||
function turnMessage(time: number, turn: number, usage?: unknown): SessionEvent {
|
||||
return {
|
||||
type: 'assistant/message',
|
||||
seq: 1,
|
||||
time,
|
||||
data: { turn, ...(usage === undefined ? {} : { usage }) },
|
||||
} as never
|
||||
}
|
||||
|
||||
describe('contextActivity unit: shape', () => {
|
||||
test('the definition carries the contract fields', () => {
|
||||
const def = createContextActivityDefinition()
|
||||
assert.equal(def.key, 'contextActivity')
|
||||
assert.equal(def.stateVersion, 4, 'the per-day skill table is the fourth shape')
|
||||
assert.equal(def.stateVersion, 5, 'the daily turns/tool/wall tallies are the fifth shape')
|
||||
assert.deepEqual(def.init(), { days: {} })
|
||||
})
|
||||
|
||||
@@ -195,13 +204,15 @@ describe('applyActivity: skill loads', () => {
|
||||
assert.equal(state.skillCalls, undefined, 'the claim consumes once')
|
||||
})
|
||||
|
||||
test('a wrapper in another tool’s result books nothing (a `read` quoting the wrapper is no load)', () => {
|
||||
test('a wrapper in another tool’s result books no skill (a `read` quoting the wrapper is no load)', () => {
|
||||
let state = applyActivity({ days: {} }, toolCall(at(0), 'c1', 'read'))
|
||||
state = applyActivity(state, skillResult(at(0), 'source text quoting <skill_content name="…"> inline', 'c1'))
|
||||
assert.deepEqual(state.days, {})
|
||||
// Wholly unpaired results book nothing either (a trimmed/foreign call).
|
||||
assert.equal(state.days['2026-01-01'].skills, undefined)
|
||||
assert.equal(state.days['2026-01-01'].toolCalls, 1, 'the read still prices its tool run')
|
||||
// Wholly unpaired results add no tool run (a trimmed/foreign call).
|
||||
state = applyActivity(state, skillResult(at(0), '<skill_content name="ponytail">…</skill_content>', 'c9'))
|
||||
assert.deepEqual(state.days, {})
|
||||
assert.equal(state.days['2026-01-01'].skills, undefined)
|
||||
assert.equal(state.days['2026-01-01'].toolCalls, 1)
|
||||
})
|
||||
|
||||
test('the lifted toolCallId and the durable source callId both pair', () => {
|
||||
@@ -216,10 +227,11 @@ describe('applyActivity: skill loads', () => {
|
||||
assert.deepEqual(state.days['2026-01-01'].skills, { tdd: { n: 1, last: at(0) } })
|
||||
})
|
||||
|
||||
test('a paired result without the wrapper consumes the claim but books nothing', () => {
|
||||
test('a paired result without the wrapper consumes the claim but books no skill', () => {
|
||||
let state = applyActivity({ days: {} }, toolCall(at(0), 'c1', 'skill'))
|
||||
state = applyActivity(state, skillResult(at(0), 'skill load failed', 'c1'))
|
||||
assert.deepEqual(state.days, {})
|
||||
assert.equal(state.days['2026-01-01'].skills, undefined)
|
||||
assert.equal(state.days['2026-01-01'].toolCalls, 1)
|
||||
assert.equal(state.skillCalls, undefined)
|
||||
})
|
||||
|
||||
@@ -240,7 +252,7 @@ describe('applyActivity: skill loads', () => {
|
||||
}
|
||||
assert.equal(state.skillCalls?.length, 100, 'the oldest pending claims drop first')
|
||||
assert.equal(state.skillCalls?.includes('c1'), false)
|
||||
// Non-skill calls and unreadable ids never arm.
|
||||
// Non-skill calls and unreadable ids never arm a skill claim (the bash run still times itself).
|
||||
for (const ev of [
|
||||
toolCall(at(0), 'c2', 'bash'),
|
||||
toolCall(at(0), 7, 'skill'),
|
||||
@@ -248,7 +260,7 @@ describe('applyActivity: skill loads', () => {
|
||||
toolCall(at(0), undefined, 'skill'),
|
||||
{ type: 'tool/call', seq: 1, time: at(0), data: null } as never,
|
||||
]) {
|
||||
assert.ok(applyActivity(state, ev) === state, JSON.stringify(ev.data))
|
||||
assert.deepEqual(applyActivity(state, ev).skillCalls, state.skillCalls, JSON.stringify(ev.data))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -479,6 +491,80 @@ describe('applyActivity: the initiation stamp', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyActivity: turn, tool-run, and step-time attribution', () => {
|
||||
const late = new Date(2026, 0, 1, 23, 30).getTime()
|
||||
const early = new Date(2026, 0, 2, 0, 30).getTime()
|
||||
|
||||
test('a turn books once, on the initiation day of its first settlement', () => {
|
||||
let state = applyActivity({ days: {} }, requestHeader('deepseek-v4', 'deepseek-official'))
|
||||
state = applyActivity(state, stepStart(at(0)))
|
||||
state = applyActivity(state, turnMessage(at(0), 7, { inputTokens: 1 }))
|
||||
state = applyActivity(state, stepEnd(at(0)))
|
||||
state = applyActivity(state, stepStart(at(1)))
|
||||
state = applyActivity(state, turnMessage(at(1), 7, { inputTokens: 1 }))
|
||||
state = applyActivity(state, stepEnd(at(1)))
|
||||
assert.equal(state.days['2026-01-01'].turns, 1)
|
||||
assert.equal(state.days['2026-01-02'].turns, undefined, 'the same turn never re-books on a later day')
|
||||
state = applyActivity(state, stepStart(at(1)))
|
||||
state = applyActivity(state, turnMessage(at(1), 8, { inputTokens: 1 }))
|
||||
assert.equal(state.days['2026-01-02'].turns, 1, 'the next turn books its own day')
|
||||
})
|
||||
|
||||
test('a tool run books its duration on the step-start day, across midnight', () => {
|
||||
let state = applyActivity({ days: {} }, requestHeader('deepseek-v4', 'deepseek-official'))
|
||||
state = applyActivity(state, stepStart(late))
|
||||
state = applyActivity(state, toolCall(late, 'c1', 'bash'))
|
||||
state = applyActivity(state, skillResult(early, 'done', 'c1'))
|
||||
assert.equal(state.days['2026-01-01'].toolCalls, 1)
|
||||
assert.equal(state.days['2026-01-01'].toolsMs, early - late)
|
||||
assert.equal(state.days['2026-01-02'], undefined)
|
||||
})
|
||||
|
||||
test('a step wall time books on the step-start day, across midnight', () => {
|
||||
let state = applyActivity({ days: {} }, stepStart(late))
|
||||
state = applyActivity(state, stepEnd(early))
|
||||
assert.equal(state.days['2026-01-01'].wallMs, early - late)
|
||||
assert.equal(state.days['2026-01-02'], undefined)
|
||||
})
|
||||
|
||||
test('an unreadable or undatable tool start never arms', () => {
|
||||
const state = applyActivity({ days: {} }, requestHeader('deepseek-v4', 'deepseek-official'))
|
||||
assert.ok(applyActivity(state, toolCall(Number.NaN, 'c1', 'bash')) === state, 'a non-finite start')
|
||||
assert.ok(applyActivity(state, toolCall(1e30, 'c2', 'bash')) === state, 'a start outside the day-key range')
|
||||
const negative = applyActivity(state, stepStart(-2 * 86_400_000))
|
||||
assert.equal(applyActivity(negative, toolCall(at(0), 'c3', 'bash')).toolStarts, undefined, 'a step stamp with no local day')
|
||||
})
|
||||
|
||||
test('a later settlement keeps the day’s tool and wall tallies', () => {
|
||||
const start = at(0)
|
||||
let state = applyActivity({ days: {} }, requestHeader('deepseek-v4', 'deepseek-official'))
|
||||
state = applyActivity(state, stepStart(start))
|
||||
state = applyActivity(state, turnMessage(start, 1, { inputTokens: 1 }))
|
||||
state = applyActivity(state, stepEnd(start + 100))
|
||||
state = applyActivity(state, stepStart(start + 200))
|
||||
state = applyActivity(state, toolCall(start + 200, 'c1', 'bash'))
|
||||
state = applyActivity(state, skillResult(start + 300, 'done', 'c1'))
|
||||
state = applyActivity(state, stepStart(start + 400))
|
||||
state = applyActivity(state, turnMessage(start + 400, 2, { inputTokens: 1 }))
|
||||
state = applyActivity(state, stepEnd(start + 500))
|
||||
const day = state.days['2026-01-01']
|
||||
assert.equal(day.turns, 2)
|
||||
assert.equal(day.toolCalls, 1)
|
||||
assert.equal(day.toolsMs, 100)
|
||||
assert.equal(day.wallMs, 200, 'the first and last completed steps')
|
||||
})
|
||||
|
||||
test('a tagged seed cut clears the turn cursor and pending tool starts', () => {
|
||||
let state = applyActivity({ days: {} }, requestHeader('deepseek-v4', 'deepseek-official'))
|
||||
state = applyActivity(state, stepStart(at(0)))
|
||||
state = applyActivity(state, turnMessage(at(0), 1, { inputTokens: 1 }))
|
||||
state = applyActivity(state, toolCall(at(0), 'c1', 'bash'))
|
||||
assert.ok(state.lastTurn !== undefined && state.toolStarts !== undefined)
|
||||
state = applyActivity(state, { type: 'session/end-seed', seq: 1, time: at(0), data: { inherited: true } } as never)
|
||||
assert.deepEqual(state, { days: {} })
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyActivity: the per-day pricing record', () => {
|
||||
test('DeepSeek peak windows book the doubled list price; off-peak books the plain one', () => {
|
||||
let state = applyActivity({ days: {} }, requestHeader('deepseek-v4', 'deepseek-official'))
|
||||
@@ -533,6 +619,11 @@ describe('contextActivity unit: the schemas over the pricing record', () => {
|
||||
state = applyActivity(state, assistantMessage(at(0), { inputTokens: 1, outputTokens: 2 }))
|
||||
assert.equal(def.stateSchema.safeParse(state).success, true)
|
||||
assert.equal(def.stateSchema.safeParse({ days: {}, model: 'm', provider: 'p', stepStart: 1 }).success, true)
|
||||
assert.equal(def.stateSchema.safeParse({ days: {}, lastTurn: 3, toolStarts: { c1: { start: 1, day: '2026-01-01' } } }).success, true)
|
||||
assert.equal(def.stateSchema.safeParse({ days: {}, toolStarts: { c1: { start: -1, day: '2026-01-01' } } }).success, false)
|
||||
assert.equal(def.stateSchema.safeParse({ days: { '2026-01-01': { tokens: 0, requests: 0, turns: 1, toolCalls: 2, toolsMs: 1.5, wallMs: 2 } } }).success, true)
|
||||
assert.equal(def.stateSchema.safeParse({ days: { '2026-01-01': { tokens: 0, requests: 0, turns: 1.5 } } }).success, false)
|
||||
assert.equal(contextActivitySchema.safeParse({ days: { '2026-01-01': { tokens: 0, requests: 0, turns: 1, toolCalls: 2, toolsMs: 1.5, wallMs: 2 } } }).success, true)
|
||||
const view = def.wire.view(state)
|
||||
assert.equal(def.wire.viewSchema.safeParse(view).success, true)
|
||||
assert.equal(contextActivitySchema.safeParse(view).success, true)
|
||||
|
||||
Reference in new issue
Block a user