From 9e6675ce1d5cd914fd036a1c2e8baae2e69043f8 Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Mon, 17 Aug 2026 01:17:00 +0800 Subject: [PATCH] =?UTF-8?q?test(host):=20pin=20R5/R6=20semantics=20?= =?UTF-8?q?=E2=80=94=20usage=20bucket=20mapping=20and=20model-switch=20sig?= =?UTF-8?q?nal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against the harness source before locking in: - agent-loop appends request/header (reason 'change' when the envelope differs) BEFORE request/context per request, so a model switch is always a header change — firing the model event only there equals the durable record, and route/capacity metadata can never double-fire it. - dsh-llm TokenUsage buckets are disjoint: billed prompt-side = inputTokens + cacheReadTokens + cacheWriteTokens, outputTokens already contains reasoningTokens — the fold's prompt/output mapping was already correct; comments now cite the official semantics. No behavior change; new assertions cover the switch event (exactly one, from model-a to model-b), provider-only changes (route update, not a switch), and the usage mapping (1000 = 800+150+50; output 30). --- src/host/fold.ts | 12 ++++++++++++ tests/host.test.mjs | 29 ++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/host/fold.ts b/src/host/fold.ts index c832dcd..3780c6d 100644 --- a/src/host/fold.ts +++ b/src/host/fold.ts @@ -341,8 +341,15 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds // every schema); per-tool prices above are display-only rankings. s.toolsTokens = estimateToolsTotal(tools) s.systemTokens = estimateSystem(header.system) + // Current route/model: the durable request envelope is the source of + // truth (request/context is only route/capacity metadata, appended + // AFTER request/header per request — see agent-loop buildRequestHeader). if (header.config && typeof header.config.model === 'string') s.model = header.config.model if (header.config && typeof header.config.provider === 'string') s.provider = header.config.provider + // A model switch has no dedicated durable event: it is a request + // header that differs from the previous one, logged with reason + // 'change' ('initial' opens a session, 'resume' reopens it). Firing + // only here keeps the model event list equal to the durable record. if (data?.reason === 'change' && s.model && s.lastModel && s.model !== s.lastModel) { s.events.push({ seq: event.seq, time: event.time, kind: 'model', from: s.lastModel, to: s.model }) } @@ -425,6 +432,11 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds total, } if (usage && typeof usage.inputTokens === 'number') { + // Official TokenUsage semantics (dsh-llm): the buckets are disjoint — + // inputTokens is uncached input only, cache read/write are separate, + // and billed prompt-side = input + cacheRead + cacheWrite. outputTokens + // already includes reasoningTokens. No separate prompt/output field + // exists in the durable vocabulary. record.prompt = usage.inputTokens + (usage.cacheReadTokens || 0) + (usage.cacheWriteTokens || 0) if (typeof usage.outputTokens === 'number') record.output = usage.outputTokens } diff --git a/tests/host.test.mjs b/tests/host.test.mjs index 922b02e..5b1ce89 100644 --- a/tests/host.test.mjs +++ b/tests/host.test.mjs @@ -236,4 +236,31 @@ 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') -console.log('✔ host half functional test passed (projection unit, fold semantics, attribution, retention, shadow-price seqs, reference stability, determinism, config bounds)') +// -- 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)')