From eba829784bd43f2ebbd179e05e376d6ebadb9cf6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:46:45 +0800 Subject: [PATCH] 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. --- .../token-meter/src/breakdown-projection.ts | 2 +- .../context-breakdown-projection.spec.ts | 31 ++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/llm/token-meter/src/breakdown-projection.ts b/packages/llm/token-meter/src/breakdown-projection.ts index 6a8a7b02eb..d3a3f21abc 100644 --- a/packages/llm/token-meter/src/breakdown-projection.ts +++ b/packages/llm/token-meter/src/breakdown-projection.ts @@ -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) => { diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index d7f952800d..18da55cf66 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -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)