diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 12487d0c43..b102dff8b8 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/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/ diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 5ad108d2bc..d000f7f4f1 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/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[] { const type = (event as { type: string }).type + const frames: Extract[] = [] // 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. */ diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index ad95f5bb17..3bdd0a21ea 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/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 => 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[] = [] 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 () => {