fix(token-meter): invalidate header-based breakdown caches

Address ds-review-bot thread 3921300994 (PRRT_kwDOS3Pfcs6eyLsu). contextBreakdown now folds system/message instead of request/header.system, so version-2 checkpoints have different semantics even though their numeric fields still pass the current schema. Bump the lower projection to stateVersion 3 so cache-only reads omit old values and restore replays the full log; derived caches are not covered by the Session-log persistence compatibility waiver. No migration or fallback is added.

Regression seeds a schema-valid v2 row at the current watermark with stale system/message prices. Before the bump, cache views returned it, restoreFloor selected seq 2 rather than 0, and restore retained system=0/message=17 instead of system=8/message=9. After the bump, the 13-test owning suite passes and exact src/breakdown-projection.ts coverage is 100% statements, branches, functions, and lines; refreshed rows equal a fresh fold at version 3. Existing token-meter README prose documents the current system/message fold without a version literal, so it needs no edit.

Propagation requirement: the upper layer already uses version 3 for a different compact cache representation. Advance that layer to version 4 when propagating this fix to avoid assigning one version to two schemas.
This commit is contained in:
Tianyi Cui
2026-09-06 20:44:39 +08:00
parent 454b80b90e
commit eba829784b
2 changed files with 31 additions and 2 deletions
@@ -61,7 +61,7 @@ const breakdownSchema = z.object({
*/
export const contextBreakdownProjectionDefinition = {
key: 'contextBreakdown',
stateVersion: 2,
stateVersion: 3,
stateSchema: contextBreakdownStateSchema,
init: () => ({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 }),
apply: (state, event) => {
@@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { createMessage, createSystemMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionSeq } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionSeq as SessionSeqType } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
@@ -311,6 +311,35 @@ describe('contextBreakdown session projection', () => {
.toBe(ctx.tokenMeter.measure(session).surfaceTokens)
})
it('discards version-2 cache values and refolds system messages from the full log', async () => {
const { ctx, session } = await harness()
try {
appendSystem(session, 'You are terse.')
session.append('request/header', { header: { config: CONFIG, tools: TOOLS }, reason: 'initial' })
appendUser(session, 'abcd')
const current = ctx.sessionProjections.checkpoint(session)
// The old fold's fields still validate, but its header-based system price is not reusable.
const staleValue = { systemTokens: 0, toolsTokens: estimateToolsTokens({ config: CONFIG, tools: TOOLS }), messageTokens: 17 }
expect(contextBreakdownProjectionDefinition.stateSchema.parse(staleValue)).toEqual(staleValue)
const checkpoint = {
...current,
contextBreakdown: { ver: 2, seq: SessionSeq(session.seq - 1), val: staleValue },
}
expect.soft(ctx.sessionProjections.viewCheckpoint(checkpoint)).not.toHaveProperty('contextBreakdown')
expect.soft(ctx.sessionProjections.restoreFloor(checkpoint)).toBe(0)
const restored = ctx.sessionProjections.restore(
checkpoint, session.snapshotEvents(), SessionLogOffset(0), session.header, session.inheritedEventCount,
)
expect(restored.snapshot.values.contextBreakdown).toEqual({
systemTokens: 8, toolsTokens: staleValue.toolsTokens, messageTokens: 9,
})
expect(restored.checkpoint).toEqual(current)
expect(restored.checkpoint['contextBreakdown']?.ver).toBe(3)
} finally {
await ctx.fiber.dispose()
}
})
it('restores from a JSON checkpoint and unregisters with the token-meter fiber', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)