diff --git a/src/client/categories.ts b/src/client/categories.ts index 44971ce..5dddb9b 100644 --- a/src/client/categories.ts +++ b/src/client/categories.ts @@ -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 = { 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) })) } diff --git a/src/client/components/contextModal.tsx b/src/client/components/contextModal.tsx index bd433b3..17d8b3c 100644 --- a/src/client/components/contextModal.tsx +++ b/src/client/components/contextModal.tsx @@ -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 : '') : '' diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx index c96d9c1..76542f2 100644 --- a/src/client/components/contextView.tsx +++ b/src/client/components/contextView.tsx @@ -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 diff --git a/src/client/components/stackedBar.tsx b/src/client/components/stackedBar.tsx index ab4a623..7fa34ff 100644 --- a/src/client/components/stackedBar.tsx +++ b/src/client/components/stackedBar.tsx @@ -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 (
{props.parts.map((p) => { + const count = p.raw ?? p.value const on = props.hoverKey !== undefined && props.hoverKey === p.key return ( {catLabel(p.key)} - {'≈' + fmt(p.value)} - {total > 0 ? {`${Math.round(p.value / total * 100)}%`} : null} + {'≈' + fmt(count)} + {total > 0 ? {`${Math.round(count / total * 100)}%`} : null} ) diff --git a/src/client/headline.ts b/src/client/headline.ts index a63154e..c9d494a 100644 --- a/src/client/headline.ts +++ b/src/client/headline.ts @@ -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 } } diff --git a/src/client/services.ts b/src/client/services.ts index 8641190..7ed4049 100644 --- a/src/client/services.ts +++ b/src/client/services.ts @@ -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>): () => 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 diff --git a/src/shared/types.ts b/src/shared/types.ts index 1e0361a..714d47e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 diff --git a/tests/chart.spec.mjs b/tests/chart.spec.mjs index f3e5f41..b9323d9 100644 --- a/tests/chart.spec.mjs +++ b/tests/chart.spec.mjs @@ -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', share%] — + // 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 diff --git a/tests/helpers/viewBed.mjs b/tests/helpers/viewBed.mjs index bbec9bf..a3d0f85 100644 --- a/tests/helpers/viewBed.mjs +++ b/tests/helpers/viewBed.mjs @@ -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, })) diff --git a/tests/multimodal.spec.mjs b/tests/multimodal.spec.mjs new file mode 100644 index 0000000..78f01a3 --- /dev/null +++ b/tests/multimodal.spec.mjs @@ -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)') +})