Sfoglia il codice sorgente

fix(fixture): mirror context breakdown projection

imccyu 1 mese fa
parent
commit
e94d539de1

+ 15 - 0
apps/web/tests/built-boot.snapshot.ts

@@ -121,6 +121,21 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
     expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
   }, { timeout: 10_000 })
 
+  // Resolve the resident approval so the ordinary composer bar (which owns
+  // ContextMeter) resumes without replacing the session shell. This minimal
+  // boot graph intentionally does not mount the separate question UI plugin.
+  fireEvent.click(await screen.findByRole('button', { name: 'Allow once' }))
+
+  // The fixture mirrors all three token-meter projections, so the assembled
+  // ContextMeter reaches its composition panel instead of only the occupancy
+  // fallback path.
+  const contextTrigger = await screen.findByRole('button', { name: /of context used/ })
+  fireEvent.click(contextTrigger)
+  const contextPanel = await screen.findByRole('dialog', { name: 'of context used' })
+  within(contextPanel).getByText('System prompt')
+  within(contextPanel).getByText('Tools')
+  within(contextPanel).getByText('Messages')
+
   // The write/edit turns render a real diff card through the assembled graph
   // (the keyed FileMutationRow composing ToolRow + DiffBlock), not just the
   // fixture's raw text. The card is collapsed by default, so expand each edit/

+ 86 - 5
packages/client/connection/src/client/fixture.ts

