fix(client): count the system prompt on legacy contextHeaders wire values

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.
This commit is contained in:
Bowen Liang committed 2026-09-01 23:36:04 +08:00
1 parent c623da9a01
commit 07aae76261
9 files changed
+106 -23

No files matched your search

+22 -1
View File
@@ -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 {
+2 -2
View File
@@ -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`).
+4 -3
View File
@@ -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
}),
+1 -5
View File
@@ -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
+16
View File
@@ -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
}
+20 -1
View File
@@ -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 } })
+30
View File
@@ -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', () => {
+5 -5
View File
@@ -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')
+6 -6
View File
@@ -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)
})
})