feat(client): read the official contextBreakdown projection for the composition card

The composition legend and tooltips now show the exact system/tools/
messages counts of the chat composer ring's click-open panel (the
token-meter's contextBreakdown projection) instead of the fold's own
sums re-anchored to the provider total: parts carry the heuristic count
(raw) for display beside the provider-anchored bar width (value), and
the message bucket subdivides into the four surface categories by the
fold's per-category ratios, always summing exactly to the official
message figure. Absent the projection (older harness), the fold's own
sums serve unchanged.
This commit is contained in:
bowenliang123 committed 2026-08-22 16:31:05 +08:00
1 parent d9c09782fc
commit d3cdc263d6
10 files changed
+324 -35

No files matched your search

+65 -10
View File
@@ -1,13 +1,26 @@
/**
* Category presentation config: the six priced buckets (system, tool
* schemas, and the four surface categories) with their chart colors, plus
* `partsOf` which projects a token breakdown (snapshot current or a request
* record) into renderable parts.
* the part builders behind the composition card.
*
* Two figures ride on every part, mirroring the official chat context
* meter's own split: `raw` is the heuristic count (the meter panel's `~`
* rows — identical to the ring panel by construction when the official
* `contextBreakdown` projection feeds it), while `value` is the
* provider-anchored bar width (the ring's fill proportioned by the
* heuristic ratios). Without a provider anchor the two are equal.
*/
import type { Category, RequestRecord, Snapshot } from '../shared/types'
import type { Category, ContextBreakdown, RequestRecord, Snapshot } from '../shared/types'
export interface PartsPart { key: string; color: string; value: number }
export interface PartsPart {
key: string
color: string
/** Bar-width figure (provider-anchored when an anchor applies). */
value: number
/** Heuristic count shown by the legend and tooltips (defaults to value). */
raw?: number
}
export const CATS: { key: Category | 'system' | 'tools'; color: string }[] = [
{ key: 'system', color: '#6366f1' },
@@ -18,23 +31,65 @@ export const CATS: { key: Category | 'system' | 'tools'; color: string }[] = [
{ key: 'tool', color: '#14b8a6' },
]
const MESSAGE_CATS: readonly (Category | 'system' | 'tools')[] = ['user', 'inject', 'assistant', 'tool']
export function partsOf(breakdown: Snapshot['current'] | RequestRecord): PartsPart[] {
return CATS.map((c) => {
return { key: c.key, color: c.color, value: breakdown[c.key] || 0 }
})
}
/**
* Build the pie-consistent raw parts: system/tools/messages take the
* OFFICIAL `contextBreakdown` figures when delivered (the exact counts the
* chat ring's panel shows), with the message bucket subdivided into the
* four surface categories by the fold's per-category ratios (rounding
* residue lands on the largest category, so the four always sum exactly to
* the official message figure). Absent the projection, the fold's own sums
* serve — the same fixed estimator, so identical on image-free sessions.
*/
export function officialParts(
current: Snapshot['current'],
breakdown: ContextBreakdown | null,
): PartsPart[] {
const foldSurface = current.user + current.inject + current.assistant + current.tool
const system = breakdown?.systemTokens ?? current.system
const tools = breakdown?.toolsTokens ?? current.tools
const messages = breakdown?.messageTokens ?? foldSurface
const shares: Record<string, number> = { system, tools }
if (foldSurface > 0) {
let assigned = 0
let largest: Category = 'user'
for (const cat of MESSAGE_CATS) {
const count = Math.round(messages * (current[cat as Category] / foldSurface))
shares[cat] = count
assigned += count
if (current[cat as Category] > current[largest]) largest = cat as Category
}
// Rounding residue lands on the largest category; clamp so a tiny
// message bucket with several rounded-up shares never goes negative.
shares[largest] = Math.max(0, shares[largest] + messages - assigned)
} else {
for (const cat of MESSAGE_CATS) shares[cat] = 0
}
return CATS.map(c => ({ key: c.key, color: c.color, value: shares[c.key] ?? 0 }))
}
/**
* Reproportion heuristic parts so they sum to a provider-anchored target —
* the same trick the official ContextMeter uses: the heuristic breakdown
* supplies the composition RATIOS, the provider sample the total. Returns
* the parts unchanged when no anchor applies.
* supplies the composition RATIOS, the provider sample the total. The
* anchored figure rides `value` (bar widths); the heuristic count stays on
* `raw` for the legend and tooltips. Returns the parts unchanged when no
* anchor applies.
*/
export function anchoredParts(parts: PartsPart[], target: number | null): PartsPart[] {
if (target === null || target <= 0) return parts
const sourced = parts.map(p => ({ ...p, raw: p.raw ?? p.value }))
if (target === null || target <= 0) return sourced
let total = 0
for (const p of parts) total += p.value
if (total <= 0 || total === target) return parts
for (const p of sourced) total += p.raw
if (total <= 0) return sourced
if (total === target) return sourced.map(p => ({ ...p, value: p.raw }))
const scale = target / total
return parts.map(p => ({ ...p, value: Math.round(p.value * scale) }))
return sourced.map(p => ({ ...p, value: Math.round(p.raw * scale) }))
}
+8 -2
View File
@@ -12,7 +12,7 @@ import type * as ReactNS from 'react'
import { headlineOf } from '../headline'
import { modalStoreOf, takePendingConsume } from '../modalStore'
import type { ClientCtx, SessionStandardProps, SessionsFace } from '../services'
import { contextPressureOf, headersOf, timelineOf } from '../services'
import { contextBreakdownOf, contextPressureOf, headersOf, timelineOf } from '../services'
import type { ViewKit } from '../viewkit'
import { makeContextBrowser } from './browser'
import { makeCurrentComposition } from './currentComposition'
@@ -55,6 +55,12 @@ export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextM
const pressure = typeof props.useProjection === 'function'
? contextPressureOf(props.useProjection('contextPressure'))
: null
// Heuristic composition counts from the official token-meter
// `contextBreakdown` projection (the chat ring panel's rows); absent
// value degrades to the fold's own sums inside headlineOf.
const breakdown = typeof props.useProjection === 'function'
? contextBreakdownOf(props.useProjection('contextBreakdown'))
: null
// Header-content epochs for the browser's system/tools sections (absent
// on older hosts -> tokens-only degradation, same as the Context tab).
const headers = typeof props.useProjection === 'function'
@@ -98,7 +104,7 @@ export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextM
if (!open) return null
const head = data !== null ? headlineOf(data, pressure) : null
const head = data !== null ? headlineOf(data, pressure, breakdown) : null
// The composition card carries the model/provider subtitle; keep the
// dialog header to just the title + close (model/provider below once).
const subtitle = data !== null ? (data.model ? data.model : '') + (data.provider ? ' · ' + data.provider : '') : ''
+12 -3
View File
@@ -13,7 +13,7 @@ import type * as ReactNS from 'react'
import type { ContextEventRecord, RequestRecord } from '../../shared/types'
import { headlineOf } from '../headline'
import type { SessionStandardProps } from '../services'
import { contextPressureOf, headersOf, timelineOf, tokenUsageOf } from '../services'
import { contextBreakdownOf, contextPressureOf, headersOf, timelineOf, tokenUsageOf } from '../services'
import type { ClientCtx, ConversationFace, ImageRefLike } from '../services'
import type { ViewKit } from '../viewkit'
import { makeContextBrowser } from './browser'
@@ -87,6 +87,14 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi
const usage = typeof props.useProjection === 'function'
? tokenUsageOf(props.useProjection('tokenUsage'))
: null
// Heuristic composition counts come from the OFFICIAL token-meter
// `contextBreakdown` projection — the exact system/tools/messages rows
// the chat ring's click-open panel shows, so the overview legend reads
// identically by construction. Absent key degrades to the fold's own
// sums (same fixed estimator) inside headlineOf.
const breakdown = typeof props.useProjection === 'function'
? contextBreakdownOf(props.useProjection('contextBreakdown'))
: null
// The header-content companion projection (full system prompt + tool
// schemas) for the Context browser card; absent key = older Host half,
// the card degrades those sections to tokens-only with a note.
@@ -236,8 +244,9 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi
// the fixed 4-chars/token heuristic can undercount by ~10-15% on
// CJK-heavy sessions — so this is the headline, and the heuristic
// composition below is anchored to it (proportions stay heuristic).
// Shared with the /context popup (headline.ts).
const head = headlineOf(data, pressure)
// Shared with the /context popup (headline.ts). The composition counts
// ride the official `contextBreakdown` rows (the chat ring panel).
const head = headlineOf(data, pressure, breakdown)
// The cost cell prices in the locale's currency (zh -> CNY, else USD);
// read the active locale at render time — the locale subscription above
+13 -4
View File
@@ -101,13 +101,18 @@ export function makeStackedBar(kit: ViewKit): (props: StackedBarProps) => ReactN
}
} else {
let acc = 0
// Counts come from the part's heuristic `raw` figure (the ring
// panel's rows); widths ride the anchored `value` (the ring's fill).
let rawTotal = 0
for (const p of props.parts) rawTotal += p.raw ?? p.value
for (const p of props.parts) {
const pct = scale > 0 ? p.value / scale * 100 : 0
if (p.key === props.hoverKey && p.value > 0) {
const count = p.raw ?? p.value
tip = {
// "(pct%)" is a share of the OCCUPIED total — the solid box
// that appears on hover frames exactly this reference region.
text: `${catLabel(p.key)} ≈${fmt(p.value)} (${Math.round(p.value / total * 100)}%) `
text: `${catLabel(p.key)} ≈${fmt(count)} (${rawTotal > 0 ? Math.round(count / rawTotal * 100) : 0}%) `
+ t('overview.ofUsed'),
leftPct: Math.max(12, Math.min(acc + pct / 2, 88)),
}
@@ -205,11 +210,15 @@ export function makeLegend(kit: ViewKit): (props: {
hoverKey?: string | null
onHoverKey?: (key: string | null) => void
}): ReactNS.ReactElement {
// The legend reports the heuristic counts (`raw` — the chat ring panel's
// rows), never the anchored bar widths, so its figures read identically
// to the official context meter.
let total = 0
for (const p of props.parts) total += p.value
for (const p of props.parts) total += p.raw ?? p.value
return (
<div className="lc-legend">
{props.parts.map((p) => {
const count = p.raw ?? p.value
const on = props.hoverKey !== undefined && props.hoverKey === p.key
return (
<span
@@ -222,8 +231,8 @@ export function makeLegend(kit: ViewKit): (props: {
<i style={{ background: p.color }} />
<span className="lc-chip-label">{catLabel(p.key)}</span>
<span className="lc-chip-nums">
{'≈' + fmt(p.value)}
{total > 0 ? <em>{`${Math.round(p.value / total * 100)}%`}</em> : null}
{'≈' + fmt(count)}
{total > 0 ? <em>{`${Math.round(count / total * 100)}%`}</em> : null}
</span>
</span>
)
+23 -12
View File
@@ -4,17 +4,17 @@
* capacity it scales against, and the composition parts anchored to that
* total (heuristic ratios, provider-anchored sum).
*
* The provider anchor is the OFFICIAL token-meter `contextPressure`
* projection (read via `useProjection('contextPressure')`) — the same
* "projectedTokens" the chat's context ring displays; the Host no longer
* mirrors it inside `contextTimeline`. When the projection is absent (older
* harness, or the meter is not composed), the headline falls back to the
* newest request's provider prompt plus the heuristic surface movement since
* it was logged, and finally to the raw heuristic total.
* Two official token-meter projections feed it, the same keys the chat's
* composer ring reads: `contextPressure` supplies the anchored total (the
* ring's percentage), and `contextBreakdown` supplies the composition
* counts (the ring panel's system/tools/messages `~` rows) so the legend
* figures stay identical to the panel's by construction. Either key absent
* (older harness, meter not composed) degrades to the fold's own sums —
* the same fixed estimator.
*/
import type { ContextPressure, ContextTimeline } from '../shared/types'
import { anchoredParts, partsOf, type PartsPart } from './categories'
import type { ContextBreakdown, ContextPressure, ContextTimeline } from '../shared/types'
import { anchoredParts, officialParts, type PartsPart } from './categories'
export interface Headline {
/** Best-known occupancy of the next request (projected ?? derived ?? heuristic total). */
@@ -23,11 +23,19 @@ export interface Headline {
window?: number
/** tokens / window, clamped to 100; null without a window. */
pct: number | null
/** Composition parts anchored to the provider total when one exists. */
/**
* Composition parts: `value` is the provider-anchored bar width (the
* ring's fill), `raw` the official heuristic count (the ring panel's
* rows) shown by the legend and tooltips.
*/
parts: PartsPart[]
}
export function headlineOf(data: ContextTimeline, pressure: ContextPressure | null = null): Headline {
export function headlineOf(
data: ContextTimeline,
pressure: ContextPressure | null = null,
breakdown: ContextBreakdown | null = null,
): Headline {
const current = data.current
// The official projection's whole value is the newest usage sample carried
// forward by the heuristic surface movement since it was taken — the same
@@ -50,6 +58,9 @@ export function headlineOf(data: ContextTimeline, pressure: ContextPressure | nu
: data.contextWindow
const tokens = occupancyTokens ?? current.total
const pct = window !== undefined && window > 0 ? Math.min(100, Math.round(tokens / window * 100)) : null
const parts = anchoredParts(partsOf(current), occupancyTokens !== null && tokens > 0 ? tokens : null)
const parts = anchoredParts(
officialParts(current, breakdown),
occupancyTokens !== null && tokens > 0 ? tokens : null,
)
return { tokens, window, pct, parts }
}
+18 -1
View File
@@ -11,7 +11,7 @@
*/
import type { Context } from '@deepseek-ai/cordis'
import type { ContextHeaders, ContextPressure, ContextTimeline, TokenUsage } from '../shared/types'
import type { ContextBreakdown, ContextHeaders, ContextPressure, ContextTimeline, TokenUsage } from '../shared/types'
export interface LocaleService {
register(ns: string, dicts: Record<string, Record<string, string>>): () => void
@@ -223,6 +223,23 @@ export function contextPressureOf(value: unknown): ContextPressure | null {
return data as ContextPressure | null
}
/**
* Narrow a delivered projection value to the official token-meter
* `contextBreakdown` projection (the heuristic composition rows of the chat
* ring's panel). Every figure must be a finite number — a partial/corrupt
* value degrades to null so the composition card falls back to the fold's
* own sums instead of mixing sources.
*/
export function contextBreakdownOf(value: unknown): ContextBreakdown | null {
const data = asRecord(value)
if (data === null) return null
const { systemTokens, toolsTokens, messageTokens } = data
if (typeof systemTokens !== 'number' || !Number.isFinite(systemTokens)) return null
if (typeof toolsTokens !== 'number' || !Number.isFinite(toolsTokens)) return null
if (typeof messageTokens !== 'number' || !Number.isFinite(messageTokens)) return null
return { systemTokens, toolsTokens, messageTokens }
}
/**
* Narrow a delivered projection value to the official token-meter
* `tokenUsage` projection (durable cumulative provider usage). Absent key or
+16
View File
@@ -138,6 +138,22 @@ export interface ContextPressure {
contextWindow?: number
}
/**
* The official token-meter `contextBreakdown` projection: the heuristic
* composition rows the chat ring's click-open panel shows (system prompt,
* tool schemas, conversation). The Client reads this key directly so the
* composition card's proportions AND counts stay identical to the panel's
* by construction; the message bucket is subdivided into the plugin's four
* surface categories by the fold's per-category ratios. Absent key/value =
* an older harness without the meter's projection units — callers fall back
* to the fold's own sums (identical estimator, minus the image correction).
*/
export interface ContextBreakdown {
systemTokens: number
toolsTokens: number
messageTokens: number
}
/**
* The official token-meter `tokenUsage` projection (registered by
* `@deepseek-ai/dsh-token-meter` on the same `SessionProjectionMap`): durable
+23
View File
@@ -498,6 +498,29 @@ test('context view: trend chart, stats board, plugin info, hover linking, granul
bed.dataValue = snapshot
tr = renderView()
// ---- the OFFICIAL token-meter `contextBreakdown` projection drives the
// legend counts (the chat ring panel's rows): system/tools read it
// verbatim, the message bucket subdivides by the fold ratios, and the four
// surface categories always sum exactly to the delivered messageTokens ----
// Fixture fold sums: user 30 / inject 5 / assistant 15 / tool 20 (70).
bed.breakdownValue = { systemTokens: 111, toolsTokens: 222, messageTokens: 600 }
tr = renderView()
const chipsBd = byClass(tr, 'lc-chip')
// Chip layout: [icon, label, nums] with nums = ['≈N', <em>share%</em>] —
// read the ≈ text node directly (a flat textOf would glue the share on).
const chipVal = (i) => chipsBd[i].args[4].args[2]
assert.equal(chipVal(0), '≈111', 'system chip shows the official systemTokens')
assert.equal(chipVal(1), '≈222', 'tools chip shows the official toolsTokens')
// 600 subdivided by 30/5/15/20 of 70 -> 257/43/129/171 (residue to user).
assert.equal(chipVal(2), '≈257', 'user chip subdivides the official messageTokens')
assert.equal(chipVal(5), '≈171', 'tool chip subdivides the official messageTokens')
const msgSum = [2, 3, 4, 5]
.map(i => Number(chipVal(i).slice(1)))
.reduce((a, b) => a + b, 0)
assert.equal(msgSum, 600, 'surface categories sum exactly to the official message figure')
bed.breakdownValue = undefined
tr = renderView()
console.log('✔ chart render test passed (context stats board, free window hover, fixed-width bars, scroll container, turn ranges, hover linking, overview tooltip, turn strip, granularity toggle, edge fades, full history, right-anchored default, message times, event range labels, detail marker chip, overview actual)')
// ---- Context browser card: step picker + category accordion + element
+5 -3
View File
@@ -54,7 +54,7 @@ export const FAKE_PRIMITIVES = {
}
export const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'ERR:', 'error.retry': 'retry', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'overview.used': 'of context used', 'overview.free': 'Free window', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}', 'stats.recycleSub': '{c} compactions · {p} prunes', 'tip.step': 'Turn {t} · Step {s}', 'tip.turn': 'Turn {t} · {n} steps', 'tip.total': 'total ≈ {n}', 'tip.actual': ' (actual {n})', 'trend.title': 'History', 'trend.empty': 'empty trend', 'cmd.close': 'Close', 'cat.user': 'User', 'browser.live': 'Live (next request)', 'browser.liveNow': 'Live · next request', 'browser.items': '{n} items', 'browser.noContent': 'outside the loaded window', 'browser.loading': 'loading older history', 'browser.preview': 'preview', 'browser.noHeader': 'older plugin build',
'browser.deltaHint': 'vs previous turn', 'overview.compactReserve': 'compact reserve {pct}%', 'tool.desc': 'Description', 'tool.params': 'Parameters', 'tool.paramsEmpty': '(no parameters)', 'tool.jsonToggle': 'View Raw JSON', 'tool.jsonHide': 'Collapse', 'rich.raw': 'Raw', 'rich.md': 'Markdown', 'rich.toMd': 'View as Markdown', 'rich.toRaw': 'View Raw Text', 'block.thinking': 'Reasoning', 'block.answer': 'Response', 'attach.images': 'Images', 'attach.other': 'Other content', 'attach.image': 'Image', 'attach.open': 'Open full image', 'attach.loading': '…', 'attach.loadFailed': 'Load failed · click to retry', 'attach.orig': 'original {d}' }
'browser.deltaHint': 'vs previous turn', 'overview.compactReserve': 'compact reserve {pct}%', 'tool.desc': 'Description', 'tool.params': 'Parameters', 'tool.paramsEmpty': '(no parameters)', 'tool.jsonToggle': 'View Raw JSON', 'tool.jsonHide': 'Collapse', 'rich.raw': 'Raw', 'rich.md': 'Markdown', 'rich.toMd': 'View as Markdown', 'rich.toRaw': 'View Raw Text', 'block.thinking': 'Reasoning', 'block.answer': 'Response', 'attach.images': 'Images', 'attach.other': 'Other content', 'attach.image': 'Image', 'attach.open': 'Open full image', 'attach.loading': '…', 'attach.loadFailed': 'Load failed · click to retry', 'attach.orig': 'original {d}', 'attach.tokens': '≈{n} tokens', 'attach.tokensTip': 'estimated tokens' }
/** Base timeline fixture: 4 requests across 3 turns, 2 live nodes. */
export const snapshot = {
@@ -133,6 +133,7 @@ export async function bootViewBed() {
// projection/service holders the tests arm between renders
dataValue: null,
pressureValue: undefined,
breakdownValue: undefined,
usageValue: undefined,
headersValue: undefined,
useSessionHolder: undefined,
@@ -299,8 +300,9 @@ export async function bootViewBed() {
sessionId: 's1',
useProjection: (key) => (key === 'contextTimeline' ? bed.dataValue
: (key === 'contextPressure' ? bed.pressureValue
: (key === 'contextHeaders' ? bed.headersValue
: (key === 'tokenUsage' ? bed.usageValue : undefined)))),
: (key === 'contextBreakdown' ? bed.breakdownValue
: (key === 'contextHeaders' ? bed.headersValue
: (key === 'tokenUsage' ? bed.usageValue : undefined))))),
useSession: bed.useSessionHolder,
loadOlderHistory: bed.loadOlderHolder,
}))
+141
View File
@@ -0,0 +1,141 @@
/**
* Multimodal token estimates and pie-chart consistency:
*
* - Host fold: `image` blocks price through the official DeepSeek docs
* calculator (117-384 tokens by pixel dimensions), not the token-meter's
* generic JSON branch (~40); unknown dimensions degrade to that JSON
* price; compaction shadows stay internally consistent with the
* corrected prices.
* - Client headline: with the official `contextBreakdown` projection
* delivered, the composition counts (part.raw) are the panel's exact
* system/tools/messages figures — the four surface categories subdivide
* the message bucket and always sum to it — while the bar widths
* (part.value) stay anchored to the provider total. Without the
* projection the fold's own sums serve.
*/
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { apply } from '../lib/index.js'
import { headlineOf } from '../src/client/headline.ts'
const defs = new Map()
apply({
inject(list, cb) { cb(this) },
effect(fn) { fn(); return () => {} },
sessionProjections: { register(d) { defs.set(d.key, d); return () => {} } },
})
const def = defs.get('contextTimeline')
const drive = (events) => {
let st = def.init()
for (const ev of events) st = def.apply(st, ev)
return def.view(st)
}
test('host fold: image blocks price by the official DeepSeek image token formula', () => {
// 2048×1365 → 313 tokens on the official docs calculator; the durable ref
// JSON would have priced ~40.
const v = drive([
{ seq: 1, type: 'user/message', time: 1000, data: { content: [
{ type: 'text', text: 'hi' },
{ type: 'image', attachment: { attachmentId: 'a1', mediaType: 'image/png', bytes: 153600, width: 2048, height: 1365, name: 'shot.png' } },
] } },
{ seq: 2, type: 'user/message', time: 2000, data: { content: [
{ type: 'image', attachment: { attachmentId: 'a2', mediaType: 'image/jpeg', bytes: 512000, width: 800, height: 600 } },
] } },
// Unknown dimensions: the meter's generic JSON branch prices the ref.
{ seq: 3, type: 'user/message', time: 3000, data: { content: [
{ type: 'image', attachment: { attachmentId: 'a3' } },
] } },
// Images nested in a tool result price the same way (recursion).
{ seq: 4, type: 'tool/call', time: 4000, data: { callId: 'c1', name: 'read', arguments: '{}' } },
{ seq: 5, type: 'tool/result', time: 4100, data: { callId: 'c1', message: { source: { kind: 'tool', callId: 'c1' }, content: [
{ type: 'tool-result', toolCallId: 'c1', content: [
{ type: 'image', attachment: { attachmentId: 'a4', mediaType: 'image/png', bytes: 1000, width: 512, height: 512 } },
] },
] } } },
])
const n1 = v.nodes.find(n => n.seq === 1)
// text 'hi' = ceil(2/4)+4 = 5; image 313+4; role framing +4.
assert.equal(n1.tokens, 5 + 313 + 4 + 4, 'text + corrected image + framing')
const n2 = v.nodes.find(n => n.seq === 2)
assert.equal(n2.tokens, 341 + 4 + 4, '800×600 → 341 + block + role')
const jsonFallback = 4 + Math.ceil(JSON.stringify({ attachmentId: 'a3' }).length / 4)
// The fold sees the whole block: { type:'image', attachment:{...} }.
const wholeBlock = 4 + Math.ceil(JSON.stringify({ type: 'image', attachment: { attachmentId: 'a3' } }).length / 4)
const n3 = v.nodes.find(n => n.seq === 3)
assert.equal(n3.tokens, wholeBlock + 4, 'unknown dims degrade to the JSON price (+ role)')
assert.ok(jsonFallback < wholeBlock)
const n5 = v.nodes.find(n => n.seq === 5)
// tool-result: outer +4, nested image 201+4 (512×512 → 201), role +4.
assert.equal(n5.tokens, 201 + 4 + 4 + 4, 'nested tool-result image priced by dims')
// Category sums carry the corrected figures.
assert.equal(v.current.user, n1.tokens + n2.tokens + n3.tokens)
// Compaction shadows the two image messages: the fold subtracts its own
// corrected prices, keeping the surface sum internally consistent.
const v2 = drive([
{ seq: 1, type: 'user/message', time: 1000, data: { content: [
{ type: 'image', attachment: { attachmentId: 'a1', mediaType: 'image/png', bytes: 153600, width: 2048, height: 1365 } },
] } },
{ seq: 2, type: 'user/message', time: 2000, data: { content: [{ type: 'text', text: 'keep me' }] } },
{ seq: 3, type: 'compaction/summary', time: 3000, data: { shadowedTokenCount: 999, shadowedSeqs: [1] } },
{ seq: 4, type: 'user/message', time: 3100, surfaceOp: { op: 'replace', start: 1, end: 1 }, data: { content: [{ type: 'text', text: 'summary' }] } },
])
const kept = v2.nodes.find(n => n.seq === 2)
const summary = v2.nodes.find(n => n.seq === 4)
assert.equal(v2.current.user, kept.tokens + summary.tokens, 'shadowed image leaves no residue')
console.log('✔ host fold image pricing passed (official formula, JSON fallback, nesting, compaction shadow)')
})
test('client headline: composition counts match the ring panel, widths stay anchored', () => {
const data = {
ok: true,
current: { system: 100, tools: 200, user: 300, inject: 100, assistant: 400, tool: 200, total: 1300 },
toolList: [],
requests: [{ seq: 1, time: 1, system: 100, tools: 200, user: 300, inject: 100, assistant: 400, tool: 200, total: 1300, prompt: 2600 }],
events: [], nodes: [], droppedNodes: 0, archive: [],
}
// The official breakdown disagrees with the fold sums (e.g. corrected
// image pricing in the fold): the DELIVERED panel figures win outright.
const breakdown = { systemTokens: 110, toolsTokens: 210, messageTokens: 1000 }
const pressure = { projectedTokens: 5200, contextWindow: 128000 }
const head = headlineOf(data, pressure, breakdown)
const raw = Object.fromEntries(head.parts.map(p => [p.key, p.raw]))
assert.equal(raw.system, 110, 'system = official figure')
assert.equal(raw.tools, 210, 'tools = official figure')
const msgRaw = raw.user + raw.inject + raw.assistant + raw.tool
assert.equal(msgRaw, 1000, 'surface categories sum exactly to the official message figure')
// Subdivision follows the fold's ratios (300/100/400/200 of 1000).
assert.equal(raw.user, 300)
assert.equal(raw.assistant, 400)
// Bar widths anchor to the provider total: they sum to the anchor.
const anchored = head.parts.reduce((s, p) => s + p.value, 0)
assert.equal(anchored, Math.round(5200), 'anchored bar fills the provider total')
// …and keep the official ratios (uniform scale of the raw figures).
const sysBar = head.parts.find(p => p.key === 'system')
assert.equal(sysBar.value, Math.round(110 * (5200 / 1320)))
assert.equal(head.tokens, 5200)
assert.equal(head.pct, Math.round(5200 / 128000 * 100))
// No breakdown delivered (older harness): the fold's own sums serve.
const fallback = headlineOf(data, pressure, null)
const rawFb = Object.fromEntries(fallback.parts.map(p => [p.key, p.raw]))
assert.equal(rawFb.system, 100)
assert.equal(rawFb.user + rawFb.inject + rawFb.assistant + rawFb.tool, 1000)
assert.equal(rawFb.assistant, 400)
// No anchor either (no pressure, no provider usage on any request):
// value === raw on every part.
const anchorless = { ...data, requests: [] }
const plain = headlineOf(anchorless, null, breakdown)
for (const p of plain.parts) assert.equal(p.value, p.raw)
// Tiny message bucket: rounding residue never turns a category negative.
const tiny = headlineOf({
...data,
current: { system: 0, tools: 0, user: 10, inject: 10, assistant: 10, tool: 10, total: 40 },
requests: [],
}, null, { systemTokens: 0, toolsTokens: 0, messageTokens: 2 })
for (const p of tiny.parts) assert.ok((p.raw ?? 0) >= 0, `${p.key} stays non-negative`)
console.log('✔ headline pie-consistency passed (official counts, exact subdivision, anchored widths)')
})