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:
1 parent
c623da9a01
commit
07aae76261
9 files changed
+106
-23
No files matched your search
+22
-1
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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,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,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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user