fix(web): let the context meter see a compaction

The composer ring, percentage, and `~used / capacity` header read
`contextPressure.pressureTokens`, which moves only when a request reports
usage. Compaction reports none — compact-basic summarizes through a direct
`ctx.llm.stream()` call and appends only its own `compact/*` records plus the
replacement `user/message` — so the meter was frozen across the one action
taken to change it. Driving a real `compactNow` through the agent loop:

    BEFORE compact:  ring=4%  header=~4227/100000  rows=[18, 0, 4365]
    AFTER  compact:  ring=4%  header=~4227/100000  rows=[18, 0,  286]

The composition rows fell 93%; the ring did not move, and would not until an
entire further turn completed. The panel then contradicted itself by more than
an order of magnitude at exactly the moment a reader opens it.

`contextPressure` now also publishes `projectedTokens`: the provider sample
plus the heuristic repricing of everything the surface gained or lost since
that sample, clamped at zero, folded through the shared `surface-fold.ts`. The
sample is stamped before the same event joins the surface, so an
`assistant/message` anchors against the surface its own request carried. Only
the delta is estimated, so the figure stays provider-anchored — the estimator's
CJK and JSON-schema underpricing stays out of the occupancy number — while
reacting the moment content lands or a span is shadowed. Same run after:

    BEFORE compact:  ring=4%  header=~4323/100000  (pressure=4227, projected=4323)
    AFTER  compact:  ring=0%  header=~ 244/100000  (pressure=4227, projected= 244)

`contextOccupancy` prefers the projected figure and falls back to the bare
sample, so a projection restored from a pre-field checkpoint degrades to the
old behavior rather than disappearing. `stateVersion` moves to 3.
This commit is contained in:
Yichen Jiang
2026-08-05 17:00:48 +08:00
parent e62cbe12e4
commit 038699bcb4
20 changed files with 327 additions and 64 deletions

View File

@@ -20,14 +20,13 @@ export interface TokenUsageProjection {
/**
* Approximate context occupancy for a status display.
*
* The two fields, when present, are deliberately NOT one atomic request
* observation: `pressureTokens` is the newest provider-reported prompt size,
* `contextWindow` the newest recorded route capacity. Switching models can
* therefore pair a fresh capacity with the previous route's pressure until the
* next request reports usage. This is an intentional trade — the value is a
* user-facing reference, not a billing or gating input — and it matches how
* the TUI status line has always computed occupancy. See the token-meter
* README for the full rationale.
* The fields, when present, are deliberately NOT one atomic request
* observation: each is a last-wins record of a different moment. Switching
* models can therefore pair a fresh capacity with the previous route's
* pressure until the next request reports usage. This is an intentional trade
* — the value is a user-facing reference, not a billing or gating input — and
* it matches how the TUI status line has always computed occupancy. See the
* token-meter README for the full rationale.
*/
export interface ContextPressureProjection {
/**
@@ -36,6 +35,15 @@ export interface ContextPressureProjection {
* grow as the current turn streams. Absent until a provider reports usage.
*/
pressureTokens?: number
/**
* What the NEXT request's prompt would cost: {@link pressureTokens} plus the
* heuristic repricing of everything the surface gained or lost since that
* sample. Only the delta is estimated, so the figure stays anchored to the
* provider while still reacting the moment a compaction shadows a span —
* which `pressureTokens` alone cannot do, since compaction reports no usage
* of its own. Absent until a provider reports usage.
*/
projectedTokens?: number
/** Newest recorded route capacity; absent when no adapter advertised one. */
contextWindow?: number
}
@@ -43,10 +51,11 @@ export interface ContextPressureProjection {
/**
* Heuristic composition of the next request's context: what the prompt is
* made of, not what it costs. All three figures use the meter's fixed
* density estimate (they will not sum exactly to the provider-reported
* `pressureTokens`, which is billing-grade and one request behind), and the
* message figure tracks the live surface, so it moves as content is appended
* or compacted while the provider number holds still.
* density estimate, so they will not sum to the provider-anchored
* `projectedTokens`: the estimator systematically underprices CJK text and
* JSON schemas, which is exactly the error the anchoring in
* {@link ContextPressureProjection.projectedTokens} keeps out of the occupancy
* figure. Present these as approximations of composition, never as a total.
*/
export interface ContextBreakdownProjection {
/** Heuristic tokens of the newest request envelope's system prompt; 0 before any request. */