Просмотр исходного кода

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.
Tianyi Cui 3 недель назад
Родитель
Сommit
eba829784b

+ 1 - 1
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) => {

+ 30 - 1
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)