Files
dsh-context/tests/host.test.mjs
T
bowenliang123 6bebd495e9 feat: route context timeline through the harness session-projection pipeline
Replace the custom /dsh-context RPC channel + 2s client polling with a native
contextTimeline session-projection unit: the host registers one pure
init/apply/view unit on ctx.sessionProjections (framework-driven, persisted by
the projection cache, streamed via session/projection push frames), and the
client reads the finished value through the framework standard
useProjection seat. Deletes the client stale-while-revalidate cache and the
local service type stubs; grows the modern shelf registration (locale) and
adds the published @deepseek-ai/* types as devDeps plus zod as the single
runtime dependency (wire schema, kept external in the host bundle).

Fixes tool-result node attribution to read the durable source.callId /
content toolCallId instead of the never-present block.callId (tool labels had
silently never rendered).

Retention is tightened to trim by whole turn-runs deterministically (bounded
state, no 1200<->1500 oscillation under incremental folding).

Tests now drive the projection unit directly (host) and feed the view through
a useProjection stub (client); all chart/render assertions preserved. Adds
docs/redesign.md (review + target architecture, phases 0-3 landed) and
docs/CLIENT_SURFACE.md (client-side audit).
2026-08-16 03:25:01 +08:00

228 lines
14 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: provider-anchored, mirroring the official contextPressure --
// The usage sample (seq 7: input 900, no cache) is stamped BEFORE its own
// message joins the surface: sampled 53 = user 28 + inject 12 + tool 13,
// surface now 62 (adds the assistant 'Hi!' 9), so projected = 900 + 62 - 53.
assert.ok(v.occupancy, 'occupancy projection present')
assert.equal(v.occupancy.pressureTokens, 900, 'pressure is input + cache traffic')
assert.equal(v.occupancy.sampledSurfaceTokens, 53, 'sample anchored before the message joins the surface')
assert.equal(v.occupancy.surfaceTokens, 62, 'surface total is current')
assert.equal(v.occupancy.projectedTokens, 909, 'sample carried forward by the surface movement')
assert.equal(v.occupancy.contextWindow, 128000, 'occupancy window from request/context')
// -- 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')
// -- a streamed usage chunk is an occupancy sample even with no
// assistant/message (e.g. the step failed mid-stream) --
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.pressureTokens, 6000, 'chunk usage supplies the pressure sample')
assert.equal(snap3.occupancy.projectedTokens, 6000, 'no surface movement since the chunk sample')
assert.equal(snap3.occupancy.contextWindow, 100000, 'occupancy window present without any request record')
console.log('✔ host half functional test passed (projection unit, fold semantics, attribution, retention, shadow-price seqs, reference stability, determinism)')