From 07aae7626140a3af63daee194d4c53e9e8a5febf Mon Sep 17 00:00:00 2001 From: Bowen Liang Date: Tue, 1 Sep 2026 23:36:04 +0800 Subject: [PATCH] fix(client): count the system prompt on legacy contextHeaders wire values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The context browser read the system row's item count from `header.systemTokens`, a field only the post-#37 metadata wire shape carries — but a host still running the pre-#37 view (a stale watch build, an app not restarted since the upgrade) delivers content-bearing epochs where the system TEXT rides the value instead. The row then read 0 项 while the ≈tokens figure still rendered, because the two come from independent projections. Diagnosed against a real session: the cached projection row was pre-#37 shaped, holding a 6261-char system string (≈1570 tokens shown, count 0). `headersOf` now normalizes both generations: unpriced legacy entries get the meter's own heuristic, priced entries and new-shape values keep the zero-copy pass-through. The heuristic (ceil(chars/4) + 4) moves to shared/estimate.ts so the host fold and the client boundary price identically. Regression tests cover the boundary matrix and the browser row through the real call path; the #37 slimming itself is untouched. --- src/client/services.ts | 23 ++++++++++++++++++- src/host/fold.ts | 4 ++-- src/host/headers.ts | 7 +++--- src/host/pricing.ts | 6 +---- src/shared/estimate.ts | 16 +++++++++++++ tests/client/components/browser.spec.ts | 21 ++++++++++++++++- tests/client/services.spec.ts | 30 +++++++++++++++++++++++++ tests/host/headers.spec.ts | 10 ++++----- tests/host/pricing.spec.ts | 12 +++++----- 9 files changed, 106 insertions(+), 23 deletions(-) create mode 100644 src/shared/estimate.ts diff --git a/src/client/services.ts b/src/client/services.ts index a90bbd2..47844e6 100644 --- a/src/client/services.ts +++ b/src/client/services.ts @@ -11,6 +11,7 @@ */ import type { Context } from '@deepseek-ai/cordis' +import { estimateSystemTokens } from '../shared/estimate' import type { ContextBreakdown, ContextHeaders, ContextPressure, ContextTimeline, HeaderEpochContent, TimingTotals, TokenUsage, ToolTimingTotals } from '../shared/types' export interface LocaleService { @@ -417,6 +418,13 @@ export function timingOf(value: unknown): TimingTotals | null { * browser's tools/sections reads, so the WHOLE projection degrades to null * and the card falls back to its metadata-only note. The epoch CONTENT is * not part of this value — the browser fetches it per epoch on demand. + * + * The pre-#37 wire generation carries the system TEXT instead of its token + * price (a host still running the old view — stale watch build, an app not + * restarted since the upgrade — serves it from its cache verbatim), so the + * two generations are normalized to the metadata shape here: unpriced legacy + * entries get the shared meter heuristic applied, priced ones and + * new-shape values pass through untouched. */ export function headersOf(value: unknown): ContextHeaders | null { const headers = asRecord(value) @@ -427,7 +435,20 @@ export function headersOf(value: unknown): ContextHeaders | null { if (!Array.isArray(entry.tools)) return null if (entry.systemTokens !== undefined && (typeof entry.systemTokens !== 'number' || !Number.isFinite(entry.systemTokens))) return null } - return headers as unknown as ContextHeaders + let legacy = false + for (const entry of headers.headers as { systemTokens?: unknown; system?: unknown }[]) { + if (entry.systemTokens === undefined && typeof entry.system === 'string' && entry.system !== '') { + legacy = true + break + } + } + if (!legacy) return headers as unknown as ContextHeaders + return { + headers: (headers.headers as { systemTokens?: number; system?: unknown }[]).map((entry) => { + if (entry.systemTokens !== undefined) return entry + return { ...entry, systemTokens: estimateSystemTokens(entry.system) || undefined } + }), + } as unknown as ContextHeaders } export interface TriggerCandidate { diff --git a/src/host/fold.ts b/src/host/fold.ts index 09137ba..2d6957b 100644 --- a/src/host/fold.ts +++ b/src/host/fold.ts @@ -20,10 +20,10 @@ */ import type { Category, ContextEventRecord, CostFamilyUsage, RequestRecord, SessionCostUsage, Snapshot, SurfaceNode, TimingTotals, ToolTimingTotals } from '../shared/types' +import { estimateSystemTokens } from '../shared/estimate' import type { FoldBounds } from './config' import { estimateMessage, - estimateSystem, estimateToolsTotal, firstText, imageCountOf, @@ -588,7 +588,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds const s = ensure() // Tools TOTAL = dsh's whole-array price (one JSON string of every schema). s.toolsTokens = estimateToolsTotal(tools) - s.systemTokens = estimateSystem(header.system) + s.systemTokens = estimateSystemTokens(header.system) // Current route/model: the durable request envelope is the source of // truth (request/context is only route/capacity metadata, appended // AFTER request/header per request — see agent-loop `buildRequest`). diff --git a/src/host/headers.ts b/src/host/headers.ts index 7f34a02..6432fd7 100644 --- a/src/host/headers.ts +++ b/src/host/headers.ts @@ -37,7 +37,8 @@ import { z } from 'zod' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition } from './compat' import type { ContextHeaders, HeaderRecord, HeaderTool } from '../shared/types' -import { estimateSystem, estimateToolSchema } from './pricing' +import { estimateToolSchema } from './pricing' +import { estimateSystemTokens } from '../shared/estimate' /** Retention cap on header epochs (metadata only; changes are rare; 50 is generous). */ const HEADERS_MAX = 50 @@ -137,7 +138,7 @@ function recordOf(event: SessionEvent): StoredHeaderRecord | null { }), } if (typeof header.system === 'string' && header.system.length > 0) { - record.systemTokens = estimateSystem(header.system) + record.systemTokens = estimateSystemTokens(header.system) } return record } @@ -169,7 +170,7 @@ export function createContextHeadersDefinition( }), } const systemTokens = h.systemTokens - ?? (typeof h.system === 'string' && h.system !== '' ? estimateSystem(h.system) : undefined) + ?? (typeof h.system === 'string' && h.system !== '' ? estimateSystemTokens(h.system) : undefined) if (systemTokens !== undefined) record.systemTokens = systemTokens return record }), diff --git a/src/host/pricing.ts b/src/host/pricing.ts index 96730a3..aa33723 100644 --- a/src/host/pricing.ts +++ b/src/host/pricing.ts @@ -92,11 +92,7 @@ export function estimateMessage(message: { content?: ContentBlock[] } | undefine return estimateBlocks(message?.content) + ROLE_OVERHEAD } -export function estimateSystem(text: unknown): number { - if (typeof text !== 'string' || text.length === 0) return 0 - return Math.ceil(text.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD -} - +/** The shared meter heuristic over rendered system-prompt text (shared/estimate.ts). */ /** Per-tool price for the top-tools display (the total uses dsh's whole-array price). */ export function estimateToolSchema(tool: unknown): number { return Math.ceil(JSON.stringify(tool).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD diff --git a/src/shared/estimate.ts b/src/shared/estimate.ts new file mode 100644 index 0000000..4f86bbf --- /dev/null +++ b/src/shared/estimate.ts @@ -0,0 +1,16 @@ +/** + * Token heuristics shared by the host fold and the client boundary — the + * harness token-meter's own fixed-density figure (dsh-token-meter/estimate.ts: + * ~4 chars ≈ 1 token, +4 role framing). Priced identically on both sides so a + * legacy value normalized at the client boundary matches what the host view + * would have served. + */ + +const CHARS_PER_TOKEN = 4 +const ROLE_OVERHEAD = 4 + +/** Price rendered system-prompt text; 0 for absent/empty/non-string input. */ +export function estimateSystemTokens(text: unknown): number { + if (typeof text !== 'string' || text.length === 0) return 0 + return Math.ceil(text.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD +} diff --git a/tests/client/components/browser.spec.ts b/tests/client/components/browser.spec.ts index bbf15c6..eb8e68b 100644 --- a/tests/client/components/browser.spec.ts +++ b/tests/client/components/browser.spec.ts @@ -10,7 +10,7 @@ import { h } from '../../../src/client/react' import { makeContextBrowser, type ContextBrowserProps } from '../../../src/client/components/browser' import { makeStackedBar } from '../../../src/client/components/stackedBar' import { UNKNOWN_TOOL_SOURCE, type ContextHeaders, type ContextTimeline, type HeaderEpochContent, type RequestRecord, type SurfaceNode } from '../../../src/shared/types' -import type { ConversationNodeLike, ImageLoader } from '../../../src/client/services' +import { headersOf, type ConversationNodeLike, type ImageLoader } from '../../../src/client/services' import { click, flush, hover, makeKit, mount, query, queryAll, text, unhover, type Mounted } from '../helpers/kit' const kit = makeKit() @@ -486,6 +486,25 @@ describe('ContextBrowser header epochs', () => { await m.unmount() }) + test('a legacy content-bearing epoch (pre-#37 wire) still counts the system prompt', async () => { + // The value shape a host running the pre-#37 view serves: the system TEXT + // rides the epoch and no systemTokens price exists (the real cached row + // that read 0 项 while the breakdown still showed ≈1.6k). Routed through + // headersOf — the boundary the real callers sanitize the projection with. + const legacy = { + headers: [{ + seq: 12, + time: 1_000, + system: 'You are an AI agent.\n', + tools: [{ name: 'bash', tokens: 815, description: 'run', schema: { type: 'object' } }], + }], + } as unknown as ContextHeaders + const data = tl({ current: { system: 9, tools: 815, user: 0, inject: 0, assistant: 0, tool: 0, total: 824 } }) + const m = await mount(h(Browser, props({ data, headers: headersOf(legacy) }))) + assert.ok(text(catRow(m, 'system')).includes(kit.t('browser.items', { n: 1 }))) + await m.unmount() + }) + test('a fetched epoch without a system prompt explains its absence', async () => { const headers: ContextHeaders = { headers: [{ seq: 3, time: 3, systemTokens: 0, tools: [] }] } const data = tl({ current: { system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0 } }) diff --git a/tests/client/services.spec.ts b/tests/client/services.spec.ts index 601510e..1fda080 100644 --- a/tests/client/services.spec.ts +++ b/tests/client/services.spec.ts @@ -396,6 +396,36 @@ describe('headersOf', () => { } assert.equal(headersOf(value), value) }) + + test('a legacy content-bearing epoch (pre-#37 wire) normalizes to the metadata shape', () => { + // 16 chars → ceil(16/4) + 4 = 8, the harness meter's own heuristic. + const legacy = { headers: [{ seq: 12, time: 1000, system: 'x'.repeat(16), tools: [{ name: 'bash', tokens: 815 }] }] } + const out = headersOf(legacy) + assert.equal(out?.headers[0]?.systemTokens, 8) + // The content fields ride along untouched — the browser never reads them + // from the delivered value (content fetches per epoch on demand). + assert.equal((out?.headers[0] as { system?: string }).system, 'x'.repeat(16)) + }) + + test('mixed generations price only the unpriced legacy entries', () => { + const value = { + headers: [ + { seq: 1, time: 1000, system: 'prompt', tools: [] }, + { seq: 2, time: 2000, system: 'kept', systemTokens: 55, tools: [] }, + { seq: 3, time: 3000, tools: [] }, + ], + } + const out = headersOf(value) + assert.equal(out?.headers[0]?.systemTokens, Math.ceil(6 / 4) + 4) + assert.equal(out?.headers[1]?.systemTokens, 55) + assert.equal(out?.headers[2]?.systemTokens, undefined) + }) + + test('a legacy entry whose system is not a non-empty string stays unpriced', () => { + const out = headersOf({ headers: [{ seq: 1, time: 1, system: '', tools: [] }, { seq: 2, time: 2, system: 7, tools: [] }] }) + assert.equal(out?.headers[0]?.systemTokens, undefined) + assert.equal(out?.headers[1]?.systemTokens, undefined) + }) }) describe('workspaceOf', () => { diff --git a/tests/host/headers.spec.ts b/tests/host/headers.spec.ts index 412d974..8f4bbbe 100644 --- a/tests/host/headers.spec.ts +++ b/tests/host/headers.spec.ts @@ -5,7 +5,7 @@ import assert from 'node:assert/strict' import { describe, test } from 'vitest' -import { estimateSystem } from '../../src/host/pricing' +import { estimateSystemTokens } from '../../src/shared/estimate' import { createContextHeadersDefinition } from '../../src/host/headers' import type { HeadersState } from '../../src/host/headers' import { header, foreign } from './helpers/events' @@ -42,7 +42,7 @@ describe('createContextHeadersDefinition', () => { assertPlainJson(state) assert.equal(state.headers.length, 1) assert.equal(state.headers[0].seq, 1) - assert.equal(state.headers[0].systemTokens, estimateSystem('You are an agent.')) + assert.equal(state.headers[0].systemTokens, estimateSystemTokens('You are an agent.')) assert.equal(state.headers[0].tools.length, 1) assert.equal(state.headers[0].tools[0].name, 'bash') @@ -105,7 +105,7 @@ describe('createContextHeadersDefinition', () => { ])) assert.ok(!('systemTokens' in view.headers[0])) assert.ok(!('systemTokens' in view.headers[1])) - assert.equal(view.headers[2].systemTokens, estimateSystem('sys')) + assert.equal(view.headers[2].systemTokens, estimateSystemTokens('sys')) }) test('the same epoch seq twice in a row returns the same state reference', () => { @@ -265,7 +265,7 @@ describe('read-compat over v1 rows', () => { assert.deepEqual(def.stateSchema.parse(structuredClone(v1State())), v1State()) const view = def.wire.view(v1State()) assert.equal(def.wire.viewSchema.safeParse(view).success, true, 'normalized view passes the strict wire schema') - assert.equal(view.headers[0].systemTokens, estimateSystem('You are an agent.'), 'legacy system text priced at view time') + assert.equal(view.headers[0].systemTokens, estimateSystemTokens('You are an agent.'), 'legacy system text priced at view time') assert.ok(!('system' in view.headers[0]), 'system text stripped from the wire') assert.deepEqual(view.headers[0].tools, [ { name: 'bash', tokens: 12 }, @@ -289,7 +289,7 @@ describe('read-compat over v1 rows', () => { assert.equal(state.headers.length, 3) const view = def.wire.view(state) assert.equal(def.wire.viewSchema.safeParse(view).success, true) - assert.equal(view.headers[2].systemTokens, estimateSystem('next')) + assert.equal(view.headers[2].systemTokens, estimateSystemTokens('next')) assert.ok(!('system' in state.headers[2]), 'new folds stay metadata-only in state') assert.ok('system' in state.headers[0], 'seeded legacy epochs keep their state shape until they age out') diff --git a/tests/host/pricing.spec.ts b/tests/host/pricing.spec.ts index ff93a76..f71ea59 100644 --- a/tests/host/pricing.spec.ts +++ b/tests/host/pricing.spec.ts @@ -6,7 +6,6 @@ import assert from 'node:assert/strict' import { describe, test } from 'vitest' import { estimateMessage, - estimateSystem, estimateToolSchema, estimateToolsTotal, firstText, @@ -15,6 +14,7 @@ import { isInjection, toolCallNames, } from '../../src/host/pricing' +import { estimateSystemTokens } from '../../src/shared/estimate' import type { ContentBlock } from '../../src/host/pricing' describe('estimateToolsTotal', () => { @@ -115,14 +115,14 @@ describe('estimateMessage', () => { describe('estimateSystem', () => { test('non-strings and the empty string price zero', () => { - assert.equal(estimateSystem(undefined), 0) - assert.equal(estimateSystem(null), 0) - assert.equal(estimateSystem(42), 0) - assert.equal(estimateSystem(''), 0) + assert.equal(estimateSystemTokens(undefined), 0) + assert.equal(estimateSystemTokens(null), 0) + assert.equal(estimateSystemTokens(42), 0) + assert.equal(estimateSystemTokens(''), 0) }) test('text prices chars/4 plus role framing', () => { - assert.equal(estimateSystem('abcd'), 5) + assert.equal(estimateSystemTokens('abcd'), 5) }) })