From 22eb2ed2f48ebe5d438e36efbc51fcd859da1a38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E8=AF=AD=E6=83=8A=E8=8A=B1?= <105714958+Nwflower@users.noreply.github.com> Date: Tue, 22 Sep 2026 06:16:22 -0400 Subject: [PATCH] perf(host): cut the timeline fold's per-event cost and fix the Windows cross-drive path check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three per-event costs leave the fold: every state-changing event shallow-copied all seven retained collections, every event re-scanned the retained requests to count turn runs, and every tool call stringified its arguments even when the tool can never row a file op. Cloning now follows the branch's own mutation list, the run count rides an incremental ledger (recomputed only where a trim replaces the array), and raw arguments are kept only where they are read. Measured over 279 real sessions (282,783 events): the fold drops 16.8% corpus wide, ~51% on V3-shaped logs and ~57% on sessions at the retention caps, with state output field-identical apart from the new optional `turnRuns` field and the intended `argsRaw` omission. `stateVersion` is unchanged, so cached rows keep parsing and stay usable. The Windows check rode along: `path.relative` returns an ABSOLUTE path across drives, so the old `..`-prefix test read a foreign drive's module as a child of the plugin's own tree and degraded version detection; `!isAbsolute(rel)` closes it. Review follow-up on top of the original six-file change: - an absent `timing` slot stays absent. The selective clone materialized `timing: undefined`, which is not losslessly JSON-serializable, so `snapshotJsonValue` rejected the whole checkpoint: every projection-cache write for that session was skipped, and a log ending on an unpaired `tool/result` never got a cache row at all — every reopen then paid the full cold fold this change set aimed to cut; - the no-`undefined`-member rule is now pinned on every intermediate fold state, not only the last one — the gap that let the above ride through a green suite, since reading the slot cannot tell an absent key from an `undefined`-valued one; - the "which tool needs its raw arguments" gate moved beside the file-op parser (`opBearingTool` / `rawArgsNeeded`), so the fold no longer restates the tool list. Verified: `pnpm run lint:fix && pnpm run test && pnpm run build` green (1763 tests, per-file coverage 100%), plus a field-level diff of the fold over 282,783 real events (no divergence beyond the intended deltas), the incremental ledger cross-checked against a full scan on every event including both trims, and a frozen-predecessor run proving no in-place write. Co-authored-by: Nwflower <1679659@qq.com> --- src/host/fold.ts | 134 +++++++++++++++++++++--------- src/host/timeline.ts | 1 + src/host/version.ts | 12 ++- src/shared/fileOps.ts | 25 +++++- tests/host/fold-ops.spec.ts | 26 ++++++ tests/host/fold-retention.spec.ts | 30 +++++++ tests/host/fold-timing.spec.ts | 10 ++- tests/host/helpers/projection.ts | 27 +++++- 8 files changed, 214 insertions(+), 51 deletions(-) diff --git a/src/host/fold.ts b/src/host/fold.ts index 5b74f80..e9230e4 100644 --- a/src/host/fold.ts +++ b/src/host/fold.ts @@ -36,7 +36,7 @@ import type { ContentBlock, MessageSource } from './pricing' import { deriveEventMessage } from '@deepseek-ai/dsh-session' import { decodeKindOfBlock, decodeSpansOfStream, firstTokenTimeOfStream, isTokenChunk, replaceRangeOf } from './logShapes' import type { DecodeKind } from './logShapes' -import { opsOfCall, parseCallArgs } from '../shared/fileOps' +import { opBearingTool, opsOfCall, parseCallArgs, rawArgsNeeded } from '../shared/fileOps' /** * The runtime event envelope this fold consumes. The core @@ -107,6 +107,16 @@ export interface TimelineState { lastModel?: string contextWindow?: number requests: RequestRecord[] + /** + * The number of turn runs in `requests` (a run = consecutive records + * sharing one `turn`). Maintained incrementally at the single append site + * and recomputed when a trim replaces the array, so the retention trim's + * cap check stays O(1) per event instead of walking every retained + * request. Absent on rows folded before the field existed — the first + * post-restore event recomputes it once (the same additive-optional shape + * as `detailRev`). + */ + turnRuns?: number events: ContextEventRecord[] /** * Recently removed surface nodes (stamped COPIES carrying `gone`), in @@ -257,13 +267,18 @@ function trimState(st: TimelineState, bounds: FoldBounds): void { // Trim by WHOLE turn-runs as soon as the run count crosses the cap — // not only when the raw step count does — so the state stays // deterministically at the newest ~maxKeptTurns turns (a threshold-only - // policy would oscillate: trim to 1200, regrow to 1500, trim again). - if (countTurnRuns(st.requests) > bounds.maxKeptTurns) { + // policy would oscillate: trim to 1200, regrow to 1500, trim again). The + // run count is the incremental `turnRuns` ledger; a row restored from + // cache before the field existed recomputes it once here. + st.turnRuns = st.turnRuns ?? countTurnRuns(st.requests) + if (st.turnRuns > bounds.maxKeptTurns) { st.requests = trimToLastTurns(st.requests, bounds.maxKeptTurns) + st.turnRuns = countTurnRuns(st.requests) } // Pathological many-step turns: hard step backstop after the turn trim. if (st.requests.length > bounds.maxRequestSteps) { st.requests = st.requests.slice(-bounds.maxRequestSteps) + st.turnRuns = countTurnRuns(st.requests) } if (st.events.length > bounds.maxEvents) st.events = st.events.slice(-bounds.maxEvents) // The file-op log: newest tail; the newest dropped op's seq rides @@ -304,6 +319,7 @@ export function createTimelineState(): TimelineState { systemTokens: 0, toolsTokens: 0, requests: [], + turnRuns: 0, events: [], archived: [], callNames: {}, @@ -804,28 +820,47 @@ function bumpToolTotals(timing: TimingTotals, name: string, ms: number): void { timing.tools[name] = { calls: cur.calls + 1, ms: cur.ms + ms } } +/** + * The collections a fold branch may mutate IN PLACE (`push`/splice/element + * or property writes). A branch names exactly its own mutations in `ensure`; + * the unlisted containers (`systems`, `pendingCodeOps`, `cost`, `stepStart`, + * `signature of pendingShadowedSeqs`) are never written in place — every + * touch REPLACES the property with a fresh array/record — so they stay + * shared with the persisted previous state without a clone. + */ +type CloneKey = 'surface' | 'sums' | 'requests' | 'events' | 'archived' | 'callNames' | 'fileOps' | 'timing' + +const CLONE_ALL: readonly CloneKey[] = ['surface', 'sums', 'requests', 'events', 'archived', 'callNames', 'fileOps', 'timing'] + export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds: FoldBounds): TimelineState { let st: TimelineState | undefined - const ensure = (): TimelineState => st ??= { - ...state, - surface: [...state.surface], - sums: { ...state.sums }, - requests: [...state.requests], - events: [...state.events], - archived: [...state.archived], - callNames: { ...state.callNames }, - fileOps: [...state.fileOps], - // The pending-ops MAP is cloned here; each key's array is rebuilt on - // touch (bufferCodeOps/flush), never mutated in place — same rule. - ...(state.pendingCodeOps !== undefined - ? { pendingCodeOps: { ...state.pendingCodeOps } } - : {}), - // The timing totals are shared with the persisted previous state — - // private working copies for this event's accumulations (per-name rows - // are replaced, never mutated, so a one-level copy suffices for them). - ...(state.timing !== undefined - ? { timing: { ...state.timing, tools: { ...state.timing.tools } } } - : {}), + /** + * The event's private working state: a shallow object clone up front, with + * only the named collections cloned into private copies. Everything else + * stays shared with the persisted previous state — the apply contract is + * "never mutate the previous state in place". Called EXACTLY ONCE per event + * (each case names the collections its own path may write into; helpers + * never call it), so building afresh per call is safe. The default is the + * full clone — a future branch that omits its list degrades to the old + * behavior instead of sharing a collection it mutates. + */ + const ensure = (keys: readonly CloneKey[] = CLONE_ALL): TimelineState => { + st = { ...state } + for (const key of keys) { + if (key === 'surface') st.surface = [...state.surface] + else if (key === 'sums') st.sums = { ...state.sums } + else if (key === 'requests') st.requests = [...state.requests] + else if (key === 'events') st.events = [...state.events] + else if (key === 'archived') st.archived = [...state.archived] + else if (key === 'callNames') st.callNames = { ...state.callNames } + else if (key === 'fileOps') st.fileOps = [...state.fileOps] + // An ABSENT `timing` stays absent: a branch may name the key without + // ever reaching `ensureTiming` (an unpaired tool/result prices no call), + // and an own `undefined`-valued property fails EVERY projection-cache + // write for the session (the plain-JSON precondition, see TimelineState). + else if (state.timing !== undefined) st.timing = { ...state.timing, tools: { ...state.timing.tools } } + } + return st } const data = event.data @@ -844,7 +879,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds config?: { model?: unknown; provider?: unknown } } const tools = Array.isArray(header.tools) ? header.tools : [] - const s = ensure() + const s = ensure(['events']) // Tools TOTAL = dsh's whole-array price (one JSON string of every schema). s.toolsTokens = estimateToolsTotal(tools) // The V0/V2 system prompt rides this ENVELOPE; V3 rejects it outright @@ -891,7 +926,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds // categories — it is the envelope figure's source, never a // user/inject/assistant/tool node, so it must not enter `surface` or // `sums` (that would double-count it against `systemTokens`). - const s = ensure() + const s = ensure(['surface', 'sums', 'archived']) // Consume the armed shadow claim (the shadow-price protocol expires it // on the next surface event) — a system node never carries one. delete s.pendingShadowedSeqs @@ -914,7 +949,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds break } case 'request/context': { - const s = ensure() + const s = ensure([]) // Route/capacity metadata: request/context is logged only when the route or capacity changes (after request/header), so it updates // the current route display — never firing a model-switch event on its own. if (data && typeof data.contextWindow === 'number') s.contextWindow = data.contextWindow @@ -924,9 +959,13 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds } case 'tool/call': { if (data && typeof data.callId === 'string' && typeof data.name === 'string') { - const s = ensure() - // The raw arguments ride along for the result-time file-op derivation (shared/fileOps.ts). - const argsRaw = argsRawOf(data.arguments) + const s = ensure(['callNames']) + // The raw arguments ride along for the result-time file-op derivation + // (shared/fileOps.ts) — ONLY where they can be read: an op-bearing + // tool, or the run_code root whose description the flush reads. + // Skipping the stringify for the rest keeps a large bash/pwsh call's + // arguments out of the persisted state. + const argsRaw = rawArgsNeeded(data.name) ? argsRawOf(data.arguments) : undefined s.callNames[data.callId] = { name: data.name, start: event.time, @@ -948,15 +987,19 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds const rootCallId = data?.rootCallId const name = data?.name if (typeof rootCallId === 'string' && typeof name === 'string') { + // Same gating as tool/call, minus the run_code arm: a dispatch's + // arguments only ever feed its own op derivation, so anything that + // cannot row an op is never serialized. + const argsRaw = opBearingTool(name) ? argsRawOf(data?.arguments) : undefined const ops = opsOfCall({ seq: event.seq, time: event.time, tool: name, - argsRaw: argsRawOf(data?.arguments), + argsRaw, err: data?.isError === true, }) if (ops.length > 0) { - const s = ensure() + const s = ensure([]) bufferCodeOps(s, rootCallId, ops) } } @@ -980,7 +1023,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds // An unknown marker still CLOSES the open block (its end is real); // only the interval it would open stays unattributed. if (start.block === undefined && kind === undefined) return state - const s = ensure() + const s = ensure([]) const decode = { ...(start.decode ?? { reasoning: 0, text: 0, toolarg: 0 }) } if (start.block !== undefined) decode[start.block.kind] += durOf(start.block.since, event.time) // The next block is ABSENT (not undefined-valued) when unknown — the @@ -995,7 +1038,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds } if (start.firstToken !== undefined) return state if (!isTokenChunk(data?.chunk)) return state - const s = ensure() + const s = ensure([]) s.stepStart = { ...start, firstToken: event.time } break } @@ -1009,7 +1052,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds if (start === undefined || start.firstToken !== undefined) return state const first = firstTokenTimeOfStream(data?.stream) if (first === undefined) return state - const s = ensure() + const s = ensure([]) s.stepStart = { time: start.time, firstToken: first } break } @@ -1020,7 +1063,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds // the whole step against this instant. Always a state change (a new // slot value), even over an un-consumed predecessor — sequential logs // never hit that, hostile ones just supersede it. - const s = ensure() + const s = ensure([]) s.stepStart = { time: event.time } break } @@ -1029,7 +1072,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds // refold) — nothing to price, and the state must stay reference-equal. const start = state.stepStart if (start === undefined) return state - const s = ensure() + const s = ensure(['timing']) ensureTiming(s).wallMs += durOf(start.time, event.time) // Consume-once: DELETE the optional field — assigning `undefined` // would break the plain-JSON persisted-state precondition. @@ -1040,7 +1083,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds // `deriveEventMessage` is the canonical per-event projection: returns // `event.data` for user/message (no `data.message` indirection). const msg = deriveEventMessage(event as never) as MessageLike | null - const s = ensure() + const s = ensure(['surface', 'sums', 'archived', 'events']) bumpDetailRev(s) const node = applySurface(s, event, event.type, data, msg) const source = msg?.source @@ -1093,7 +1136,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds ?? (typeof blockId === 'string' ? state.callNames[blockId] : undefined) const buffered = (typeof srcId === 'string' ? state.pendingCodeOps?.[srcId] : undefined) ?? (typeof blockId === 'string' ? state.pendingCodeOps?.[blockId] : undefined) - const s = ensure() + const s = ensure(['surface', 'sums', 'archived', 'events', 'fileOps', 'timing']) bumpDetailRev(s) const node = applySurface(s, event, event.type, data, toolMsg) // An answered question prompt is a human input too (whole-session @@ -1161,7 +1204,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds // Snapshot the request exactly as dispatched: current surface + header, // before this response joins the surface. const usage = data?.usage as UsageLike | null | undefined - const s = ensure() + const s = ensure(['surface', 'sums', 'archived', 'events', 'requests', 'timing']) bumpDetailRev(s) const total = s.systemTokens + s.toolsTokens + s.sums.user + s.sums.inject + s.sums.skill + s.sums.assistant + s.sums.tool const record: RequestRecord = { @@ -1215,6 +1258,15 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds } } s.requests.push(record) + // The `turnRuns` ledger (see TimelineState): one turn run per change + // of the `turn` value, exactly `countTurnRuns`'s rule — the last + // retained request decides the delta, and a restored row without the + // field recomputes its base once. + const prevLast = state.requests.at(-1) + s.turnRuns = (state.turnRuns ?? countTurnRuns(state.requests)) + + (prevLast === undefined + ? (record.turn !== undefined ? 1 : 0) + : (record.turn === prevLast.turn ? 0 : 1)) // Timing: one completed model call; its wait/generation split prices // off the slot's first-token stamp. That stamp comes from a V0 // `assistant/chunk` delta or, when the log carries none, from the @@ -1272,7 +1324,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds // Plan mode adds a guidance section to every model request while // active — a real context-composition change, so it earns an event. if (data && typeof data.active === 'boolean') { - const s = ensure() + const s = ensure(['events']) s.events.push({ seq: event.seq, time: event.time, kind: 'mode', name: data.active ? 'plan.on' : 'plan.off' }) bumpDetailRev(s) } @@ -1280,7 +1332,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds } case 'compaction/summary': case 'compaction/prune': { - const s = ensure() + const s = ensure(['events']) bumpDetailRev(s) // Arm the shadow-price claim: the replacement that follows this // event synchronously shadows exactly these node seqs. diff --git a/src/host/timeline.ts b/src/host/timeline.ts index 4240440..a773a23 100644 --- a/src/host/timeline.ts +++ b/src/host/timeline.ts @@ -245,6 +245,7 @@ const timelineStateSchema = z.object({ lastModel: z.string().optional(), contextWindow: z.number().optional(), requests: z.array(requestRecordSchema), + turnRuns: z.number().int().nonnegative().optional(), events: z.array(contextEventSchema), archived: z.array(surfaceNodeSchema), cost: costUsageSchema.optional(), diff --git a/src/host/version.ts b/src/host/version.ts index f3cd9ce..75fb565 100644 --- a/src/host/version.ts +++ b/src/host/version.ts @@ -34,7 +34,7 @@ import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' -import { dirname, join, relative } from 'node:path' +import { dirname, isAbsolute, join, relative } from 'node:path' import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' @@ -142,11 +142,15 @@ function probeAnchor(resolve: Resolve, packageNames: readonly string[]): string /** * Whether `candidate` lies inside `root`'s tree. A `..`-prefixed relative path * escapes it; a sibling such as `dsh-context-extra` is reached through `..` too, - * so it never counts as a child. A pathological child literally named `..foo` - * would degrade to the home anchor, which is the safe direction. + * so it never counts as a child. On Windows, a cross-drive `relative()` returns + * the candidate's ABSOLUTE path (no `..` prefix) — a different drive is never + * inside this tree, so the absolute answer is rejected the same way. A + * pathological child literally named `..foo` would degrade to the home anchor, + * which is the safe direction. */ function isInside(root: string, candidate: string): boolean { - return !relative(root, candidate).startsWith('..') + const rel = relative(root, candidate) + return !rel.startsWith('..') && !isAbsolute(rel) } /** diff --git a/src/shared/fileOps.ts b/src/shared/fileOps.ts index e070aeb..2d23a02 100644 --- a/src/shared/fileOps.ts +++ b/src/shared/fileOps.ts @@ -60,6 +60,26 @@ export function kindOfCall(tool: string, args: Record | null): return kindOfTool(tool) } +/** + * Whether a settled call's arguments can yield an op at all: the file tools + * plus `str_replace_editor`, whose purpose follows its `command`. The public + * face of the gate {@link opsOfCall} short-circuits on — callers that decide + * whether to parse or to KEEP a call's raw arguments ask here instead of + * restating the tool list. + */ +export function opBearingTool(tool: string): boolean { + return tool === 'str_replace_editor' || kindOfTool(tool) !== null +} + +/** + * Whether a call's raw arguments must ride the fold state + * (TimelineState.callNames): every op-bearing tool, plus `run_code`, whose + * `description` labels the ops its nested dispatches book at flush time. + */ +export function rawArgsNeeded(tool: string): boolean { + return opBearingTool(tool) || tool === 'run_code' +} + /** * The operation's target path: the path-ish argument of read/write tools; * for searches the narrowing `path`, else the pattern itself (a pathless @@ -203,7 +223,10 @@ export function opsOfCall(input: { parent?: number program?: string }): FileOpRecord[] { - const args = parseCallArgs(input.argsRaw) + // A non-op-bearing tool never rows one — skip its arguments parse entirely + // (a call's arguments are its largest payload, and the fold may hand a large + // bash/pwsh call here). + const args = opBearingTool(input.tool) ? parseCallArgs(input.argsRaw) : null const kind = kindOfCall(input.tool, args) if (kind === null) return [] const stamp: FileOpRecord = { diff --git a/tests/host/fold-ops.spec.ts b/tests/host/fold-ops.spec.ts index 6b3990d..5025129 100644 --- a/tests/host/fold-ops.spec.ts +++ b/tests/host/fold-ops.spec.ts @@ -58,6 +58,23 @@ describe('the file-op log — call/result pairing', () => { assert.deepEqual(state.fileOps, []) }) + test('a non-file call saves no raw arguments; str_replace_editor keeps and kinds them', () => { + const def = timelineDef() + let state = def.apply(def.init(), toolCall(1, { callId: 'b1', name: 'bash', arguments: JSON.stringify({ command: 'ls' }) })) + state = def.apply(state, toolCall(2, { + callId: 's1', name: 'str_replace_editor', arguments: JSON.stringify({ command: 'view', path: 'a.ts', view_range: [1, 3] }), + })) + // The args serialization is gated on tools that can row an op: bash never + // does, str_replace_editor's op derives from the arguments. + assert.equal(state.callNames.b1?.name, 'bash') + assert.ok(!('argsRaw' in (state.callNames.b1 ?? {})), 'a non-file call saves no raw arguments') + assert.equal(state.callNames.s1?.argsRaw, JSON.stringify({ command: 'view', path: 'a.ts', view_range: [1, 3] })) + state = def.apply(state, toolResult(3, { callId: 'b1', content: [{ type: 'text', text: 'ok' }] })) + state = def.apply(state, toolResult(4, { callId: 's1', content: [{ type: 'text', text: 'ok' }] })) + // A `view` command reads; the op rows off the parsed arguments. + assert.deepEqual(state.fileOps.map(o => [o.kind, o.path, o.tool]), [['read', 'a.ts', 'str_replace_editor']]) + }) + test('a call without raw arguments pairs for the surface label but books no op', () => { const { state } = driveTimeline([ { type: 'tool/call', seq: 1, time: 1, data: { callId: 'c1', name: 'read' } } as unknown as TimelineEvent, @@ -157,6 +174,15 @@ describe('the file-op log — Code Mode (PTC) sub-dispatches', () => { ]) assert.deepEqual(state.fileOps.map(o => [o.path, o.parent ?? 0]), [['a.ts', 5], ['b.ts', 6]]) }) + + test('a str_replace_editor dispatch keeps its arguments and kinds by command', () => { + const { state } = driveTimeline([ + toolCall(1, { callId: 'rc1', name: 'run_code', arguments: '{}' }), + codeDispatch(2, { rootCallId: 'rc1', name: 'str_replace_editor', arguments: { command: 'view', path: 'a.ts', view_range: [1, 3] } }), + toolResult(3, { callId: 'rc1', content: [{ type: 'text', text: 'ok' }] }), + ]) + assert.deepEqual(state.fileOps.map(o => [o.kind, o.path, o.parent ?? 0]), [['read', 'a.ts', 3]]) + }) }) describe('the file-op log — retention, schema, and the served payload', () => { diff --git a/tests/host/fold-retention.spec.ts b/tests/host/fold-retention.spec.ts index 34af2c1..6a5c980 100644 --- a/tests/host/fold-retention.spec.ts +++ b/tests/host/fold-retention.spec.ts @@ -96,6 +96,36 @@ describe('trimState request/event bounds', () => { }) }) +describe('the incremental turnRuns ledger', () => { + test('counts turn runs exactly like a full scan, including a turn-less first record', () => { + const drive = driveTimeline([ + assistantMessage(1, {}), + assistantMessage(2, { turn: 1, step: 1 }), + assistantMessage(3, { turn: 1, step: 2 }), + assistantMessage(4, { turn: 2, step: 1 }), + assistantMessage(5, { turn: 2, step: 2 }), + ]) + // Scan semantics: the leading turn-less record forms no run; runs open at + // turn 1 (record 2) and turn 2 (record 4). + assert.equal(drive.state.turnRuns, 2) + assertStatesPlainJson(drive) + }) + + test('a restored row folded before the field existed recomputes its base once', () => { + const def = timelineDef() + let state = def.apply(def.init(), assistantMessage(1, { turn: 1, step: 1 })) + state = def.apply(state, assistantMessage(2, { turn: 1, step: 2 })) + // A cached row from an older build carries no `turnRuns` (additive-optional). + delete (state as { turnRuns?: unknown }).turnRuns + // A request push recomputes the base incrementally (1 retained run + the new turn). + const pushed = def.apply(state, assistantMessage(3, { turn: 2, step: 1 })) + assert.equal(pushed.turnRuns, 2) + // A non-request change recomputes in the trim pass instead. + const trimmed = def.apply(state, planMode(4, { active: true })) + assert.equal(trimmed.turnRuns, 1) + }) +}) + describe('trimState archive pruning', () => { test('removals at or before the oldest retained request are dropped, recording archiveFloor', () => { const drive = driveTimeline([ diff --git a/tests/host/fold-timing.spec.ts b/tests/host/fold-timing.spec.ts index adb85f8..46ebc94 100644 --- a/tests/host/fold-timing.spec.ts +++ b/tests/host/fold-timing.spec.ts @@ -218,9 +218,13 @@ describe('timing — tool call durations', () => { assert.deepEqual(state.timing?.tools, { bash: { calls: 2, ms: 6_000 } }) }) - test('an unpaired result carries no duration and no tally', () => { - const { state } = driveTimeline([toolResult(1, { callId: 'ghost', content: text('ok') })]) - assert.equal(state.timing, undefined) + test('an unpaired result carries no duration, no tally, and no `timing` slot at all', () => { + const drive = driveTimeline([toolResult(1, { callId: 'ghost', content: text('ok') })]) + assert.equal(drive.state.timing, undefined) + // ABSENT, never materialized as an `undefined`-valued property: the fold + // priced no call, so the slot stays untouched — one such property fails + // every projection-cache write for the session (the plain-JSON contract). + assert.equal(Object.hasOwn(drive.state, 'timing'), false) }) test('result before call (out-of-order log) prices nothing', () => { diff --git a/tests/host/helpers/projection.ts b/tests/host/helpers/projection.ts index 64c93bb..f791d19 100644 --- a/tests/host/helpers/projection.ts +++ b/tests/host/helpers/projection.ts @@ -1,7 +1,7 @@ // Fold drivers for the host specs: run event envelopes through the REAL // projection units the plugin registers (no harness plumbing — the units are -// pure init/apply/view), with the framework's own serializer pinned on every -// intermediate state. +// pure init/apply/view), pinning the state's plain-JSON rule on every +// intermediate state (see assertNoAbsentMembers). import assert from 'node:assert/strict' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -102,6 +102,27 @@ export function assertPlainJson(state: T): T { return copy as T } +/** + * The one plain-JSON rule a DURABLE log can break (see TimelineState): no own + * `undefined`-valued property may enter a folded state — one such property + * fails EVERY projection-cache write for that session from then on. The rest + * of the lossless contract cannot arrive through a real log (the durable + * vocabulary is JSON: no non-finite numbers, holes, or cycles, and dsh + * validates every payload before appending it), so `assertPlainJson` stays the + * explicit whole-contract check for the specs that mean to assert it. + * + * Applied to every state the fold PRODUCES, not only the last one: a mid-log + * violation sticks even when a later event overwrites the offending key. + */ +function assertNoAbsentMembers(value: unknown, path = '$', seen = new Set()): void { + if (value === null || typeof value !== 'object' || seen.has(value)) return + seen.add(value) + for (const [key, member] of Object.entries(value)) { + if (member === undefined) assert.fail(`${path}.${key} must stay ABSENT, never carry undefined`) + assertNoAbsentMembers(member, `${path}.${key}`, seen) + } +} + export interface TimelineDrive { def: TimelineDefLike state: TimelineState @@ -114,9 +135,11 @@ export interface TimelineDrive { export function driveTimeline(events: TimelineEvent[], config?: Config): TimelineDrive { const def = timelineDef(config) let state = def.init() + assertNoAbsentMembers(state) const states = [state] for (const ev of events) { state = def.apply(state, ev) + assertNoAbsentMembers(state) states.push(state) } return { def, state, states, view: def.wire.view(state) }