feat(web): show durable token usage and context occupancy in the stats line
The chat stats line took its token totals from the loaded conversation nodes, so paging changed them and compaction erased the billing behind replaced content. It also had no way to show context occupancy: the numerator and capacity never reached the browser. Both now come from token-meter session projections read through the standard useProjection seat. Window nodes keep supplying turn and step counts plus LLM and tool wall times, which are correctly window-scoped facts about what is on screen; accounting no longer comes from there. `tokenUsage` supplies billing and cache hit. `contextPressure` supplies occupancy, pairing the newest provider-reported prompt size with the newest capacity recorded by `request/context`. Deployments without token-meter drop the token groups; a route whose adapter advertises no capacity drops the occupancy group rather than rendering a placeholder. Occupancy is deliberately approximate: the numerator and capacity are independent last-wins fields, not one atomic request observation, so switching models pairs a fresh capacity with the prior route's pressure until the next request reports usage. It is a user-facing reference figure that nothing in the harness makes decisions from, and it matches how the TUI status line has always computed occupancy. The Agent Note and token-meter README state this as a decision, including why the atomic alternative was implemented and rejected, so it is not re-litigated as a defect. Snapshot delta is one added `Context N% of 128K` segment across eight web goldens; the preceding commit absorbed master's pre-existing golden drift.
This commit is contained in:
@@ -218,14 +218,19 @@ describe('small branch tails', () => {
|
||||
})
|
||||
|
||||
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
|
||||
// cacheHitPct is null only when input+cacheRead are both zero (pure
|
||||
// output accounting) — any input makes it a real 0%.
|
||||
// Cache hit is null only when uncached input and cache reads are both zero
|
||||
// (pure output accounting) — any input makes it a real 0%.
|
||||
const snap = {
|
||||
nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }],
|
||||
}
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const view = render(
|
||||
<StatsLine useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']} />,
|
||||
<StatsLine
|
||||
useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']}
|
||||
useProjection={(key: string) => key === 'tokenUsage'
|
||||
? { uncachedInputTokens: 0, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
||||
: undefined}
|
||||
/>,
|
||||
)
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 10 tok')
|
||||
})
|
||||
|
||||
@@ -51,7 +51,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
}
|
||||
|
||||
describe('deriveStats', () => {
|
||||
it('folds turns/steps/token split and cache hit percentage', () => {
|
||||
it('counts turns and steps and never folds node usage into accounting', () => {
|
||||
const stats = deriveStats([
|
||||
assistant(1, 1, { inputTokens: 100, outputTokens: 50, cacheReadTokens: 900 }),
|
||||
assistant(2, 1, { inputTokens: 100, outputTokens: 50 }),
|
||||
@@ -59,12 +59,12 @@ describe('deriveStats', () => {
|
||||
])
|
||||
expect(stats.turns).toBe(2)
|
||||
expect(stats.steps).toBe(3)
|
||||
expect(stats.inputTokens).toBe(1100)
|
||||
expect(stats.outputTokens).toBe(100)
|
||||
expect(stats.cacheHitPct).toBe(82)
|
||||
// Window-scoped by design: the paged window is not an accounting source, so
|
||||
// the fold exposes no token fields at all (billing rides the projection).
|
||||
expect(Object.keys(stats).sort()).toEqual(['llmMs', 'steps', 'toolMs', 'turns'])
|
||||
})
|
||||
|
||||
it('cache hit stays null with no cache accounting; out-of-window tool results ignored', () => {
|
||||
it('ignores tool results with no call time', () => {
|
||||
const tool: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [],
|
||||
isError: false, callView: null, resultView: null,
|
||||
@@ -72,7 +72,6 @@ describe('deriveStats', () => {
|
||||
const stats = deriveStats([tool, assistant(1, 1)])
|
||||
expect(stats.steps).toBe(1)
|
||||
expect(stats.toolMs).toBe(0)
|
||||
expect(stats.cacheHitPct).toBeNull()
|
||||
})
|
||||
|
||||
it('sums LLM wall time from assistant timing and tool wall time from call/result pairs', () => {
|
||||
@@ -109,22 +108,71 @@ describe('formatters', () => {
|
||||
})
|
||||
|
||||
describe('StatsLine', () => {
|
||||
function props(source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }): StatsLineProps {
|
||||
return { useSession: bindSnapshotSelector(source) }
|
||||
const USAGE = { uncachedInputTokens: 10, outputTokens: 5, cacheReadTokens: 90, cacheWriteTokens: 0 }
|
||||
|
||||
/** Stub the projection seat: a key-addressed table of whole values. */
|
||||
function projections(values: Record<string, unknown>): StatsLineProps['useProjection'] {
|
||||
return (key: string) => values[key]
|
||||
}
|
||||
|
||||
function props(
|
||||
source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void },
|
||||
values: Record<string, unknown> = { tokenUsage: USAGE },
|
||||
): StatsLineProps {
|
||||
return { useSession: bindSnapshotSelector(source), useProjection: projections(values) }
|
||||
}
|
||||
|
||||
it('renders the grouped stats row and hides with zero steps', () => {
|
||||
const { source } = makeSource({
|
||||
nodes: [assistant(1, 1, { inputTokens: 10, outputTokens: 5, cacheReadTokens: 90 })],
|
||||
})
|
||||
const { source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
const view = render(<StatsLine {...props(source)} />)
|
||||
// No timing on the fixture: the duration group drops out whole.
|
||||
// No timing on the fixture: the duration group drops out whole. Tokens come
|
||||
// from the projection, so paging the window cannot change them.
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps|Cache hit 90%|Input 100 tok · Output 5 tok')
|
||||
const empty = makeSource()
|
||||
const emptyView = render(<StatsLine {...props(empty.source)} />)
|
||||
expect(emptyView.container.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('renders context occupancy only when the projection knows a capacity', () => {
|
||||
const { source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
const withCapacity = render(<StatsLine {...props(source, {
|
||||
tokenUsage: USAGE,
|
||||
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
|
||||
})} />)
|
||||
expect(withCapacity.container.textContent).toContain('Context 25% of 128K')
|
||||
// Pressure without capacity has no denominator: the group drops out.
|
||||
const noCapacity = render(<StatsLine {...props(source, {
|
||||
tokenUsage: USAGE,
|
||||
contextPressure: { pressureTokens: 32_000 },
|
||||
})} />)
|
||||
expect(noCapacity.container.textContent).not.toContain('Context')
|
||||
})
|
||||
|
||||
it('clamps occupancy at 100% when pressure exceeds the recorded capacity', () => {
|
||||
// Capacity and pressure are independent last-wins fields, so a model switch
|
||||
// can pair a smaller new window with the previous route's larger prompt.
|
||||
const { source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
const view = render(<StatsLine {...props(source, {
|
||||
tokenUsage: USAGE,
|
||||
contextPressure: { pressureTokens: 300_000, contextWindow: 128_000 },
|
||||
})} />)
|
||||
expect(view.container.textContent).toContain('Context 100% of 128K')
|
||||
})
|
||||
|
||||
it('drops every token group when no projection is composed', () => {
|
||||
const { source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
const view = render(<StatsLine {...props(source, {})} />)
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps')
|
||||
})
|
||||
|
||||
it('omits cache hit when nothing was billed on the input side', () => {
|
||||
const { source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
const view = render(<StatsLine {...props(source, {
|
||||
tokenUsage: { uncachedInputTokens: 0, outputTokens: 7, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
||||
})} />)
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 7 tok')
|
||||
})
|
||||
|
||||
it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {
|
||||
const { set, source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
let renders = 0
|
||||
|
||||
@@ -36,20 +36,24 @@ describe('render branch tails', () => {
|
||||
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('StatsLine skips usage-less nodes and defaults each absent counter to zero', () => {
|
||||
it('StatsLine counts window nodes but drops every token group without a projection', () => {
|
||||
// Node `usage` is deliberately ignored: billing rides the durable
|
||||
// tokenUsage projection, so an absent projection leaves counts only.
|
||||
const snap = {
|
||||
nodes: [
|
||||
{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [] },
|
||||
{ kind: 'assistant', seq: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } },
|
||||
// outputTokens absent: the tokens sum's ?? 0 arm for output.
|
||||
{ kind: 'assistant', seq: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } },
|
||||
],
|
||||
}
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const view = render(
|
||||
<StatsLine useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
|
||||
<StatsLine
|
||||
useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>}
|
||||
useProjection={() => undefined}
|
||||
/>,
|
||||
)
|
||||
expect(view.container.textContent).toBe('2 turns · 3 steps|Cache hit 0%|Input 9 tok · Output 6 tok')
|
||||
expect(view.container.textContent).toBe('2 turns · 3 steps')
|
||||
})
|
||||
|
||||
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {
|
||||
|
||||
Reference in New Issue
Block a user