token-meter is a core service that owns estimation and replay (its docs: 'the singleton ctx.tokenMeter directly owns estimation and replay'), and the published package registers three projections on the shared SessionProjectionMap — tokenUsage / contextPressure / contextBreakdown. The host was duplicating the contextPressure fold (pressureTokens + sampledSurfaceTokens + occupancyWindow, sampleUsage on assistant/message and usage chunks) and the client was rendering from that mirror. Host: the occupancy mirror leaves the fold entirely (assistant/chunk is now structurally ignored — same reference, no change feed). stateVersion 1 -> 2 so cached rows from the old fold are discarded. The strict wire schema no longer accepts occupancy; the shared type keeps it optional as legacy for downstream consumers. Client: the headline reads the official token-meter contextPressure projection via the useProjection seat (the same key the chat's context ring reads); absent key/value degrades to the existing derived fallback (last request prompt + surface movement), so the UI renders identically in every deployment. UI functionality is unchanged — same headline, same percentages, same composition anchoring. diff-fold.mjs is repurposed: the plugin no longer has an occupancy fold to compare, but its heuristic composition (current.system/tools + surface total) must stay equal to the official contextBreakdown projection — verified MATCH on three real session logs. Tests: host asserts occupancy is absent + chunk reference stability; client feeds the official projection through the stub and asserts the same UI text.
265 lines
17 KiB
JavaScript
265 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Functional smoke test for the packaged host half (v0.9): mounts the plugin
|
|
* on a fake ctx, captures the `contextTimeline` session-projection unit it
|
|
* registers, drives the unit's pure `init`/`apply`/`view` over synthetic
|
|
* event logs, and asserts the snapshot shape the browser receives.
|
|
*
|
|
* The harness framework normally drives `apply` per committed `session/event`
|
|
* and persists the state via the projection cache; those parts are not this
|
|
* plugin's concern. What we own and verify here: the fold semantics, the wire
|
|
* view shape, reference-stability of the apply contract, and the retention /
|
|
* attribution rules.
|
|
*/
|
|
import assert from 'node:assert/strict'
|
|
import { apply } from '../lib/index.js'
|
|
|
|
let def = null
|
|
const disposers = []
|
|
const fakeCtx = {
|
|
inject(list, cb) { cb(this) }, // ctx.inject(['sessionProjections'], ...)
|
|
effect(fn) { disposers.push(fn()); return () => {} },
|
|
sessionProjections: {
|
|
register(d) { def = d; return () => {} },
|
|
},
|
|
}
|
|
apply(fakeCtx)
|
|
assert.ok(def !== null, 'contextTimeline projection unit registered')
|
|
assert.equal(def.key, 'contextTimeline')
|
|
assert.equal(typeof def.init, 'function')
|
|
assert.equal(typeof def.apply, 'function')
|
|
assert.equal(typeof def.view, 'function')
|
|
|
|
/** Fold a full event log through the unit (the framework's watermark would
|
|
* restrict this to the delta; determinism here is the same output). */
|
|
const drive = (events) => {
|
|
let st = def.init()
|
|
for (const ev of events) st = def.apply(st, ev)
|
|
return def.view(st)
|
|
}
|
|
|
|
// ---- synthetic log: header, context, user+inject, tool call/result, step ----
|
|
const live = { events: [
|
|
{ seq: 1, type: 'request/header', time: 1000, data: {
|
|
header: { system: 'You are a harness agent.', tools: [{ name: 'bash', description: 'run a command' }], config: { model: 'deepseek-v4', provider: 'deepseek' } },
|
|
} },
|
|
{ seq: 2, type: 'request/context', time: 1000, data: { contextWindow: 128000 } },
|
|
{ seq: 3, type: 'user/message', time: 2000, data: { content: [{ type: 'text', text: 'Hello there, a fairly long user message that should cost more than one token!' }] } },
|
|
{ seq: 4, type: 'user/message', time: 3000, data: { source: { kind: 'plugin', form: 'notice', plugin: 'dsh-agent-presets', summary: 'Skill injected (code-review)' }, content: [{ type: 'text', text: 'injected text' }] } },
|
|
{ seq: 5, type: 'tool/call', time: 4000, data: { callId: 'c1', name: 'bash', arguments: '{}' } },
|
|
// Real tool/result envelope: the durable message carries source.kind='tool' + callId
|
|
{ seq: 6, type: 'tool/result', time: 4100, data: { callId: 'c1', message: { source: { kind: 'tool', callId: 'c1' }, content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'ok' }] }] } } },
|
|
{ seq: 7, type: 'assistant/message', time: 5000, data: { turn: 1, step: 1, usage: { inputTokens: 900, outputTokens: 40 }, message: { content: [{ type: 'text', text: 'Hi!' }] } } },
|
|
{ seq: 8, type: 'compaction/summary', time: 6000, data: { shadowedTokenCount: 5000, shadowedSeqs: [3, 4, 5, 6] } },
|
|
] }
|
|
|
|
const v = drive(live.events)
|
|
assert.equal(v.ok, true)
|
|
assert.equal(v.model, 'deepseek-v4')
|
|
assert.equal(v.provider, 'deepseek')
|
|
assert.equal(v.contextWindow, 128000)
|
|
assert.ok(v.current.system > 0, 'system prompt tokens')
|
|
assert.ok(v.current.tools > 0, 'tool schema tokens')
|
|
assert.ok(v.current.user > 0, 'user message tokens')
|
|
assert.ok(v.current.inject > 0, 'injection tokens')
|
|
assert.ok(v.current.tool > 0, 'tool result tokens')
|
|
assert.ok(v.current.assistant > 0, 'assistant tokens')
|
|
assert.equal(v.requests.length, 1)
|
|
assert.equal(v.requests[0].prompt, 900)
|
|
assert.equal(v.requests[0].output, 40)
|
|
assert.ok(v.events.some(e => e.kind === 'inject' && e.form === 'notice'), 'injection event recorded')
|
|
assert.ok(v.events.some(e => e.kind === 'compaction'), 'compaction event recorded')
|
|
assert.ok(v.nodes.length >= 4, 'surface nodes folded')
|
|
assert.equal(v.nodes[0].time, 2000, 'nodes carry their event timestamp')
|
|
|
|
// Message projection must price CONTENT: assistant replies and tool results
|
|
// carry their message body (data.message), not a flat envelope price.
|
|
const asstNode = v.nodes.find(n => n.seq === 7)
|
|
assert.equal(asstNode.tokens, 9, "assistant 'Hi!' prices its text content (1 + 4 + 4)")
|
|
const toolNode = v.nodes.find(n => n.seq === 6)
|
|
assert.equal(toolNode.tokens, 13, 'tool result prices its nested content (5 + 4 + 4)')
|
|
assert.equal(toolNode.tool, 'bash', 'tool result node names its tool (via source.callId)')
|
|
assert.equal(asstNode.text, 'Hi!', 'assistant node carries its text preview')
|
|
|
|
// -- occupancy is NOT folded by the host anymore (R3): the client reads the
|
|
// official token-meter `contextPressure` projection (token-meter owns
|
|
// estimation and replay); the timeline wire carries only the heuristic
|
|
// composition + per-request provider usage (record.prompt/output) --
|
|
assert.equal(v.occupancy, undefined, 'the host no longer mirrors contextPressure inside contextTimeline')
|
|
assert.equal(v.requests[0].prompt, 900, 'provider usage still rides the request records (usage sample input + cache)')
|
|
|
|
// -- events are attributed to the requests around them (turn/step + range) --
|
|
const injectEv = v.events.find(e => e.kind === 'inject')
|
|
assert.ok(injectEv, 'injection event present')
|
|
assert.equal(injectEv.turn, 1, 'inject before step 1\'s call lands on Turn 1')
|
|
assert.equal(injectEv.step, 1, 'inject lands on Step 1')
|
|
assert.equal(injectEv.fromTurn, undefined, 'no request before the first inject')
|
|
const compactEv = v.events.find(e => e.kind === 'compaction' && e.seq === 8)
|
|
assert.ok(compactEv, 'compaction event present')
|
|
assert.equal(compactEv.turn, undefined, 'compaction with no following request yet stays unlabeled')
|
|
assert.equal(compactEv.step, undefined, 'no step for a trailing event')
|
|
assert.equal(compactEv.fromTurn, 1, 'trailing event still knows the request before it')
|
|
assert.equal(compactEv.fromStep, 1, 'trailing event keeps its from-step')
|
|
|
|
// -- determinism + reference-stability of the projection contract --
|
|
// Unrelated events must return the SAME state reference (Object.is gates the
|
|
// framework change feed); view() must not mutate persisted state.
|
|
let base = def.init()
|
|
for (const ev of live.events) base = def.apply(base, ev)
|
|
assert.ok(base.events.length > 0, 'folded state carries events')
|
|
assert.equal(def.apply(base, { type: 'todo/write', seq: 99, time: 0, data: { todos: [] } }), base,
|
|
'unrelated events keep the same reference (no change notification)')
|
|
assert.equal(def.apply(base, { type: 'totally/unknown', seq: 100, time: 0, data: {} }), base,
|
|
'unknown event vocabulary is safely ignored')
|
|
const emptyView = def.view(def.init())
|
|
assert.equal(emptyView.ok, true)
|
|
assert.equal(emptyView.requests.length, 0)
|
|
assert.equal(emptyView.events.length, 0)
|
|
assert.equal(emptyView.nodes.length, 0)
|
|
assert.equal(emptyView.droppedNodes, 0)
|
|
assert.equal(emptyView.current.total, 0)
|
|
assert.ok(!('turn' in base.events[0] || 'step' in base.events[0]), 'view() attributions stay on copies, never on persisted state')
|
|
|
|
// -- append a same-turn step: the compaction now spans Step 1 -> Step 2 --
|
|
live.events.push({ seq: 9, type: 'assistant/message', time: 7000, data: { turn: 1, step: 2, message: { content: [{ type: 'text', text: 'more' }] } } })
|
|
const after = drive(live.events)
|
|
assert.equal(after.requests.length, 2, 'new request folded')
|
|
assert.equal(after.requests[1].turn, 1)
|
|
const compactAfter = after.events.find(e => e.kind === 'compaction' && e.seq === 8)
|
|
assert.equal(compactAfter.turn, 1, 'compaction inside a turn lands on the same turn')
|
|
assert.equal(compactAfter.step, 2, 'compaction lands on the step whose request follows it')
|
|
assert.equal(compactAfter.fromTurn, 1, 'same-turn range keeps the previous request\'s turn')
|
|
assert.equal(compactAfter.fromStep, 1, 'same-turn range keeps the previous step')
|
|
|
|
// -- a cross-turn compaction: prev turn 1 step 2 -> next turn 2 step 1 --
|
|
live.events.push({ seq: 10, type: 'compaction/summary', time: 7500, data: { shadowedTokenCount: 3000, shadowedSeqs: [3, 4] } })
|
|
live.events.push({ seq: 11, type: 'assistant/message', time: 8000, data: { turn: 2, step: 1, message: { content: [{ type: 'text', text: 'more2' }] } } })
|
|
const cross = drive(live.events)
|
|
const compactCross = cross.events.find(e => e.kind === 'compaction' && e.seq === 10)
|
|
assert.equal(compactCross.turn, 2, 'between-turn compaction lands on the next turn')
|
|
assert.equal(compactCross.step, 1, 'lands on the next turn\'s first step')
|
|
assert.equal(compactCross.fromTurn, 1, 'cross-turn range remembers the previous turn')
|
|
assert.equal(compactCross.fromStep, 2, 'cross-turn range remembers the previous step')
|
|
|
|
// -- an empty-content assistant message (usage-only step) prices 0 tokens --
|
|
live.events.push({ seq: 12, type: 'assistant/message', time: 9000, data: { turn: 2, step: 2, message: { content: [] } } })
|
|
const emptyMsg = drive(live.events)
|
|
const emptyNode = emptyMsg.nodes.find(n => n.seq === 12)
|
|
assert.ok(emptyNode, 'usage-only message is still a surface node')
|
|
assert.equal(emptyNode.tokens, 0, 'empty assistant message prices 0, like dsh deriveEventMessage')
|
|
// A LATER step in the same turn must not re-attribute the compaction.
|
|
const compactStill = emptyMsg.events.find(e => e.kind === 'compaction' && e.seq === 8)
|
|
assert.equal(compactStill.turn, 1, 'compaction stays on Turn 1')
|
|
assert.equal(compactStill.step, 2, 'compaction stays on Step 2 (first request after it)')
|
|
|
|
// Repeat the same log: the fold is a deterministim replay, so a fresh fold
|
|
// must reproduce the identical wire value (what the projection cache relies on).
|
|
const again = drive(live.events)
|
|
assert.deepEqual(again, emptyMsg, 'deterministic replay across fresh folds')
|
|
|
|
// -- the shadow price covers the SEQ list, not the declared range end: a
|
|
// prune replacement node whose seq lies beyond the range must still be
|
|
// removed (the producer's shadowedSeqs include it) --
|
|
const shadow = drive([
|
|
{ seq: 1, type: 'user/message', time: 1000, data: { content: [{ type: 'text', text: 'a'.repeat(40) }] } },
|
|
// two tool results, 80-char text each: 20 + 4 (text) + 4 (tool-result) + 4 (role) = 32
|
|
{ seq: 2, type: 'tool/result', time: 2000, data: { message: { source: { kind: 'tool', callId: 'c1' }, content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'b'.repeat(80) }] }] } } },
|
|
{ seq: 3, type: 'tool/result', time: 3000, data: { message: { source: { kind: 'tool', callId: 'c2' }, content: [{ type: 'tool-result', toolCallId: 'c2', content: [{ type: 'text', text: 'c'.repeat(80) }] }] } } },
|
|
// metering event: range says [2,2] but the shadowed seqs cover 2 AND 3
|
|
{ seq: 4, type: 'compaction/prune', time: 4000, data: { shadowedRange: { start: 2, end: 2 }, shadowedSeqs: [2, 3], shadowedTokenCount: 64 } },
|
|
// replacement: 'd' -> 1 + 4 (text) + 4 (tool-result) + 4 (role) = 13
|
|
{ seq: 5, type: 'tool/result', time: 5000, surfaceOp: { op: 'replace', start: 2, end: 2 }, data: { message: { source: { kind: 'tool', callId: 'c3' }, content: [{ type: 'tool-result', toolCallId: 'c3', content: [{ type: 'text', text: 'd' }] }] } } },
|
|
])
|
|
assert.equal(shadow.current.tool, 13, 'shadowed seqs beyond the range end are removed too (only the replacement survives)')
|
|
assert.equal(shadow.current.user, 18, 'unrelated nodes keep their price (40 chars: 10 + 4 + 4)')
|
|
assert.equal(shadow.current.total, 18 + 13, 'total reflects the seq-based removal')
|
|
// no tool/call events in this log, so no call name is registered — the tool
|
|
// label stays empty (never a crash)
|
|
assert.equal(shadow.nodes.find(n => n.seq === 5).tool, undefined, 'no registered call name -> no tool label')
|
|
|
|
// -- tool names also resolve from the content block's toolCallId when the
|
|
// source is absent on a (legacy) envelope --
|
|
const legacyTool = drive([
|
|
{ seq: 1, type: 'user/message', time: 1000, data: { content: [{ type: 'text', text: 'x'.repeat(40) }] } },
|
|
{ seq: 2, type: 'tool/result', time: 2000, data: { message: { content: [{ type: 'tool-result', toolCallId: 't9', content: [{ type: 'text', text: 'y' }] }] } } },
|
|
])
|
|
assert.equal(legacyTool.nodes.find(n => n.seq === 2).tool, undefined,
|
|
'no source and no registered call name -> no tool label (never crashes)')
|
|
|
|
// -- turn-based retention: long sessions trim by whole turns, never mid-turn --
|
|
// 400 turns x 4 steps = 1600 requests exceeds the step bound; the fold must
|
|
// keep the newest 300 WHOLE turns (turns 101..400, 1200 requests).
|
|
const many = []
|
|
let seq = 1000
|
|
for (let turn = 1; turn <= 400; turn++) {
|
|
for (let step = 0; step < 4; step++) {
|
|
many.push({
|
|
seq: seq++, type: 'assistant/message', time: seq * 1000,
|
|
data: { turn, step, message: { content: [{ type: 'text', text: 'x' }] } },
|
|
})
|
|
}
|
|
}
|
|
const long = drive(many)
|
|
assert.equal(long.requests.length, 1200, 'kept 300 turns x 4 steps after trimming')
|
|
assert.equal(long.requests[0].turn, 101, 'trim starts at a whole turn boundary')
|
|
assert.equal(long.requests[0].step, 0, 'trim starts at the first step of that turn')
|
|
const keptTurns = new Set(long.requests.map(r => r.turn))
|
|
assert.equal(keptTurns.size, 300, 'all 300 kept turns are present')
|
|
assert.ok([...keptTurns].every(t => t >= 101 && t <= 400), 'kept turns are the newest ones')
|
|
assert.equal(long.requests.filter(r => r.turn === 101).length, 4, 'first kept turn is complete')
|
|
assert.equal(long.requests.filter(r => r.turn === 400).length, 4, 'last turn is complete')
|
|
|
|
// -- streamed chunks are structurally ignored (same reference): token-level
|
|
// events carry no state change for the timeline since the occupancy mirror
|
|
// left the fold (R3) --
|
|
const chunkEvent = { seq: 3, type: 'assistant/chunk', time: 3000, data: { turn: 1, step: 1, chunk: { type: 'usage', usage: { inputTokens: 5000 } } } }
|
|
assert.equal(def.apply(base, chunkEvent), base, 'usage chunks keep the same reference (no change feed)')
|
|
const snap3 = drive([
|
|
{ seq: 1, type: 'request/context', time: 1000, data: { contextWindow: 100000 } },
|
|
{ seq: 2, type: 'user/message', time: 2000, data: { content: [{ type: 'text', text: 'a'.repeat(40) }] } },
|
|
{ seq: 3, type: 'assistant/chunk', time: 3000, data: { turn: 1, step: 1, chunk: { type: 'usage', usage: { inputTokens: 5000, cacheReadTokens: 1000, outputTokens: 0 } } } },
|
|
])
|
|
assert.equal(snap3.occupancy, undefined, 'chunk usage never re-enters the timeline')
|
|
assert.equal(snap3.contextWindow, 100000, 'route capacity still folds from request/context')
|
|
|
|
// -- entry config: bounded slices are honored (config -> bounds threading) --
|
|
let cfgDef = null
|
|
apply({ ...fakeCtx, sessionProjections: { register(d) { cfgDef = d; return () => {} } } }, { maxNodes: 2, maxKeptTurns: 1 })
|
|
const bounded = (() => {
|
|
let st = cfgDef.init()
|
|
for (const ev of live.events) st = cfgDef.apply(st, ev)
|
|
return cfgDef.view(st)
|
|
})()
|
|
assert.ok(bounded.nodes.length <= 2, 'maxNodes bounds the served surface slice')
|
|
assert.ok(bounded.requests.length <= 8, 'maxKeptTurns bounds the retained history (400-turn fixture trimmed by whole turns)')
|
|
assert.equal(typeof cfgDef.schema.safeParse(bounded).success, 'boolean', 'custom-bounds view still passes the unit schema')
|
|
|
|
// -- model-switch semantics (R6): the only durable signal is a request
|
|
// header differing from the previous one (reason 'change'); route/capacity
|
|
// metadata (request/context) must NOT fire a switch event by itself, and a
|
|
// provider-only change with the same model is not a switch either --
|
|
const switchLog = drive([
|
|
{ seq: 1, type: 'request/header', time: 1000, data: { reason: 'initial', header: { system: 's', config: { model: 'model-a', provider: 'deepseek' } } } },
|
|
{ seq: 2, type: 'request/header', time: 2000, data: { reason: 'change', header: { system: 's', config: { model: 'model-b', provider: 'deepseek' } } } },
|
|
{ seq: 3, type: 'request/context', time: 2000, data: { model: 'model-b', provider: 'deepseek', contextWindow: 64000 } },
|
|
{ seq: 4, type: 'request/header', time: 3000, data: { reason: 'change', header: { system: 's', config: { model: 'model-b', provider: 'openai' } } } },
|
|
{ seq: 5, type: 'request/header', time: 4000, data: { reason: 'change', header: { system: 's', config: { model: 'model-b', provider: 'openai' } } } },
|
|
])
|
|
const switchEvents = switchLog.events.filter(e => e.kind === 'model')
|
|
assert.equal(switchEvents.length, 1, 'exactly one model-switch event')
|
|
assert.equal(switchEvents[0].from, 'model-a', 'switch records the previous model')
|
|
assert.equal(switchEvents[0].to, 'model-b', 'switch records the new model')
|
|
assert.equal(switchLog.model, 'model-b', 'current model follows the header')
|
|
assert.equal(switchLog.provider, 'openai', 'provider-only change updates the route, not a switch')
|
|
assert.equal(switchLog.contextWindow, 64000, 'request/context supplies route capacity')
|
|
|
|
// Usage mapping (R5): prompt-side = input + cacheRead + cacheWrite (billed
|
|
// input), output = outputTokens; reasoningTokens stay inside outputTokens.
|
|
const usageLog = drive([
|
|
{ seq: 1, type: 'assistant/message', time: 1000, data: { turn: 1, step: 1, usage: { inputTokens: 800, cacheReadTokens: 150, cacheWriteTokens: 50, outputTokens: 30, reasoningTokens: 10 }, message: { content: [] } } },
|
|
])
|
|
assert.equal(usageLog.requests[0].prompt, 1000, 'prompt side sums the disjoint input buckets')
|
|
assert.equal(usageLog.requests[0].output, 30, 'output is outputTokens (reasoning already inside)')
|
|
|
|
console.log('✔ host half functional test passed (projection unit, fold semantics, attribution, retention, shadow-price seqs, reference stability, determinism, config bounds, model switch, usage mapping)')
|