Files
dsh-context/tests/host/fold-developer.spec.ts
T
quifox b65939e887 fix(host): fold V4 tool-registry developer messages into the timeline
V4 harness releases log a `developer/message` event whenever the effective tool registry changes mid-session (first shipped in 0.1.7-rc.2): the message nests under data.message with role `developer`, source kind `tool-registry`, and `tool-addition`/`tool-removal` content blocks, lands on the session surface, and the harness's own token meter prices it like any non-system message. The timeline fold had no case for it, so every such message vanished from the plugin's accounting — injection totals, per-request composition, the live surface, and compaction archives under-reported, and the Context tab's numbers could not reconcile with the harness meter or a compaction shadow price.

The fold now treats the event like an injected user message: the nested message is read structurally (the pinned 0.1.5-rc.1 dependency's deriveEventMessage predates the family), the node prices through the shared estimator with the harness's empty-content-is-zero rule, and the injection record takes the producer kind as its label. Developer messages never count as human inputs whatever a hostile source claims; malformed payloads leave the state reference-equal.

stateVersion 24 -> 25: earlier cached rows omitted these nodes and later events cannot recover them, so on upgrade every timeline row refolds once from the durable log (the dashboard warm-up rebuilds idle sessions). The wire shape and durable logs are unchanged. The V4 baselines now carry the event family, and the real-source compat matrix asserts surface tokens, request composition, warm restore, and a v24-row rebuild on both supported V4 lines.

Validation: 102 test files / 1,910 tests with per-file coverage 100%; the real-source matrix passed for 0.1.5-rc.1, 0.1.7-rc.2, and 0.2.0-rc.2; GitHub PR checks green (486069f fixed the workflow's pnpm setup that had failed every external PR at its first step).
2026-10-04 00:03:27 +08:00

121 lines
4.9 KiB
TypeScript

import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import type { TimelineEvent } from '../../src/host/fold'
import { assistantMessage, at, compaction, userMessage } from './helpers/events'
import { assertStatesPlainJson, driveTimeline, timelineDef } from './helpers/projection'
// The agent loop logs these blocks when the effective tool registry changes.
const changes = [
{ type: 'tool-addition', toolName: 'read_file' },
{ type: 'tool-removal', toolName: 'shell' },
]
function developerMessage(seq: number, options: {
content?: unknown
source?: unknown
surfaceOp?: unknown
} = {}): TimelineEvent {
return {
type: 'developer/message', seq, time: at(), surfaceOp: options.surfaceOp ?? 'append',
data: {
turn: 1, step: 1, headerSeq: 0,
message: {
role: 'developer',
source: options.source ?? { kind: 'tool-registry' },
content: options.content ?? changes,
},
},
}
}
describe('developer/message', () => {
test('tool-registry changes enter the surface, request composition and served detail', () => {
const result = driveTimeline([
userMessage(1, [{ type: 'text', text: 'hello' }]),
developerMessage(2),
assistantMessage(3, { turn: 1, step: 1 }),
])
const node = result.state.surface.find(n => n.seq === 2)
assert.ok(node, 'the model-visible registry change must not disappear')
assert.equal(node.cat, 'inject')
assert.equal(node.tokens, 35)
assert.equal(node.name, 'tool-registry')
assert.equal(result.state.humanInputs, 1)
assert.equal(result.state.requests[0].inject, 35)
assert.equal(result.state.sums.inject, 35)
assert.equal(result.view.nodes.some(n => n.seq === 2 && n.tokens === 35), true)
assert.equal(result.state.events.find(e => e.seq === 2)?.kind, 'inject')
assertStatesPlainJson(result)
result.def.stateSchema.parse(result.state)
result.def.wire.viewSchema.parse(result.view)
})
test('compaction removes and archives registry nodes with the rest of the claimed surface', () => {
const result = driveTimeline([
userMessage(1, [{ type: 'text', text: 'hello' }]),
developerMessage(2),
assistantMessage(3, { turn: 1, step: 1 }),
compaction(4, 'summary', { shadowedSeqs: [1, 2, 3], shadowedTokenCount: 55 }),
userMessage(5, [{ type: 'text', text: 'summary' }], { kind: 'plugin', form: 'summary' }, {
surfaceOp: { op: 'replace', startSeq: 1, endSeq: 3 },
}),
assistantMessage(6, { turn: 2, step: 1 }),
])
assert.equal(result.state.surface.some(n => n.seq === 2), false)
const archived = result.state.archived.find(n => n.seq === 2)
assert.equal(archived?.tokens, 35)
assert.equal(archived?.gone, 5)
assert.equal(result.state.requests[0].inject, 35)
assert.equal(result.state.requests[1].inject, 10)
assertStatesPlainJson(result)
})
test('a developer replacement archives the replaced injection and bumps the detail revision', () => {
const result = driveTimeline([
developerMessage(1),
developerMessage(2, {
content: [{ type: 'tool-removal', toolName: 'read_file' }],
surfaceOp: { op: 'replace', startSeq: 1, endSeq: 1 },
}),
])
assert.deepEqual(result.state.surface.map(n => n.seq), [2])
assert.equal(result.state.archived[0].seq, 1)
assert.equal(result.state.archived[0].gone, 2)
assert.equal(result.state.sums.inject, result.state.surface[0].tokens)
assert.equal(result.state.detailRev, 2)
assert.equal(result.state.humanInputs, undefined)
assertStatesPlainJson(result)
})
test('empty content keeps its surface position without adding model tokens', () => {
const result = driveTimeline([developerMessage(1, { content: [] })])
assert.equal(result.state.surface[0].seq, 1)
assert.equal(result.state.surface[0].tokens, 0)
assert.equal(result.state.sums.inject, 0)
assert.equal(result.state.events[0].tokens, 0)
})
test.each([undefined, null, {}, { kind: 'user' }])('a developer source %j never counts as a human input', source => {
const event = developerMessage(1)
const message = event.data!.message as Record<string, unknown>
if (source === undefined) delete message.source
else message.source = source
const result = driveTimeline([event])
assert.equal(result.state.surface[0].cat, 'inject')
assert.equal(result.state.sums.inject, 35)
assert.equal(result.state.humanInputs, undefined)
assert.equal(result.state.events[0].kind, 'inject')
assertStatesPlainJson(result)
})
test.each([undefined, {}, { message: null }, { message: { content: 'invalid' } }])(
'a malformed nested message leaves the state unchanged: %j', data => {
const def = timelineDef()
const initial = def.init()
const event: TimelineEvent = { type: 'developer/message', seq: 1, time: at(), ...(data === undefined ? {} : { data }) }
assert.equal(def.apply(initial, event), initial)
def.stateSchema.parse(initial)
},
)
})