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:
@@ -235,6 +235,34 @@ function recordContext(session: Session, model: string, contextWindow?: number):
|
||||
})
|
||||
}
|
||||
|
||||
/** Append one model-visible user turn and return its surface seq. */
|
||||
function appendUser(session: Session, text: string): number {
|
||||
return session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' }).seq
|
||||
}
|
||||
|
||||
/** Append one finalized assistant turn carrying its provider usage. */
|
||||
function appendAssistant(
|
||||
session: Session,
|
||||
text: string,
|
||||
usage: TokenUsage,
|
||||
turn: number,
|
||||
step: number,
|
||||
): number {
|
||||
return session.append('assistant/message', {
|
||||
turn,
|
||||
step,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'model', provider: 'mock', model: 'mock' },
|
||||
}),
|
||||
usage,
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [] }).seq
|
||||
}
|
||||
|
||||
describe('contextPressure session projection', () => {
|
||||
it('serves no pressure or capacity for an empty log', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
@@ -277,9 +305,13 @@ describe('contextPressure session projection', () => {
|
||||
startStep(session, 1, 1)
|
||||
recordContext(session, 'small', 64_000)
|
||||
usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
|
||||
expect(pressure(ctx, session)).toEqual({ pressureTokens: 100, contextWindow: 64_000 })
|
||||
expect(pressure(ctx, session)).toEqual({
|
||||
pressureTokens: 100, projectedTokens: 100, contextWindow: 64_000,
|
||||
})
|
||||
recordContext(session, 'large', 256_000)
|
||||
expect(pressure(ctx, session)).toEqual({ pressureTokens: 100, contextWindow: 256_000 })
|
||||
expect(pressure(ctx, session)).toEqual({
|
||||
pressureTokens: 100, projectedTokens: 100, contextWindow: 256_000,
|
||||
})
|
||||
})
|
||||
|
||||
it('removes an older capacity when the newest route advertises none', async () => {
|
||||
@@ -288,7 +320,7 @@ describe('contextPressure session projection', () => {
|
||||
recordContext(session, 'small', 64_000)
|
||||
usageChunk(session, { inputTokens: 100, outputTokens: 10 }, 1, 1)
|
||||
recordContext(session, 'unknown')
|
||||
expect(pressure(ctx, session)).toEqual({ pressureTokens: 100 })
|
||||
expect(pressure(ctx, session)).toEqual({ pressureTokens: 100, projectedTokens: 100 })
|
||||
})
|
||||
|
||||
it('pushes no change for unrelated events or a restated capacity', async () => {
|
||||
@@ -319,7 +351,7 @@ describe('contextPressure session projection', () => {
|
||||
const checkpoint = JSON.parse(JSON.stringify(
|
||||
ctx.sessionProjections.checkpoint(session),
|
||||
)) as ReturnType<typeof ctx.sessionProjections.checkpoint>
|
||||
expect(checkpoint.contextPressure?.ver).toBe(2)
|
||||
expect(checkpoint.contextPressure?.ver).toBe(3)
|
||||
|
||||
await meterFiber.dispose()
|
||||
expect(ctx.sessionProjections.snapshot(session).values).not.toHaveProperty('contextPressure')
|
||||
@@ -327,7 +359,60 @@ describe('contextPressure session projection', () => {
|
||||
await ctx.plugin(TokenMeterService)
|
||||
expect(ctx.sessionProjections.viewCheckpoint(checkpoint).contextPressure).toEqual({
|
||||
pressureTokens: 42,
|
||||
projectedTokens: 42,
|
||||
contextWindow: 64_000,
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the sample forward over surface growth and a compaction', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
recordContext(session, 'large', 128_000)
|
||||
const question = appendUser(session, 'a first question worth a few tokens')
|
||||
startStep(session, 1, 1)
|
||||
// The provider prices the prompt its request actually carried; the sample
|
||||
// must anchor against the surface as of that request, not after the
|
||||
// assistant message joins it.
|
||||
const answer = appendAssistant(session, 'an answer of some length', { inputTokens: 900, outputTokens: 20 }, 1, 1)
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
const afterTurn = pressure(ctx, session)
|
||||
expect(afterTurn.pressureTokens).toBe(900)
|
||||
// The assistant message landed after the sample, so it already shows.
|
||||
expect(afterTurn.projectedTokens).toBeGreaterThan(900)
|
||||
|
||||
const grown = appendUser(session, 'a follow-up question that grows the surface further')
|
||||
const beforeCompaction = pressure(ctx, session).projectedTokens
|
||||
expect(beforeCompaction).toBeGreaterThan(afterTurn.projectedTokens!)
|
||||
|
||||
// Compaction reports no usage of its own, so `pressureTokens` cannot move;
|
||||
// the projected figure must shrink anyway — the defect this field fixes.
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'summary' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: question, end: grown },
|
||||
sourceEventSeqs: [question, answer, grown],
|
||||
})
|
||||
const compacted = pressure(ctx, session)
|
||||
expect(compacted.pressureTokens).toBe(900)
|
||||
expect(compacted.projectedTokens).toBeLessThan(beforeCompaction!)
|
||||
})
|
||||
|
||||
it('clamps a projection that heuristic error drove below zero', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
recordContext(session, 'large', 128_000)
|
||||
const question = appendUser(session, 'a question long enough to outprice the sample'.repeat(4))
|
||||
startStep(session, 1, 1)
|
||||
// A provider sample far below the heuristic price of what it replaced:
|
||||
// shadowing that span subtracts more than the sample holds.
|
||||
appendAssistant(session, 'ok', { inputTokens: 3, outputTokens: 1 }, 1, 1)
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '.' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: question, end: question },
|
||||
sourceEventSeqs: [question],
|
||||
})
|
||||
expect(pressure(ctx, session).projectedTokens).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user