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:
Hypatia May
2026-07-30 14:48:19 +08:00
parent 901bd575e1
commit 8a8c1965d7
43 changed files with 535 additions and 159 deletions

View File

@@ -8,7 +8,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { createUserMessage, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
@@ -521,3 +521,70 @@ describe('request stability across the loop', () => {
})
})
})
describe('request/context capacity records', () => {
/** Adapter advertising a per-model capacity, keyed by model id. */
function capacityAdapter(windows: Record<string, number>, script: StreamChunk[][]): MockAdapter {
return new class extends MockAdapter {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
const contextWindow = windows[model]
return Promise.resolve({
provider,
id: model,
name: model,
...contextWindow === undefined ? {} : { context: { contextWindow } },
})
}
}(script)
}
it('records capacity once and skips it while the route is unchanged', async () => {
const adapter = capacityAdapter({ mock: 128_000 }, [textResponse('a'), textResponse('b')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('capacity-dedup'), { provider: 'mock', model: 'mock' })
send(agent, 'first')
await waitForIdle(ctx, agent)
send(agent, 'second')
await waitForIdle(ctx, agent)
const records = agent.session.events.filter(event => event.type === 'request/context')
expect(records).toHaveLength(1)
expect(records[0]?.data).toEqual({ provider: 'mock', model: 'mock', contextWindow: 128_000 })
// Log-only: not a SurfaceEventType, so it can never reach a model request
// (the type system rejects a surfaceOp here; the session invariant also
// requires the record to sit inside its open turn).
expect(agent.session.surface.nodes).not.toContain(records[0]?.seq)
})
it('records a second capacity when the route changes mid-session', async () => {
const adapter = capacityAdapter(
{ small: 64_000, large: 256_000 },
[textResponse('a'), textResponse('b')],
)
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('capacity-switch'), { provider: 'mock', model: 'small' })
send(agent, 'first')
await waitForIdle(ctx, agent)
ctx.on('agent/request', (subject, _turn, _step, _signal, next) => subject === agent
? Promise.resolve({ provider: 'mock', model: 'large' })
: next())
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(agent.session.events
.filter(event => event.type === 'request/context')
.map(event => event.data.contextWindow)).toEqual([64_000, 256_000])
})
it('records nothing when the adapter advertises no capacity', async () => {
// The absent-capacity path must stay silent rather than log a placeholder:
// consumers read "no capacity known" and omit their percentage entirely.
const ctx = await harness(new MockAdapter([textResponse('a')]))
const agent = ctx.agentLoop.create(SessionId('capacity-absent'), { provider: 'mock', model: 'mock' })
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(agent.session.events.some(event => event.type === 'request/context')).toBe(false)
})
})