feat(web): turn speed metrics and composer context meter

Assistant footers and the stats line gain TTFT/tok-per-second readings
folded from step timings; context occupancy moves off the stats line onto
a composer ring whose panel shows a heuristic system/tools/messages
breakdown from the new token-meter contextBreakdown session projection.
This commit is contained in:
Yif
2026-08-05 13:54:46 +08:00
parent 6f10f9c01c
commit 0073d6aaa1
45 changed files with 1654 additions and 241 deletions

View File

@@ -0,0 +1,195 @@
// contextBreakdown projection: heuristic system/tools/message composition,
// plus the shared estimator's pricing branches.
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client'
import { contextBreakdownProjectionDefinition } from '../src/breakdown-projection.ts'
import {
estimateContent,
estimateHeader,
estimateMessage,
estimateSystemTokens,
estimateToolsTokens,
} from '../src/estimate.ts'
const CONFIG = { provider: 'test', model: 'test-model' }
const TOOLS: ToolSchema[] = [{
name: 'bash',
description: 'run a command',
parameters: { type: 'object', properties: {} },
}]
async function harness(): Promise<{ ctx: Context; session: Session }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(TokenMeterService)
return { ctx, session: ctx.sessions.create() }
}
const projected = (ctx: Context, session: Session): ContextBreakdownProjection => {
const value = ctx.sessionProjections.snapshot(session).values.contextBreakdown
if (value === undefined) throw new Error('contextBreakdown projection is not registered')
return value
}
function appendUser(session: Session, text: string): number {
return session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user' },
}), { surfaceOp: 'append' }).seq
}
describe('contextBreakdown session projection', () => {
it('serves zeros for an empty log', async () => {
const { ctx, session } = await harness()
expect(projected(ctx, session)).toEqual({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 })
})
it('prices the newest envelope last-wins and pushes no change for a restated one', async () => {
const { ctx, session } = await harness()
session.append('request/header', {
header: { config: CONFIG, system: 'You are terse.', tools: TOOLS },
reason: 'initial',
})
expect(projected(ctx, session)).toEqual({
systemTokens: estimateSystemTokens({ config: CONFIG, system: 'You are terse.' }),
toolsTokens: estimateToolsTokens({ config: CONFIG, tools: TOOLS }),
messageTokens: 0,
})
const changed: string[] = []
ctx.sessionProjections.onChanged((_session, key) => { changed.push(key) })
session.append('request/header', {
header: { config: CONFIG, system: 'You are terse.', tools: TOOLS },
reason: 'change',
})
session.append('todo/write', { todos: [] })
expect(changed).not.toContain('contextBreakdown')
// A system-less, tool-less envelope prices back to zero.
session.append('request/header', { header: { config: CONFIG }, reason: 'change' })
expect(projected(ctx, session)).toEqual({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 })
})
it('sums surface appends and skips an empty-content assistant message', async () => {
const { ctx, session } = await harness()
appendUser(session, 'abcd')
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [],
source: { kind: 'model', provider: 'mock', model: 'mock' },
}),
usage: { inputTokens: 9, outputTokens: 0 },
}, { surfaceOp: 'append', sourceEventSeqs: [] })
session.append('step/end', { turn: 1, step: 1 })
// 'abcd' prices to 9 (1 text + 4 block + 4 role); the usage-only assistant
// message derives to no transcript entry and adds nothing.
expect(projected(ctx, session).messageTokens).toBe(9)
})
it('shrinks the message figure when a replacement compacts the surface', async () => {
const { ctx, session } = await harness()
const first = appendUser(session, 'before compaction, a longer message')
const second = appendUser(session, 'and a second entry')
const summary = createUserMessage({
content: [{ type: 'text', text: 'summary' }],
source: { kind: 'plugin', plugin: 'test' },
})
session.append('user/message', summary, {
surfaceOp: { op: 'replace', start: first, end: second },
sourceEventSeqs: [first, second],
})
expect(projected(ctx, session).messageTokens).toBe(estimateMessage(summary))
})
it('fails loud on a replace range absent from the folded surface', () => {
const definition = contextBreakdownProjectionDefinition
const replace = (start: number, end: number): SessionEvent => ({
type: 'user/message',
seq: 9,
time: 0,
data: createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }),
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs: [start, end],
} as unknown as SessionEvent)
const append = (seq: number): SessionEvent => ({
type: 'user/message',
seq,
time: 0,
data: createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }),
surfaceOp: 'append',
} as unknown as SessionEvent)
let state = definition.init()
state = definition.apply(state, append(1))
state = definition.apply(state, append(3))
expect(() => definition.apply(state, replace(7, 3))).toThrow('invalid current range')
expect(() => definition.apply(state, replace(1, 7))).toThrow('invalid current range')
expect(() => definition.apply(state, replace(3, 1))).toThrow('invalid current range')
})
it('restores from a JSON checkpoint and unregisters with the token-meter fiber', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
const meterFiber = await ctx.plugin(TokenMeterService)
const session = ctx.sessions.create()
session.append('request/header', {
header: { config: CONFIG, system: 'You are terse.' },
reason: 'initial',
})
appendUser(session, 'abcd')
const checkpoint = JSON.parse(JSON.stringify(
ctx.sessionProjections.checkpoint(session),
)) as ReturnType<typeof ctx.sessionProjections.checkpoint>
await meterFiber.dispose()
expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('contextBreakdown')
await ctx.plugin(TokenMeterService)
expect(ctx.sessionProjections.viewCheckpoint(checkpoint).contextBreakdown).toEqual({
systemTokens: estimateSystemTokens({ config: CONFIG, system: 'You are terse.' }),
toolsTokens: 0,
messageTokens: 9,
})
})
})
describe('shared estimator', () => {
it('prices every content-block shape under the fixed heuristic', () => {
expect(estimateContent([{ type: 'text', text: 'abcd' }])).toBe(5)
expect(estimateContent([{ type: 'reasoning', text: 'abcdefgh' }] as ContentBlock[])).toBe(6)
expect(estimateContent([{ type: 'tool-call', id: 'c' as never, name: 'bash', arguments: '{"a":1}' }])).toBe(7)
expect(estimateContent([{
type: 'tool-result', toolCallId: 'c' as never,
content: [{ type: 'text', text: 'abcd' }],
}])).toBe(9)
const unknown = { type: 'mystery', payload: 'abc' } as unknown as ContentBlock
expect(estimateContent([unknown])).toBe(4 + Math.ceil(JSON.stringify(unknown).length / 4))
})
it('prices envelope parts independently and absent parts to zero', () => {
expect(estimateSystemTokens(undefined)).toBe(0)
expect(estimateSystemTokens({ config: CONFIG })).toBe(0)
expect(estimateSystemTokens({ config: CONFIG, system: 'abcdefgh' })).toBe(6)
expect(estimateToolsTokens(undefined)).toBe(0)
expect(estimateToolsTokens({ config: CONFIG, tools: [] })).toBe(0)
expect(estimateToolsTokens({ config: CONFIG, tools: TOOLS }))
.toBe(Math.ceil(JSON.stringify(TOOLS).length / 4) + 4)
expect(estimateHeader(undefined)).toBe(0)
expect(estimateHeader({ config: CONFIG, system: 'abcdefgh', tools: TOOLS }))
.toBe(6 + Math.ceil(JSON.stringify(TOOLS).length / 4) + 4)
})
})