perf(host): cut the timeline fold's per-event cost and fix the Windows cross-drive path check
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>
This commit is contained in:
1 parent
9932d84d32
commit
22eb2ed2f4
8 files changed
+214
-51
No files matched your search
+93
-41
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+8
-4
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+24
-1
@@ -60,6 +60,26 @@ export function kindOfCall(tool: string, args: Record<string, unknown> | 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 = {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<T>(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<object>()): 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) }
|
||||
|
||||
Reference in new issue
Block a user