@@ -27,7 +27,7 @@ import type {
 // Type-only: the brand constructor is host-side; the fixture casts at its
 // wire-fabrication boundary (the schema layer's one-cast-point posture).
 import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
-import { foldSurface } from '@deepseek-ai/dsh-session/surface'
+import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface'
 import type {
   ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
   ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
@@ -358,6 +358,12 @@ function buildAlphaLog(): SessionEvent[] {
     events.push({ seq, time: (time += 800), ...authored })
     return seq
   }
+  // This resident history represents completed model requests, so retain the
+  // route capacity that accompanied them just as the live prompt path does.
+  push({
+    type: 'request/context',
+    data: { provider: 'deepseek-official', model: 'deepseek-v4-flash', contextWindow: 128_000 },
+  })
   for (let turn = 0; turn < 60; turn++) {
     push({ type: 'turn/start', data: { turn } })
     const userSeq = push({
@@ -819,6 +825,65 @@ interface FixtureRequestContext {
   contextWindow?: number
 }
 
+interface FixtureContextBreakdownProjection {
+  systemTokens: number
+  toolsTokens: number
+  messageTokens: number
+}
+
+/** Fixed token-meter heuristic constants mirrored by this client-only fixture. */
+const CHARS_PER_TOKEN = 4
+const BLOCK_OVERHEAD = 4
+const ROLE_OVERHEAD = 4
+
+/** Price fixture content with token-meter's fixed-density heuristic. */
+function estimateFixtureContent(blocks: readonly ContentBlock[]): number {
+  let tokens = 0
+  for (const block of blocks) {
+    switch (block.type) {
+      case 'text':
+      case 'reasoning':
+        tokens += Math.ceil(block.text.length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
+        break
+      case 'tool-call':
+        tokens += Math.ceil(block.name.length / CHARS_PER_TOKEN)
+          + Math.ceil(block.arguments.length / CHARS_PER_TOKEN)
+          + BLOCK_OVERHEAD
+        break
+      case 'tool-result':
+        tokens += estimateFixtureContent(block.content) + BLOCK_OVERHEAD
+        break
+      default:
+        tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN)
+    }
+  }
+  return tokens
+}
+
+/** Fixture parallel of token-meter's heuristic context-composition projection. */
+function contextBreakdownOf(log: readonly SessionEvent[]): FixtureContextBreakdownProjection {
+  const headerEvent = log.findLast(event => event.type === 'request/header')
+  const header = headerEvent === undefined
+    ? undefined
+    : headerEvent.data.header
+  let messageTokens = 0
+  for (const seq of foldSurface(log).nodes) {
+    const event = log[seq]
+    if (event === undefined) continue
+    const message = deriveEventMessage(event)
+    if (message !== null) messageTokens += estimateFixtureContent(message.content) + ROLE_OVERHEAD
+  }
+  return {
+    systemTokens: header?.system === undefined
+      ? 0
+      : Math.ceil(header.system.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD,
+    toolsTokens: header?.tools === undefined || header.tools.length === 0
+      ? 0
+      : Math.ceil(JSON.stringify(header.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD,
+    messageTokens,
+  }
+}
+
 /** Latest log-only route context, or undefined before any request ran. */
 function lastRequestContext(
   log: readonly SessionEvent[],
@@ -874,28 +939,44 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
   values['tokenUsage'] = tokenUsageOf(log)
   // Always present (token-meter composed): last request pressure and capacity.
   values['contextPressure'] = contextPressureOf(log)
+  // Always present (token-meter composed): heuristic request composition.
+  values['contextBreakdown'] = contextBreakdownOf(log)
   return values
 }
 
 /** Host push-frame parallel: emit one session/projection frame per key the given event advanced. */
 function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: SessionEvent): Extract<MuxFrame, { type: 'session/projection' }>[] {
   const type = (event as { type: string }).type
+  const frames: Extract<MuxFrame, { type: 'session/projection' }>[] = []
   // One usage sample advances both token-meter units.
   if (usageSampleOf(event) !== undefined) {
-    return [
+    frames.push(
       { type: 'session/projection', sessionId: id, key: 'tokenUsage', value: tokenUsageOf(log), seq: event.seq },
       { type: 'session/projection', sessionId: id, key: 'contextPressure', value: contextPressureOf(log), seq: event.seq },
-    ]
+    )
   }
   if (type === 'request/context') {
-    return [{
+    frames.push({
       type: 'session/projection',
       sessionId: id,
       key: 'contextPressure',
       value: contextPressureOf(log),
       seq: event.seq,
-    }]
+    })
+  }
+  if (type === 'request/header'
+    || type === 'user/message'
+    || type === 'assistant/message'
+    || type === 'tool/result') {
+    frames.push({
+      type: 'session/projection',
+      sessionId: id,
+      key: 'contextBreakdown',
+      value: contextBreakdownOf(log),
+      seq: event.seq,
+    })
   }
+  if (frames.length > 0) return frames
   if (type === 'session/title') {
     const values = projectionValuesOf(log)
     /* v8 ignore next -- the advancing title event is in the log, so the key is present. */

+ 19 - 5
packages/client/connection/tests/fixture.spec.ts

@@ -159,6 +159,11 @@ describe('createFixtureApi', () => {
         },
         // No request ran, so neither pressure nor capacity is known yet.
         contextPressure: {},
+        contextBreakdown: {
+          systemTokens: 0,
+          toolsTokens: 0,
+          messageTokens: 0,
+        },
       } },
     })
   })
@@ -304,6 +309,10 @@ describe('createFixtureApi', () => {
       frame.type === 'session/projection'
       && frame.key === 'contextPressure'
       && (frame.value as { contextWindow?: number }).contextWindow === 128_000)).toBe(true)
+    expect(frames.some(frame =>
+      frame.type === 'session/projection'
+      && frame.key === 'contextBreakdown'
+      && (frame.value as { messageTokens?: number }).messageTokens! > 0)).toBe(true)
     const finalize = frames.find((f): f is Extract<MuxFrame, { type: 'session/event' }> => f.type === 'session/event' && f.event.type === 'assistant/message')
     expect(JSON.stringify(finalize?.event.data)).toContain('(已中断)')
     // Idle cancel: no replay in flight, must not explode; running flips false.
@@ -335,7 +344,7 @@ describe('createFixtureApi', () => {
       const envelopes: RpcRequest<MuxFrame>[] = []
       for await (const envelope of api.events.mux(req({}), abort.signal)) {
         envelopes.push(envelope)
-        if (envelopes.length >= 10) abort.abort()
+        if (envelopes.length >= 11) abort.abort()
       }
       return envelopes
     }
@@ -351,10 +360,15 @@ describe('createFixtureApi', () => {
     expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null })
     expect(first[6]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'tokenUsage' })
     expect(first[7]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'contextPressure' })
-    expect(first[8]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
-    expect(second[8]?.rpcId).toBe(first[8]?.rpcId) // stable rpcId across replays (host replay semantics)
-    expect(first[9]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
-    expect(second[9]?.rpcId).toBe(first[9]?.rpcId)
+    expect(first[8]?.payload).toMatchObject({
+      type: 'session/projection', sessionId: 'fx-alpha', key: 'contextBreakdown',
+      value: { systemTokens: 0, toolsTokens: 0 },
+    })
+    expect((first[8]?.payload as { value: { messageTokens: number } }).value.messageTokens).toBeGreaterThan(0)
+    expect(first[9]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
+    expect(second[9]?.rpcId).toBe(first[9]?.rpcId) // stable rpcId across replays (host replay semantics)
+    expect(first[10]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
+    expect(second[10]?.rpcId).toBe(first[10]?.rpcId)
   })
 
   it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {