diff --git a/src/client/assemble.ts b/src/client/assemble.ts index 25bc464..0e1ae13 100644 --- a/src/client/assemble.ts +++ b/src/client/assemble.ts @@ -15,13 +15,19 @@ * retention), the result carries the flags the UI turns into notices. */ -import type { ContextHeaders, ContextTimeline, HeaderRecord, SurfaceNode } from '../shared/types' +import type { ContextHeaders, ContextTimeline, HeaderRecord, SurfaceNode, SystemPromptNode } from '../shared/types' export interface Assembled { /** True when browsing the live surface (the next request's context). */ live: boolean /** The header epoch in force (null = headers projection absent or none yet). */ header: HeaderRecord | null + /** + * The system prompt in force at the shown step (null = none), resolved from + * the timeline's live `systems` nodes; on rows folded before that field + * existed the header epoch's own envelope figure stands in. + */ + system: SystemPromptNode | null /** The assembled surface messages, in seq order. */ nodes: SurfaceNode[] /** Live nodes outside the served window that are also part of the context. */ @@ -40,6 +46,33 @@ export function headerAt(headers: ContextHeaders | null, seq: number | null): He return null } +/** + * The system prompt in force at `seq` (null = none) — the LAST live system + * node at or before it carrying tokens, which is exactly the host fold's + * "last nonempty surviving system" rule and therefore agrees with the + * per-step `system` figure the fold recorded. Rows folded before `systems` + * existed carry none; the header epoch's envelope figure stands in for them + * (the pre-V3 wire shape). + */ +export function systemAt( + data: ContextTimeline, + header: HeaderRecord | null, + seq: number | null, +): SystemPromptNode | null { + const systems = data.systems + if (systems !== undefined && systems.length > 0) { + for (let i = systems.length - 1; i >= 0; i--) { + const node = systems[i] + if (node.tokens <= 0) continue + if (seq === null || node.seq < seq) return node + } + return null + } + return header !== null && header.systemTokens !== undefined + ? { seq: header.seq, time: header.time, tokens: header.systemTokens } + : null +} + export function assemble(data: ContextTimeline, headers: ContextHeaders | null, seq: number | null): Assembled { const live = seq === null let nodes: SurfaceNode[] @@ -71,5 +104,6 @@ export function assemble(data: ContextTimeline, headers: ContextHeaders | null, && data.archiveFloor !== undefined && seq < data.archiveFloor - return { live, header: headerAt(headers, seq), nodes, missingLive, approximate } + const header = headerAt(headers, seq) + return { live, header, system: systemAt(data, header, seq), nodes, missingLive, approximate } } diff --git a/src/client/components/browser.tsx b/src/client/components/browser.tsx index e390c92..4c8f32a 100644 --- a/src/client/components/browser.tsx +++ b/src/client/components/browser.tsx @@ -556,7 +556,7 @@ function byCatOf(asm: Assembled): Partial> { } function countOf(asm: Assembled, byCat: Partial>, c: string): number { - if (c === 'system') return asm.header !== null && asm.header.systemTokens !== undefined ? 1 : 0 + if (c === 'system') return asm.system !== null ? 1 : 0 if (c === 'tools') return asm.header !== null ? asm.header.tools.length : 0 return byCat[c as Category]?.length ?? 0 } @@ -683,15 +683,19 @@ export function makeContextBrowser( : null const view = assemble(data, headers, seq) - // Header epoch CONTENT (system prompt text, tool descriptions/schemas): - // the projection carries metadata only, so the selected step's epoch is - // fetched on demand — one seq-anchored history read when its system or - // tools section first opens. The same fetch-on-miss machine as the row - // read above (content caches per epoch — history is immutable). + // Header/tool epoch CONTENT (tool descriptions/schemas) and the system + // prompt TEXT: the projections carry metadata only, so the selected step's + // sources are fetched on demand — one seq-anchored history read per open + // section. The system prompt rides the timeline's own `systems` nodes (a + // V3 `system/message`, or the V0/V2 epoch that carried `header.system`), + // the tools the header epoch; both map through the same fetcher. Content + // caches per seq — history is immutable. const fetchHeader = props.fetchHeader const headerSeq = view.header !== null ? view.header.seq : null + const systemSeq = view.system !== null ? view.system.seq : null + const contentSeq = openCat === 'system' ? systemSeq : openCat === 'tools' ? headerSeq : null const epoch = useFetchOnMiss( - headerSeq !== null && (openCat === 'system' || openCat === 'tools') ? headerSeq : null, + contentSeq, fetchHeader, 'dsh-context: header content fetch failed', ) @@ -767,7 +771,7 @@ export function makeContextBrowser( // one click lands on the content directly (the lone prompt / tool schema / // surface node). const singleKeyOf = (c: string): string | null => { - if (c === 'system') return view.header?.systemTokens !== undefined ? 'sys' : null + if (c === 'system') return view.system !== null ? 'sys' : null if (c === 'tools') { const tools = view.header?.tools return tools !== undefined && tools.length === 1 ? 'tool:' + tools[0].name : null @@ -823,9 +827,16 @@ export function makeContextBrowser( const catBody = (c: string): ReactNode => { if (c === 'system') { - if (view.header === null) return
{t(headers === null ? 'browser.noHeader' : 'browser.noEpoch')}
- const content = headerContent.get(view.header.seq) - // Metadata-only until the epoch's content resolves (fetched when the + // No prompt in force at this step. The category only opens without one + // when there is no header epoch at all (the openable guard above), so + // the note names the missing PROJECTION — no headers service versus + // headers that carried no epoch yet. + const sys = view.system + if (sys === null) { + return
{t(headers === null ? 'browser.noHeader' : 'browser.noEpoch')}
+ } + const content = headerContent.get(sys.seq) + // Metadata-only until the prompt's event resolves (fetched when the // section opens; the note names the state). if (content === undefined) return
{headerNote}
if (content.system === undefined) return
{t('browser.noSystem')}
diff --git a/src/client/dna.ts b/src/client/dna.ts index 364921d..a3fcc22 100644 --- a/src/client/dna.ts +++ b/src/client/dna.ts @@ -7,15 +7,16 @@ * The order is PROMPT order, which is also the order items joined the * context: the system prompt and tool schemas prefix every request, so they * lead the strip; the message flow follows in seq (chronological) order. - * The header epoch's own seq is NOT usable as an ordering key: dsh appends - * `request/header` at dispatch time of the first request that uses a header - * (agent.ts — 'initial'/'resume'/'change'/'series'), a bookkeeping snapshot - * that lands AFTER every message already in context. A mid-session refresh - * would otherwise park the system/tools bands in the middle of the strip, - * behind the very tool results those schemas describe. A refreshed epoch - * REPLACES the prefix in place — the strip reflects that by keeping the - * current epoch's bands at the front (the band's tooltip carries the - * epoch's time). + * The system band rides the timeline's own `systems` nodes (the prompt's real + * surface positions), the tool bands their header epoch. The header epoch's + * own seq is NOT usable as an ordering key: dsh appends `request/header` at + * dispatch time of the first request that uses a header (agent.ts — + * 'initial'/'resume'/'change'/'series'), a bookkeeping snapshot that lands + * AFTER every message already in context. A mid-session refresh would + * otherwise park the tools bands in the middle of the strip, behind the very + * tool results those schemas describe. A refreshed epoch REPLACES the prefix + * in place — the strip reflects that by keeping the current epoch's bands at + * the front (the band's tooltip carries the epoch's time). */ import type { Category, SurfaceNode } from '../shared/types' @@ -32,10 +33,10 @@ export type DnaItem = export function dnaOf(view: Assembled): DnaItem[] { const items: DnaItem[] = [] + if (view.system !== null) { + items.push({ key: 'sys', cat: 'system', tokens: view.system.tokens, time: view.system.time }) + } if (view.header !== null) { - if (view.header.systemTokens !== undefined) { - items.push({ key: 'sys', cat: 'system', tokens: view.header.systemTokens, time: view.header.time }) - } for (const tool of view.header.tools) { items.push({ key: 'tool:' + tool.name, cat: 'tools', tokens: tool.tokens }) } diff --git a/src/client/historyPage.ts b/src/client/historyPage.ts index 07c3474..9a2621f 100644 --- a/src/client/historyPage.ts +++ b/src/client/historyPage.ts @@ -55,6 +55,26 @@ function textOf(blocks: unknown): string | null { return out.trim() === '' ? null : out } +/** + * The system prompt's exact rendered text — every text block joined with NO + * normalization: a whitespace-only prompt is still the prompt the model + * received (the host prices it as text), so it must render rather than read + * as absent. Null when the content carries no text block at all. + */ +function systemTextOf(blocks: unknown): string | null { + if (!Array.isArray(blocks)) return null + let out = '' + let seen = false + for (const b of blocks) { + const text = b !== null && typeof b === 'object' ? (b as { type?: unknown; text?: unknown }).text : undefined + if (typeof text === 'string') { + out += text + seen = true + } + } + return seen ? out : null +} + /** * One assistant content block → the snapshot block vocabulary the browser * already renders (`kind`: text/reasoning/image/tool-call); unmappable @@ -309,13 +329,23 @@ export function makeContentFetcher(sessionId: string): ContentFetcher | undefine } /** - * Map one raw `request/header` event into the epoch content the browser - * renders — the full system prompt text plus each tool's producer - * description and raw schema, mirroring the host fold's per-entry guards - * (a null or primitive tool entry degrades to an unnamed row instead of - * throwing the read). Null when the envelope carries no usable header. + * Map one raw durable event into the epoch content the browser renders. A + * `request/header` yields the full system prompt text (V0/V2 envelope) plus + * each tool's producer description and raw schema; a V3 `system/message` + * yields the prompt text alone (its tools live in the request header). Both + * mirror the host fold's per-entry guards — a null or primitive tool entry + * degrades to an unnamed row instead of throwing the read. Null when the + * envelope carries neither. */ -function headerContentOf(data: Record): HeaderEpochContent | null { +function headerContentOf(event: { type: string; data: Record }): HeaderEpochContent | null { + const { type, data } = event + if (type === 'system/message') { + const message = data.message !== null && typeof data.message === 'object' + ? data.message as Record + : null + const system = systemTextOf(message?.content) + return system === null ? null : { system, tools: [] } + } const rawHeader = data.header !== null && typeof data.header === 'object' ? data.header as Record : null @@ -339,15 +369,15 @@ function headerContentOf(data: Record): HeaderEpochContent | nu } /** - * The on-demand CONTENT fetch for `contextHeaders` epochs — the lazy - * counterpart of the node fetcher above. One seq-anchored history read off - * the epoch's `seq` returns the page holding that epoch's `request/header` - * event (non-message events ride the page verbatim); the raw header is - * mapped client-side into the renderable content. Epochs cache per session - * (history is immutable), and OLDER epochs sharing the page cache for free — - * stepping back through epochs walks the same pages. Undefined when no - * history face exists — the browser keeps a metadata-only degradation - * instead. + * The on-demand CONTENT fetch for the browser's System and Tools sections — + * the lazy counterpart of the node fetcher above. One seq-anchored history + * read off the requested seq returns the page holding that event (non-message + * events ride the page verbatim); a `request/header` maps to the epoch's + * tools (and its V0/V2 system text), a `system/message` to a V3 prompt's + * text. Landed content caches per seq (history is immutable), and OLDER + * epochs sharing the page cache for free — stepping back through epochs walks + * the same pages. Undefined when no history face exists — the browser keeps a + * metadata-only degradation instead. */ export function makeHeaderFetcher(sessionId: string): HeaderFetcher | undefined { const read = pageReaderOf(sessionId) @@ -362,11 +392,11 @@ export function makeHeaderFetcher(sessionId: string): HeaderFetcher | undefined let picked: HeaderEpochContent | null = null for (const entry of rows) { const ev = eventOf(entry) - if (ev === null || ev.type !== 'request/header') continue - const content = headerContentOf(ev.data) + if (ev === null || (ev.type !== 'request/header' && ev.type !== 'system/message')) continue + const content = headerContentOf(ev) if (content === null) continue - // The page's exclusive bound is seq + 1, so every header event on it - // is the picked epoch or an OLDER one — cache them all. + // The page's exclusive bound is seq + 1, so every content event on it + // is the picked one or an OLDER one — cache them all. cache.set(ev.seq, content) if (ev.seq === seq) picked = content } diff --git a/src/client/services.ts b/src/client/services.ts index 9e9528d..cfdf930 100644 --- a/src/client/services.ts +++ b/src/client/services.ts @@ -16,7 +16,7 @@ import type { Context } from '@deepseek-ai/cordis' import { estimateSystemTokens } from '../shared/estimate' -import type { ContextBreakdown, ContextHeaders, ContextPressure, ContextTimeline, HeaderEpochContent, TimingTotals, TokenUsage, ToolTimingTotals } from '../shared/types' +import type { ContextBreakdown, ContextHeaders, ContextPressure, ContextTimeline, HeaderEpochContent, SystemPromptNode, TimingTotals, TokenUsage, ToolTimingTotals } from '../shared/types' export interface LocaleService { register(ns: string, dicts: Record>): () => void @@ -268,6 +268,7 @@ export function timelineOf(value: unknown): ContextTimeline | null { && recordsOnly(data.events) && recordsOnly(data.nodes) && recordsOnly(data.archive) + && (data.systems === undefined || recordsOnly(data.systems)) && timingFastOk(data.timing)) { // Well-formed: pass the delivered value through untouched (cheap, and reference-stable so plain re-renders stay zero-copy). return data as unknown as ContextTimeline @@ -310,6 +311,7 @@ export function timelineOf(value: unknown): ContextTimeline | null { ...(typeof data.detailRev === 'number' && Number.isFinite(data.detailRev) ? { detailRev: data.detailRev } : {}), ...(cost !== undefined ? { cost } : {}), ...(timing !== null ? { timing } : {}), + ...(data.systems !== undefined ? { systems: systemsOf(data.systems) } : {}), ...(typeof data.surfaceFloor === 'number' ? { surfaceFloor: data.surfaceFloor } : {}), ...(typeof data.archiveFloor === 'number' ? { archiveFloor: data.archiveFloor } : {}), ...(data.fileOps !== undefined ? { fileOps: objectsOf(data.fileOps) } : {}), @@ -318,6 +320,25 @@ export function timelineOf(value: unknown): ContextTimeline | null { return safe } +/** + * The live system-prompt nodes, re-proved per entry and sorted by seq: an + * entry missing a finite seq/time/tokens drops out (the browser then falls + * back to the header epoch), so a hostile collection can never produce a NaN + * prompt figure or an unfetchable seq. Absent or empty stays absent. + */ +function systemsOf(value: unknown): ContextTimeline['systems'] { + const list = objectsOf>(value) + const out: SystemPromptNode[] = [] + for (const entry of list) { + const { seq, time, tokens } = entry + if (typeof seq !== 'number' || !Number.isFinite(seq)) continue + if (typeof time !== 'number' || !Number.isFinite(time)) continue + if (typeof tokens !== 'number' || !Number.isFinite(tokens)) continue + out.push({ seq, time, tokens }) + } + return out.sort((a, b) => a.seq - b.seq) +} + /** * The split head's count figures, re-proved field by field: a present-but- * partial record zeroes its unreadable fields (the stats board's no-NaN diff --git a/src/host/fold.ts b/src/host/fold.ts index 7924dbf..e91bfb6 100644 --- a/src/host/fold.ts +++ b/src/host/fold.ts @@ -19,8 +19,8 @@ * the request/event records are the raw material of `buildTimelineView`. */ -import type { Category, ContextEventRecord, ContextTimelineDetail, CostFamilyUsage, FileOpRecord, RequestRecord, SessionCostUsage, Snapshot, SurfaceNode, TimingTotals, ToolTimingTotals } from '../shared/types' -import { estimateSystemTokens } from '../shared/estimate' +import type { Category, ContextEventRecord, ContextTimelineDetail, CostFamilyUsage, FileOpRecord, RequestRecord, SessionCostUsage, Snapshot, SurfaceNode, SystemPromptNode, TimingTotals, ToolTimingTotals } from '../shared/types' +import { estimateSystemContent, estimateSystemTokens } from '../shared/estimate' import type { FoldBounds } from './config' import { estimateMessage, @@ -33,6 +33,7 @@ import { } from './pricing' import type { ContentBlock, MessageSource } from './pricing' import { deriveEventMessage } from '@deepseek-ai/dsh-session' +import { firstTokenTimeOfStream, isTokenChunk, replaceRangeOf } from './logShapes' import { opsOfCall, parseCallArgs } from '../shared/fileOps' /** @@ -67,6 +68,27 @@ export interface TimelineState { surface: SurfaceNode[] sums: Record systemTokens: number + /** + * The live system-prompt nodes, oldest first — a V3 log's `system/message` + * surface nodes, or the single entry a V0/V2 `request/header.header.system` + * envelope defines. `systemTokens` is the LAST entry with tokens > 0 (the + * harness's own "last nonempty surviving system" rule), so an empty dormant + * node keeps its position without clearing the prompt. Bounded by + * SYSTEM_NODES_MAX. ABSENT on rows folded before this field existed — the + * wire then serves no `systems` and the client falls back to the header + * epoch's own envelope figure. + */ + systems?: SystemPromptNode[] + /** + * Whether `systems` was built from the V0/V2 request ENVELOPE + * (`header.system`) rather than from V3 `system/message` events. Only then + * may a system-less header CLEAR the list: its canonical V0 meaning is + * "this request has no system prompt", while a V3 header never carries one + * (its prompt lives in the message history). Absent = log-sourced, and + * never materialized as an `undefined`-valued property (plain-JSON + * precondition — see the note above `model`). + */ + systemsFromHeader?: true toolsTokens: number /** * The projection-cache precondition is plain JSON: a property whose value @@ -283,6 +305,29 @@ function bumpDetailRev(st: TimelineState): void { st.detailRev = (st.detailRev ?? 0) + 1 } +/** + * Bound on the live system-prompt nodes (TimelineState.systems). The + * effective figure is the LAST nonempty node, so dropping the oldest can only + * under-report a pathological log whose newest SYSTEM_NODES_MAX nodes are all + * empty while an older one still carried text. + */ +const SYSTEM_NODES_MAX = 8 + +/** The effective system-prompt price: the last nonempty node, else 0 (the harness's own rule). */ +function systemTokensOf(systems: readonly SystemPromptNode[]): number { + for (let i = systems.length - 1; i >= 0; i--) { + if (systems[i].tokens > 0) return systems[i].tokens + } + return 0 +} + +/** Append one system-prompt node, bounding the list (see SYSTEM_NODES_MAX). */ +function pushSystem(st: TimelineState, node: SystemPromptNode): void { + const systems = [...(st.systems ?? []), node] + st.systems = systems.length > SYSTEM_NODES_MAX ? systems.slice(-SYSTEM_NODES_MAX) : systems + st.systemTokens = systemTokensOf(st.systems) +} + /** * Bound on the buffered nested Code-Mode ops (TimelineState.pendingCodeOps) * — a hostile log that dispatches without settling the parent run_code @@ -328,6 +373,30 @@ function archiveRemoved(st: TimelineState, removed: SurfaceNode[], goneSeq: numb for (const n of removed) st.archived.push({ ...n, gone: goneSeq }) } +/** + * Remove every live surface node whose seq the replacement claims, keeping the + * per-category sums equal to the surviving nodes and archiving the removals. + * Removal follows the SEQ list, not the declared range: pruned replacement + * nodes keep their own seqs beyond the range end, so a range-based removal + * would leave them behind and overcount. Returns the removed nodes. + */ +function removeSurfaceSeqs(st: TimelineState, claimed: ReadonlySet, goneSeq: number): SurfaceNode[] { + if (claimed.size === 0) return [] + const kept: SurfaceNode[] = [] + const removed: SurfaceNode[] = [] + for (const n of st.surface) { + if (claimed.has(n.seq)) { + st.sums[n.cat] -= n.tokens + removed.push(n) + } else { + kept.push(n) + } + } + archiveRemoved(st, removed, goneSeq) + st.surface = kept + return removed +} + interface SurfaceEventLike { seq: number time: number @@ -340,6 +409,17 @@ interface MessageLike { error?: boolean } +/** + * The message nested under an event payload's `message` field + * (`system/message`, `assistant/message`, `tool/result`) — read structurally + * rather than through `deriveEventMessage`, whose 0.1.2-rc.1 generation knows + * nothing of the V3 `system/message` variant. A malformed payload reads null. + */ +function messageOf(data: Record | undefined): MessageLike | null { + const message = data?.message + return message !== null && typeof message === 'object' ? message : null +} + /** * The first full text block, recursing through nested content blocks (a tool * result wraps its text in a `tool-result` block). Unlike `firstText` this @@ -464,23 +544,15 @@ function applySurface( delete st.pendingShadowedSeqs delete st.pendingShadowEventSeq - const op = ev.surfaceOp as { op?: string; start?: number; end?: number } | null | undefined - if (op !== null && typeof op === 'object' && op.op === 'replace') { + const op = replaceRangeOf(ev.surfaceOp) + if (op !== null) { if (Array.isArray(shadowedSeqs) && shadowedSeqs.length > 0) { // The producer's shadow price covers exactly these node seqs, which can // include replacement nodes BEYOND the declared range end (their own // seqs postdate the range). Removing by seqs keeps our per-category // bookkeeping equal to the producer's total — a range-based removal // would leave those nodes behind and overcount. - const shadowed = new Set(shadowedSeqs) - const kept: SurfaceNode[] = [] - const removed: SurfaceNode[] = [] - for (const n of st.surface) { - if (shadowed.has(n.seq)) { st.sums[n.cat] -= n.tokens; removed.push(n) } - else kept.push(n) - } - archiveRemoved(st, removed, ev.seq) - st.surface = kept + const removed = removeSurfaceSeqs(st, new Set(shadowedSeqs), ev.seq) st.sums[cat] += node.tokens st.surface.push(node) // Rewrite the metering event's row from its gross shadow price to the @@ -494,6 +566,12 @@ function applySurface( } return node } + // No shadow claim: the replacement names its span directly, read off BOTH + // endpoint spellings (logShapes.replaceRangeOf) and spliced IN PLACE — the + // harness's own surface semantics (the replacing node takes the span's + // position). BOTH endpoints must name live nodes, exactly as the harness's + // registry validates; a malformed span degrades to an append, which keeps + // the nodes rather than silently dropping context. let si = -1 let ei = -1 for (let i = 0; i < st.surface.length; i++) { @@ -623,25 +701,6 @@ function durOf(from: number, to: number): number { return Math.max(0, to - from) } -/** - * Whether a stream chunk carries a non-empty token delta — the first-token - * marker the TTFT fold waits for (the same rule as the harness's own - * session-stats fold). Shape-guarded: a malformed chunk is just not a token. - */ -function isTokenDelta(chunk: unknown): boolean { - if (chunk === null || typeof chunk !== 'object') return false - const c = chunk as { type?: unknown; text?: unknown; argumentsDelta?: unknown; name?: unknown } - switch (c.type) { - case 'text-delta': - case 'reasoning-delta': - return typeof c.text === 'string' && c.text !== '' - case 'tool-call-delta': - return (typeof c.argumentsDelta === 'string' && c.argumentsDelta !== '') || c.name !== undefined - default: - return false - } -} - /** * The fold's private timing accumulator: created on first use, and CLONED on * every later ensure() (see `applyTimeline`) — the object left in the @@ -731,7 +790,23 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds const s = ensure() // Tools TOTAL = dsh's whole-array price (one JSON string of every schema). s.toolsTokens = estimateToolsTotal(tools) - s.systemTokens = estimateSystemTokens(header.system) + // The V0/V2 system prompt rides this ENVELOPE; V3 rejects it outright + // (surface.ts: "must omit header.system; use system/message") and + // carries the prompt as a surface node instead. A present string is + // the envelope's own prompt for every request in its series; an + // absent one means "this request has no system prompt" ONLY when the + // list was envelope-sourced — otherwise the header is a V3 snapshot + // and the log's system nodes stay untouched. + const systemText = header.system + if (typeof systemText === 'string' && systemText !== '') { + s.systems = [{ seq: event.seq, time: event.time, tokens: estimateSystemTokens(systemText) }] + s.systemsFromHeader = true + s.systemTokens = systemTokensOf(s.systems) + } else if (s.systemsFromHeader === true) { + s.systems = [] + delete s.systemsFromHeader + s.systemTokens = 0 + } // 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 `buildRequest`). @@ -753,6 +828,34 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds if (s.model) s.lastModel = s.model break } + case 'system/message': { + // The V3 system prompt: a SURFACE node (position 0 of the harness's + // ordered surface) that the plugin tracks outside its message + // 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() + // 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 + delete s.pendingShadowEventSeq + const op = replaceRangeOf(event.surfaceOp) + if (op !== null) { + const systems = s.systems ?? [] + s.systems = systems.filter(n => n.seq < op.start || n.seq > op.end) + // Defensive: a replacement claiming ordinary surface nodes (never + // produced by dsh's system-prompt projection) removes them too, so + // the surface and its sums stay consistent with the claim. + const claimed = new Set() + for (const n of s.surface) { + if (n.seq >= op.start && n.seq <= op.end) claimed.add(n.seq) + } + if (removeSurfaceSeqs(s, claimed, event.seq).length > 0) bumpDetailRev(s) + } + delete s.systemsFromHeader + pushSystem(s, { seq: event.seq, time: event.time, tokens: estimateSystemContent(messageOf(data)?.content) }) + break + } case 'request/context': { const s = ensure() // Route/capacity metadata: request/context is logged only when the route or capacity changes (after request/header), so it updates @@ -775,13 +878,16 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds } break } - case 'tool/code-dispatch': { - // A nested Code-Mode call settling inside a run_code program: one - // settled sub-dispatch books its file ops exactly like a top-level + case 'tool/code-dispatch': + case 'tool/ptc-dispatch': { + // A nested PTC (Code Mode) call settling inside a run_code program: + // one settled sub-dispatch books its file ops exactly like a top-level // call — minus meta (the dispatch event carries none, so read windows // and per-file search attribution degrade to the argument-only // forms). The ops buffer under the top run_code call id and flush // when its result folds (their locate target is that result's row). + // BOTH vocabulary generations land here: `tool/code-dispatch` on + // V0/V2 logs, `tool/ptc-dispatch` on V3 (the rename keeps the payload). const rootCallId = data?.rootCallId const name = data?.name if (typeof rootCallId === 'string' && typeof name === 'string') { @@ -800,17 +906,32 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds break } case 'assistant/chunk': { - // The token flood: every stream chunk is one event, so this case stays - // cheap and mostly reference-stable — only the open step's FIRST token - // delta stamps the slot (later deltas and steps without a slot return - // the same state). A malformed chunk just is not a token. + // V0 stream events: the token flood, one event per chunk, so this case + // stays cheap and mostly reference-stable — only the open step's FIRST + // token delta stamps the slot (later deltas and steps without a slot + // return the same state). V2+ logs carry no such events; their timed + // stream rides `assistant/message` / `assistant/attempt` (see below). const start = state.stepStart if (start === undefined || start.firstToken !== undefined) return state - if (!isTokenDelta(data?.chunk)) return state + if (!isTokenChunk(data?.chunk)) return state const s = ensure() s.stepStart = { time: start.time, firstToken: event.time } break } + case 'assistant/attempt': { + // V2+: one model attempt that committed no surface message. Its embedded + // stream still carries the attempt's first token, which the harness's own + // sessionStats fold stamps on the open step the same way — an in-step + // retry therefore keeps its real TTFT instead of falling into the card's + // residue. + const start = state.stepStart + if (start === undefined || start.firstToken !== undefined) return state + const first = firstTokenTimeOfStream(data?.stream) + if (first === undefined) return state + const s = ensure() + s.stepStart = { time: start.time, firstToken: first } + break + } case 'step/start': { // Arm the single pending-step slot (see TimelineState.stepStart): the // following assistant/chunk stamps the first token on it, and the @@ -983,16 +1104,22 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds } s.requests.push(record) // Timing: one completed model call; its wait/generation split prices - // off the slot's first-token stamp when the stream carried one (a - // chunk-less call — legacy log, aborted step — stays unattributed and - // lands in the card's residue). The pending slot stays armed — - // the step's tool calls and `step/end` still follow. + // off the slot's first-token stamp. That stamp comes from a V0 + // `assistant/chunk` delta or, when the log carries none, from the + // message's own EMBEDDED stream (V2+ settlements) — the same fallback + // the harness's sessionStats fold applies. A call whose stream carried + // no token (legacy log, aborted step) stays unattributed and lands in + // the card's residue. The pending slot stays armed — the step's tool + // calls and `step/end` still follow. const timing = ensureTiming(s) timing.calls += 1 const stepStart = state.stepStart - if (stepStart !== undefined && stepStart.firstToken !== undefined) { - timing.ttftMs += durOf(stepStart.time, stepStart.firstToken) - timing.genMs += durOf(stepStart.firstToken, event.time) + if (stepStart !== undefined) { + const firstToken = stepStart.firstToken ?? firstTokenTimeOfStream(data?.stream) + if (firstToken !== undefined) { + timing.ttftMs += durOf(stepStart.time, firstToken) + timing.genMs += durOf(firstToken, event.time) + } } // `deriveEventMessage` returns `data.message` for assistant/message, or // null when the content array is empty (usage-only events project to no @@ -1114,6 +1241,14 @@ function headFieldsOf(state: TimelineState): Snapshot { for (const k in state.timing.tools) tools[k] = { ...state.timing.tools[k] } result.timing = { ...state.timing, tools } } + // The live system-prompt nodes ride the wire as COPIES: the browser resolves + // the prompt in force at any step from them and fetches its TEXT on demand + // from `seq` — a `system/message` event on V3, the epoch's `request/header` + // on V0/V2. Absent when the log carried none, which is exactly the legacy + // shape older clients already degrade on (they fall back to the epoch). + if (state.systems !== undefined && state.systems.length > 0) { + result.systems = state.systems.map(n => ({ ...n })) + } return result } diff --git a/src/host/logShapes.ts b/src/host/logShapes.ts new file mode 100644 index 0000000..db9ddcd --- /dev/null +++ b/src/host/logShapes.ts @@ -0,0 +1,109 @@ +/** + * Shape-driven readers over the durable session-event vocabulary — the ONE + * place the plugin reconciles the two supported log generations: + * + * - V0 (dsh 0.1.2-rc.1): `request/header.header.system`, `assistant/chunk` + * stream events, `SurfaceOp { start, end }`, `tool/code-dispatch`. + * - V3 (dsh 0.1.5-alpha.1+): `system/message` surface nodes, + * `assistant/message.data.stream` / `assistant/attempt.data.stream`, + * `SurfaceOp { startSeq, endSeq }`, `tool/ptc-dispatch`. + * + * The fold reads SHAPES, never a detected harness version: a session log is + * written by exactly one generation, the two spellings are mutually + * exclusive within it, and a deployment's version probe can be wrong (a + * healed profile mirror may name a different release than the running + * harness). Every reader is total over untrusted input — a malformed record + * yields "nothing here", never a throw (the projection registry drives the + * fold without an error boundary; one throw stalls the unit's push feed and + * the browser waits on "loading" forever). + * + * @module dsh-context/host/log-shapes + */ + +/** + * Whether one raw stream chunk carries a token delta — the first-token marker + * both generations share. Mirrors dsh-llm's `isTokenDelta` (a non-empty text + * or reasoning fragment, or any Tool-call delta carrying arguments or a name); + * a malformed chunk is simply not a token. + */ +export function isTokenChunk(chunk: unknown): boolean { + if (chunk === null || typeof chunk !== 'object') return false + const c = chunk as { type?: unknown; text?: unknown; argumentsDelta?: unknown; name?: unknown } + switch (c.type) { + case 'text-delta': + case 'reasoning-delta': + return typeof c.text === 'string' && c.text !== '' + case 'tool-call-delta': + return (typeof c.argumentsDelta === 'string' && c.argumentsDelta !== '') || c.name !== undefined + default: + return false + } +} + +/** + * The first token's instant inside one PACKED delta run (`text-chunks` / + * `reasoning-chunks` / `tool-call-chunks`): the run's base time plus the + * accumulated inter-member deltas, taken at the first qualifying member — + * a name-bearing Tool-call run starts at its first member. Mirrors dsh-llm's + * `runFirstTokenTime`; a non-finite base or delta yields undefined rather + * than a NaN instant. + */ +function runFirstTokenTime(record: Record): number | undefined { + const time0 = record.time0 + if (typeof time0 !== 'number' || !Number.isFinite(time0)) return undefined + if (record.type === 'tool-call-chunks' && record.name !== undefined) return time0 + const fragments = record.type === 'tool-call-chunks' ? record.args : record.texts + if (!Array.isArray(fragments)) return undefined + const dt: readonly unknown[] = Array.isArray(record.dt) ? record.dt as unknown[] : [] + let time = time0 + for (const [index, fragment] of fragments.entries()) { + if (index > 0) { + const step = dt[index - 1] + if (typeof step !== 'number' || !Number.isFinite(step)) return undefined + time += step + } + if (typeof fragment === 'string' && fragment !== '') return time + } + return undefined +} + +/** + * The first token's instant inside an embedded assistant stream + * (`assistant/message.data.stream`, `assistant/attempt.data.stream` — the V2+ + * settlement that replaced the V0 `assistant/chunk` events), or undefined + * when the stream carries no token. Mirrors dsh-llm's + * `assistantStreamFirstTokenTime` over the compact record union. + */ +export function firstTokenTimeOfStream(stream: unknown): number | undefined { + if (!Array.isArray(stream)) return undefined + for (const record of stream) { + if (record === null || typeof record !== 'object') continue + const r = record as Record + if (r.type === 'chunk') { + const time = r.time + if (typeof time === 'number' && Number.isFinite(time) && isTokenChunk(r.chunk)) return time + continue + } + const time = runFirstTokenTime(r) + if (time !== undefined) return time + } + return undefined +} + +/** + * The inclusive surface range a replacement op covers, or null for `append` + * and for any unrecognized/hostile op (which the fold treats as an append). + * Reads BOTH endpoint spellings: V3's `startSeq`/`endSeq` first, then V0's + * `start`/`end` — each accepted only as a finite number, so a hostile op + * with one good and one malformed endpoint degrades to append. + */ +export function replaceRangeOf(surfaceOp: unknown): { start: number; end: number } | null { + if (surfaceOp === null || typeof surfaceOp !== 'object') return null + const op = surfaceOp as { op?: unknown; start?: unknown; end?: unknown; startSeq?: unknown; endSeq?: unknown } + if (op.op !== 'replace') return null + const start = typeof op.startSeq === 'number' ? op.startSeq : op.start + const end = typeof op.endSeq === 'number' ? op.endSeq : op.end + if (typeof start !== 'number' || !Number.isFinite(start)) return null + if (typeof end !== 'number' || !Number.isFinite(end)) return null + return { start, end } +} diff --git a/src/host/timeline.ts b/src/host/timeline.ts index 53c7452..6d388a7 100644 --- a/src/host/timeline.ts +++ b/src/host/timeline.ts @@ -42,6 +42,13 @@ const surfaceNodeSchema = z.object({ calls: z.array(z.string()).optional(), }).strict() +/** One live system-prompt node (shared/types.ts SystemPromptNode). */ +const systemPromptNodeSchema = z.object({ + seq: z.number().int().nonnegative(), + time: z.number(), + tokens: z.number().int().nonnegative(), +}).strict() + const requestRecordSchema = z.object({ turn: z.number().optional(), step: z.number().optional(), @@ -183,6 +190,7 @@ export const contextTimelineSchema = z.object({ events: z.array(contextEventSchema).optional(), cost: z.object({ flash: costFamilySchema.optional(), pro: costFamilySchema.optional() }).strict().optional(), timing: timingTotalsSchema.optional(), + systems: z.array(systemPromptNodeSchema).optional(), nodes: z.array(surfaceNodeSchema).optional(), droppedNodes: z.number().int().nonnegative().optional(), archive: z.array(surfaceNodeSchema).optional(), @@ -207,6 +215,8 @@ const timelineStateSchema = z.object({ tool: z.number().int().nonnegative(), }).strict(), systemTokens: z.number().int().nonnegative(), + systems: z.array(systemPromptNodeSchema).optional(), + systemsFromHeader: z.literal(true).optional(), toolsTokens: z.number().int().nonnegative(), model: z.string().optional(), provider: z.string().optional(), @@ -311,6 +321,16 @@ export function createContextTimelineDefinition(config: Config, slim: () => bool // 15: a search with the complete matched-file meta now books its call // TARGET (the searched path / the pattern) in addition to the per-file // hit rows — the op log's fold semantics changed, so cached rows refold. + // + // 15 since 0.47: the fold reads BOTH supported log generations (see + // host/logShapes.ts) — V3 `system/message` nodes, embedded assistant + // streams, `startSeq`/`endSeq` replacements, `tool/ptc-dispatch`. The new + // state fields (`systems`, `systemsFromHeader`) are additive-OPTIONAL, so + // cached rows keep parsing and stay USABLE: a bump would invalidate every + // row and orphan the key for idle sessions, which have no refresh channel + // until they go live again (the #37 regression) — strictly worse than a + // pre-fix session showing its corrected figures from the next folded + // event onward. stateVersion: 15, } return definition diff --git a/src/shared/estimate.ts b/src/shared/estimate.ts index 4f86bbf..bbe2b57 100644 --- a/src/shared/estimate.ts +++ b/src/shared/estimate.ts @@ -14,3 +14,32 @@ export function estimateSystemTokens(text: unknown): number { if (typeof text !== 'string' || text.length === 0) return 0 return Math.ceil(text.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD } + +/** + * Price a `system/message` payload's content exactly like the harness's + * token-meter (`estimateSystemMessage`): text density over EVERY text block + * plus role framing, with no per-block overhead — an adapter serializes the + * prompt as plain text, so a text block costs its characters alone. Any other + * block (or a hostile element) falls back to its JSON length. 0 for empty + * content, which the harness reads as "no system prompt". + */ +export function estimateSystemContent(blocks: unknown): number { + if (!Array.isArray(blocks) || blocks.length === 0) return 0 + let characters = 0 + for (const block of blocks) { + const text = block !== null && typeof block === 'object' && (block as { type?: unknown }).type === 'text' + ? (block as { text?: unknown }).text + : undefined + if (typeof text === 'string') { + characters += text.length + continue + } + try { + const json: unknown = JSON.stringify(block) + if (typeof json === 'string') characters += json.length + } catch { + // A cyclic/hostile block contributes no characters instead of throwing the fold. + } + } + return Math.ceil(characters / CHARS_PER_TOKEN) + ROLE_OVERHEAD +} diff --git a/src/shared/types.ts b/src/shared/types.ts index c33f220..c693bcf 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -48,6 +48,20 @@ declare module '@deepseek-ai/dsh-session-projection/types' { export type Category = 'user' | 'inject' | 'assistant' | 'tool' +/** + * One live system-prompt node (Snapshot.systems) — the harness models the + * system prompt as a surface node, so its TEXT is fetched on demand from the + * event at `seq`: a V3 `system/message` event, or the V0/V2 `request/header` + * whose envelope carried `header.system`. `tokens` is the node's heuristic + * price (0 for a dormant empty node, which the harness reads as "no system + * prompt"); the effective figure is the LAST node with `tokens > 0`. + */ +export interface SystemPromptNode { + seq: number + time: number + tokens: number +} + /** * The stats board's count figures, precomputed host-side over the RETAINED * request/event records (the same set the detail payload serves). Carried by @@ -168,6 +182,13 @@ export interface Snapshot { * one — clients treat absence as an empty timing card). */ timing?: TimingTotals + /** + * The live system-prompt nodes, oldest first — the browser's per-step source + * for the System section. Absent when the log carried no system prompt, and + * on older plugin builds (the client then falls back to the header epoch's + * own `systemTokens`, the pre-V3 shape). + */ + systems?: SystemPromptNode[] /** * The served live surface: the newest `maxNodes` tail PLUS every live inject node older than the tail (injections land first and are * few, @@ -358,9 +379,13 @@ export interface ToolTimingTotals { /** * Whole-session timing totals, host-folded from the durable `step/start` / - `step/end` / `assistant/chunk` / `tool/call` / `tool/result` lifecycle - * (running totals over the COMPLETE session log — the same never-trimmed - * framing as `cost`). Durations are wall-clock milliseconds: `wallMs` sums + * `step/end` / `tool/call` / `tool/result` lifecycle plus the model call's + * first token (running totals over the COMPLETE session log — the same + * never-trimmed framing as `cost`). The first token comes from a V0 + * `assistant/chunk` delta or from the call's own embedded stream + * (`assistant/message.data.stream` / `assistant/attempt.data.stream`, the + * V2+ settlement) — whichever the log carries, matching the harness's own + * session-stats fold. Durations are wall-clock milliseconds: `wallMs` sums * whole steps, `ttftMs` the step-start → first-token slice (the model wait) * and `genMs` the first-token → assistant-message slice (the generation) — * both only over calls whose stream carried a token delta, `toolsMs` the sum diff --git a/tests/client/assemble.spec.ts b/tests/client/assemble.spec.ts index e126b71..3aeeefd 100644 --- a/tests/client/assemble.spec.ts +++ b/tests/client/assemble.spec.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { describe, test } from 'vitest' -import { assemble, headerAt } from '../../src/client/assemble' +import { assemble, headerAt, systemAt } from '../../src/client/assemble' import type { ContextHeaders, ContextTimeline, HeaderRecord, SurfaceNode } from '../../src/shared/types' function node(seq: number, over: Partial = {}): SurfaceNode { @@ -135,3 +135,39 @@ describe('assemble approximate flag', () => { assert.equal(assemble(timeline(), null, 6).approximate, false) }) }) + +describe('systemAt — the system prompt in force', () => { + const sys = (seq: number, tokens: number, time = seq * 100) => ({ seq, time, tokens }) + + test('the last nonempty live node wins, dormant empties are skipped', () => { + const data = timeline({ systems: [sys(3, 10), sys(8, 0), sys(12, 20)] }) + assert.deepEqual(systemAt(data, null, null), sys(12, 20)) + // Only a dormant node after the last live one: the earlier prompt stands. + assert.deepEqual(systemAt(timeline({ systems: [sys(3, 10), sys(8, 0)] }), null, null), sys(3, 10)) + }) + + test('a step resolves the prompt logged before it', () => { + const data = timeline({ systems: [sys(3, 10), sys(12, 20)] }) + assert.deepEqual(systemAt(data, null, 12), sys(3, 10)) + assert.deepEqual(systemAt(data, null, 13), sys(12, 20)) + assert.equal(systemAt(data, null, 3), null, 'at the node itself the prompt is not yet in force') + }) + + test('a list holding only dormant nodes yields no prompt', () => { + assert.equal(systemAt(timeline({ systems: [sys(3, 0)] }), null, null), null) + }) + + test('rows folded before `systems` existed fall back to the header epoch', () => { + const epoch: HeaderRecord = { seq: 15, time: 1500, systemTokens: 42, tools: [] } + assert.deepEqual(systemAt(timeline(), epoch, null), { seq: 15, time: 1500, tokens: 42 }) + assert.deepEqual(systemAt(timeline({ systems: [] }), epoch, 20), { seq: 15, time: 1500, tokens: 42 }) + assert.equal(systemAt(timeline(), { seq: 15, time: 1500, tools: [] }, null), null, 'an epoch without a price yields none') + assert.equal(systemAt(timeline(), null, null), null) + }) + + test('assemble exposes the resolved prompt', () => { + const data = timeline({ systems: [sys(3, 10)] }) + assert.deepEqual(assemble(data, null, null).system, sys(3, 10)) + assert.equal(assemble(timeline(), null, null).system, null) + }) +}) diff --git a/tests/client/components/browser.spec.ts b/tests/client/components/browser.spec.ts index b3a4330..63e2e73 100644 --- a/tests/client/components/browser.spec.ts +++ b/tests/client/components/browser.spec.ts @@ -516,6 +516,58 @@ describe('ContextBrowser header epochs', () => { assert.ok(text(query(m.container, '.lc-br-body')).includes('carried no system prompt')) await m.unmount() }) + + test('a V3 system prompt rides the timeline systems list, fetched from its own event seq', async () => { + // The V3 generation: the header epoch carries NO system price (the prompt + // is a `system/message` surface node), so the section resolves the prompt + // from `data.systems` and reads its text off that node's seq. + const headers: ContextHeaders = { headers: [{ seq: 15, time: 1500, tools: [{ name: 'x', tokens: 1 }] }] } + const data = tl({ + current: { system: 30, tools: 1, user: 0, inject: 0, assistant: 0, tool: 0, total: 31 }, + systems: [{ seq: 7, time: 700, tokens: 30 }], + }) + const asked: number[] = [] + const fetchHeader = (seq: number): Promise => { + asked.push(seq) + return Promise.resolve(seq === 7 ? { system: 'V3 PROMPT', tools: [] } : { tools: [] }) + } + const m = await mount(h(Browser, props({ data, headers, fetchHeader }))) + assert.ok(text(catRow(m, 'system')).includes(kit.t('browser.items', { n: 1 }))) + await click(catRow(m, 'system')) + await flush() + assert.deepEqual(asked, [7], 'the prompt is fetched from the system node, not the header epoch') + assert.ok(text(query(m.container, '.lc-br-body')).includes('V3 PROMPT')) + await m.unmount() + }) + + test('a resolved prompt whose event carries no text explains its absence', async () => { + // The node exists (so the category opens) but the fetched event maps to + // no prompt text — a foreign or truncated envelope. + const data = tl({ + current: { system: 30, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 30 }, + systems: [{ seq: 7, time: 700, tokens: 30 }], + }) + const m = await mount(h(Browser, props({ data, fetchHeader: () => Promise.resolve({ tools: [] }) }))) + await click(catRow(m, 'system')) + await flush() + assert.ok(text(query(m.container, '.lc-br-body')).includes('carried no system prompt')) + await m.unmount() + }) + + test('a step before the system node shows no prompt (the list is step-scoped)', async () => { + const headers: ContextHeaders = { headers: [{ seq: 3, time: 300, tools: [] }] } + const data = tl({ + current: { system: 30, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 30 }, + requests: [req({ seq: 5, turn: 1, step: 0, system: 0, tools: 0, user: 5, total: 5 })], + systems: [{ seq: 7, time: 700, tokens: 30 }], + }) + const m = await mount(h(Browser, props({ data, headers }))) + await pickStep(m, '5') + assert.ok(text(catRow(m, 'system')).includes('0 Items')) + await click(catRow(m, 'system')) + assert.equal(queryAll(m.container, '.lc-br-body').length, 0, 'no prompt yet at this step') + await m.unmount() + }) }) describe('ContextBrowser tool schemas', () => { diff --git a/tests/client/dna.spec.ts b/tests/client/dna.spec.ts index c1fecf5..773e858 100644 --- a/tests/client/dna.spec.ts +++ b/tests/client/dna.spec.ts @@ -1,22 +1,26 @@ // dnaOf (src/client/dna.ts): the DNA strip's per-item decomposition — the -// header epoch's bands (system prompt, then tool schemas in producer order) -// lead, the message flow follows in seq order: the order the model reads -// the context, which is also the order items joined it. +// system prompt and the header epoch's tool schemas lead, the message flow +// follows in seq order: the order the model reads the context, which is also +// the order items joined it. import assert from 'node:assert/strict' import { describe, test } from 'vitest' import { dnaOf } from '../../src/client/dna' import type { Assembled } from '../../src/client/assemble' -import type { HeaderRecord, HeaderTool, SurfaceNode } from '../../src/shared/types' +import type { HeaderRecord, HeaderTool, SurfaceNode, SystemPromptNode } from '../../src/shared/types' function asm(over: Partial): Assembled { - return { live: true, header: null, nodes: [], missingLive: 0, approximate: false, ...over } + return { live: true, header: null, system: null, nodes: [], missingLive: 0, approximate: false, ...over } } function header(over: Partial): HeaderRecord { return { seq: 10, time: 9000, tools: [], ...over } } +function system(over: Partial = {}): SystemPromptNode { + return { seq: 3, time: 9000, tokens: 100, ...over } +} + function tool(name: string, tokens: number): HeaderTool { return { name, tokens } } @@ -30,15 +34,16 @@ describe('dnaOf', () => { assert.deepEqual(dnaOf(asm({})), []) }) - test('header epoch bands lead: the system prompt first, then the tool schemas in producer order', () => { + test('the system prompt leads, then the header epoch\'s tool schemas in producer order', () => { const items = dnaOf(asm({ - header: header({ systemTokens: 100, tools: [tool('bash', 30), tool('write', 20)] }), + system: system(), + header: header({ tools: [tool('bash', 30), tool('write', 20)] }), nodes: [node({ seq: 1 })], })) assert.deepEqual(items.map(i => i.key), ['sys', 'tool:bash', 'tool:write', 'n1']) assert.deepEqual(items.map(i => i.cat), ['system', 'tools', 'tools', 'user']) assert.deepEqual(items.map(i => i.tokens), [100, 30, 20, 5]) - assert.equal(items[0].time, 9000, 'the system band carries the epoch time') + assert.equal(items[0].time, 9000, 'the system band carries the prompt node\'s time') assert.equal('time' in items[1], false, 'tool bands carry no time') assert.ok(!('node' in items[0]), 'header bands carry no node') }) @@ -65,7 +70,8 @@ describe('dnaOf', () => { // refreshed epoch's seq lands AFTER every message already in context — epoch-seq ordering parked the system/tools // bands behind the tool results those very schemas describe. const items = dnaOf(asm({ - header: header({ seq: 50, systemTokens: 100, tools: [tool('bash', 30)] }), + system: system({ seq: 50 }), + header: header({ seq: 50, tools: [tool('bash', 30)] }), nodes: [node({ seq: 3 }), node({ seq: 20, cat: 'tool' }), node({ seq: 60, cat: 'assistant' })], })) assert.deepEqual(items.map(i => i.key), ['sys', 'tool:bash', 'n3', 'n20', 'n60']) diff --git a/tests/client/historyPage.spec.ts b/tests/client/historyPage.spec.ts index 9a72e4a..cd84d80 100644 --- a/tests/client/historyPage.spec.ts +++ b/tests/client/historyPage.spec.ts @@ -305,6 +305,32 @@ describe('makeContentFetcher — the per-session targeted read', () => { broken.dispose() }) + test('a V3 system/message event maps its prompt text (the epoch fetch reads both shapes)', async () => { + const ctx = armHistoryFaces({ session: pageFace([ + ev('system/message', 7, { message: { content: [{ type: 'text', text: 'V3 prompt' }, { type: 'text', text: ' line two' }] } }), + ]) }) + const content = await makeHeaderFetcher('s')!(7) + assert.deepEqual(content, { system: 'V3 prompt line two', tools: [] }) + ctx.dispose() + }) + + test('a whitespace-only system prompt renders (never reads as absent), and no text block maps to null', async () => { + const ctx = armHistoryFaces({ session: pageFace([ + ev('system/message', 7, { message: { content: [{ type: 'text', text: ' ' }] } }), + ev('system/message', 8, { message: { content: [{ type: 'image', attachment: {} }] } }), + ev('system/message', 9, { message: {} }), + ev('system/message', 10, {}), + ev('system/message', 11, { message: { content: [7, null] } }), + ]) }) + const fetch = makeHeaderFetcher('s')! + assert.deepEqual(await fetch(7), { system: ' ', tools: [] }) + assert.equal(await fetch(8), null, 'no text block: nothing to render') + assert.equal(await fetch(9), null) + assert.equal(await fetch(10), null) + assert.equal(await fetch(11), null, 'primitive elements carry no text') + ctx.dispose() + }) + test('malformed rpc envelopes reject so the caller can offer a retry', async () => { const nullResponse = armHistoryFaces({ session: { page: async () => null } }) await assert.rejects(makeContentFetcher('s')!(1)) diff --git a/tests/client/services.spec.ts b/tests/client/services.spec.ts index 2784367..faa39c5 100644 --- a/tests/client/services.spec.ts +++ b/tests/client/services.spec.ts @@ -428,6 +428,44 @@ describe('timingOf', () => { }) }) +describe('timelineOf — the live system-prompt nodes', () => { + const current = { system: 1, tools: 2, user: 3, inject: 4, assistant: 5, tool: 6, total: 7 } + const base = { ok: true, current, requests: [], events: [], nodes: [], archive: [], droppedNodes: 0 } + + test('a well-formed systems list passes through by reference (fast path)', () => { + const wire = { ...base, systems: [{ seq: 3, time: 300, tokens: 42 }] } + assert.equal(timelineOf(wire), wire) + }) + + test('a malformed list takes the sanitizing slow path: entries re-proved and seq-ordered', () => { + const wire = { + ...base, + systems: [ + { seq: 9, time: 900, tokens: 5 }, + null, + 7, + { seq: 'x', time: 1, tokens: 1 }, + { seq: 2, time: Number.NaN, tokens: 1 }, + { seq: 2, time: 200, tokens: 'nope' }, + { seq: 2, time: 200, tokens: 8 }, + { seq: 5, time: 500, tokens: 3 }, + ], + } + const out = timelineOf(wire) + assert.ok(out !== (wire as unknown)) + assert.deepEqual(out?.systems, [ + { seq: 2, time: 200, tokens: 8 }, + { seq: 5, time: 500, tokens: 3 }, + { seq: 9, time: 900, tokens: 5 }, + ]) + }) + + test('an absent systems key stays absent; a non-list becomes empty', () => { + assert.equal('systems' in (timelineOf(base) ?? {}), false) + assert.deepEqual(timelineOf({ ...base, systems: 'nope' })?.systems, []) + }) +}) + describe('timelineOf — timing integration', () => { const current = { system: 1, tools: 2, user: 3, inject: 4, assistant: 5, tool: 6, total: 7 } const base = { ok: true, current, requests: [], events: [], nodes: [], archive: [], droppedNodes: 0 } diff --git a/tests/host/fold-v3.spec.ts b/tests/host/fold-v3.spec.ts new file mode 100644 index 0000000..d4b3688 --- /dev/null +++ b/tests/host/fold-v3.spec.ts @@ -0,0 +1,263 @@ +// The V3 log generation (dsh 0.1.5-alpha.1+): `system/message` surface nodes, +// the embedded assistant stream that replaced `assistant/chunk`, the +// `startSeq`/`endSeq` replacement endpoints, `tool/ptc-dispatch`, and +// `assistant/attempt`. Each case is the generation seam the fold must read +// alongside the V0 shapes (tests/host/fold-surface.spec.ts and +// fold-timing.spec.ts pin those), plus the hostile shapes the durable log may +// carry. No mocks: the real fold runs. + +import assert from 'node:assert/strict' +import { describe, test } from 'vitest' +import { estimateSystemContent } from '../../src/shared/estimate' +import type { TimelineEvent } from '../../src/host/fold' +import { + assistantAttempt, + assistantMessage, + codeDispatch, + header, + stepEnd, + stepStart, + toolCall, + toolResult, + userMessage, +} from './helpers/events' +import { assertStable, driveTimeline, timelineDef } from './helpers/projection' + +const text = (t: string) => [{ type: 'text', text: t }] + +/** One raw stream record carrying a token delta at `time`. */ +const token = (time: number, t = 'x') => ({ type: 'chunk', time, chunk: { type: 'text-delta', text: t } }) + +describe('V3 system/message — the system prompt as a surface node', () => { + test('an appended system prompt prices into systemTokens and the request total', () => { + const prompt = 'You are an agent. '.repeat(20) + const { state, view } = driveTimeline([ + header(1, { tools: [] }), + { type: 'system/message', seq: 2, time: 2000, data: { turn: 1, step: 1, message: { content: text(prompt) } }, surfaceOp: 'append' }, + userMessage(3, text('hi'), { kind: 'user' }), + assistantMessage(4, { turn: 1, step: 1 }), + ]) + const price = estimateSystemContent(text(prompt)) + assert.equal(state.systemTokens, price) + assert.deepEqual(state.systems, [{ seq: 2, time: 2000, tokens: price }]) + assert.equal(state.requests[0].system, price, 'the request record snapshots the prompt in force') + assert.deepEqual(view.systems, [{ seq: 2, time: 2000, tokens: price }], 'the wire carries the live prompt nodes') + assert.equal(view.current.total, price + state.sums.user + state.sums.assistant, 'the prompt joins the composition total') + }) + + test('a system-less request header never clears log-sourced nodes (V3 headers carry no prompt)', () => { + const prompt = 'prompt '.repeat(10) + const { state } = driveTimeline([ + { type: 'system/message', seq: 1, time: 1000, data: { message: { content: text(prompt) } }, surfaceOp: 'append' }, + header(2, { tools: [] }), + header(3, { tools: [] }), + ]) + assert.equal(state.systemTokens, estimateSystemContent(text(prompt))) + assert.equal(state.systems?.length, 1) + }) + + test('an empty node is dormant: it keeps its position without clearing a live prompt', () => { + const prompt = 'prompt '.repeat(10) + const { state } = driveTimeline([ + { type: 'system/message', seq: 1, time: 1000, data: { message: { content: text(prompt) } }, surfaceOp: 'append' }, + { type: 'system/message', seq: 2, time: 2000, data: { message: { content: [] } }, surfaceOp: 'append' }, + ]) + assert.equal(state.systemTokens, estimateSystemContent(text(prompt)), 'the last NONEMPTY node stays effective') + assert.equal(state.systems?.length, 2) + assert.equal(state.systems?.[1].tokens, 0) + }) + + test('a replacement over the head swaps the node and reprices (V3 startSeq/endSeq)', () => { + const first = 'first '.repeat(10) + const second = 'second prompt text '.repeat(10) + const { state, view } = driveTimeline([ + { type: 'system/message', seq: 1, time: 1000, data: { message: { content: text(first) } }, surfaceOp: 'append' }, + { type: 'system/message', seq: 2, time: 2000, data: { message: { content: text(second) } }, surfaceOp: { op: 'replace', startSeq: 1, endSeq: 1 } }, + ]) + assert.deepEqual(state.systems, [{ seq: 2, time: 2000, tokens: estimateSystemContent(text(second)) }]) + assert.equal(state.systemTokens, estimateSystemContent(text(second))) + assert.deepEqual(view.systems, state.systems) + }) + + test('a replacement that also claims ordinary surface nodes removes them (hostile log)', () => { + const { state } = driveTimeline([ + userMessage(1, text('aaaa'), { kind: 'user' }), + { type: 'system/message', seq: 2, time: 2000, data: { message: { content: text('p') } }, surfaceOp: 'append' }, + userMessage(3, text('bbbb'), { kind: 'user' }), + userMessage(9, text('cccc'), { kind: 'user' }), + { type: 'system/message', seq: 10, time: 3000, data: { message: { content: text('q') } }, surfaceOp: { op: 'replace', startSeq: 1, endSeq: 3 } }, + ]) + assert.deepEqual(state.surface.map(n => n.seq), [9], 'the claimed nodes left the surface, the later one stayed') + assert.equal(state.sums.user, state.surface.reduce((sum, n) => sum + n.tokens, 0)) + assert.deepEqual(state.archived.map(n => [n.seq, n.gone]), [[1, 10], [3, 10]]) + assert.deepEqual(state.systems, [{ seq: 10, time: 3000, tokens: estimateSystemContent(text('q')) }]) + }) + + test('a system replacement leaves system nodes outside its range intact', () => { + const { state } = driveTimeline([ + { type: 'system/message', seq: 1, time: 1000, data: { message: { content: text('earlier') } }, surfaceOp: 'append' }, + { type: 'system/message', seq: 2, time: 2000, data: { message: { content: text('p') } }, surfaceOp: 'append' }, + { type: 'system/message', seq: 5, time: 5000, data: { message: { content: text('later prompt') } }, surfaceOp: 'append' }, + { type: 'system/message', seq: 8, time: 8000, data: { message: { content: text('q') } }, surfaceOp: { op: 'replace', startSeq: 2, endSeq: 2 } }, + ]) + assert.deepEqual(state.systems?.map(n => n.seq), [1, 5, 8], 'only the claimed node left the list') + assert.equal(state.systemTokens, estimateSystemContent(text('q')), 'the newest nonempty node is effective') + }) + + test('a system/message expires an armed shadow claim (the shadow-price protocol)', () => { + // The claim is armed by the metering event and consumed by the NEXT surface + // event: a system node settling in between must expire it, so a later + // APPEND does not silently remove the seqs the claim named. + const { state } = driveTimeline([ + userMessage(1, text('a'), { kind: 'user' }), + { type: 'compaction/summary', seq: 2, time: 2000, data: { shadowedSeqs: [1], shadowedTokenCount: 9 } }, + { type: 'system/message', seq: 3, time: 3000, data: { message: { content: text('p') } }, surfaceOp: 'append' }, + userMessage(4, text('b'), { kind: 'user' }), + ]) + assert.equal(state.pendingShadowedSeqs, undefined) + assert.deepEqual(state.surface.map(n => n.seq), [1, 4], 'the stale claim no longer removes node 1') + }) + + test('the live node list is bounded (a hostile log cannot grow the state)', () => { + const events: TimelineEvent[] = [] + for (let i = 1; i <= 12; i++) { + events.push({ type: 'system/message', seq: i, time: i * 1000, data: { message: { content: text('p' + String(i)) } }, surfaceOp: 'append' }) + } + const { state } = driveTimeline(events) + assert.equal(state.systems?.length, 8) + assert.deepEqual(state.systems?.map(n => n.seq), [5, 6, 7, 8, 9, 10, 11, 12]) + assert.equal(state.systemTokens, estimateSystemContent(text('p12'))) + }) + + test('a replacement arriving before any system node starts the list', () => { + const { state } = driveTimeline([ + { type: 'system/message', seq: 4, time: 4000, data: { message: { content: text('q') } }, surfaceOp: { op: 'replace', startSeq: 1, endSeq: 1 } }, + ]) + assert.deepEqual(state.systems, [{ seq: 4, time: 4000, tokens: estimateSystemContent(text('q')) }]) + assert.equal(state.systemTokens, estimateSystemContent(text('q'))) + }) + + test('a malformed payload prices 0 and a non-object message reads as empty', () => { + const { state } = driveTimeline([ + { type: 'system/message', seq: 1, time: 1000, data: null as never, surfaceOp: 'append' }, + { type: 'system/message', seq: 2, time: 2000, data: { message: 'not-a-record' }, surfaceOp: 'append' }, + { type: 'system/message', seq: 3, time: 3000, data: { message: { content: text('real prompt') } }, surfaceOp: 'append' }, + ]) + assert.deepEqual(state.systems?.map(n => n.tokens), [0, 0, estimateSystemContent(text('real prompt'))]) + assert.equal(state.systemTokens, estimateSystemContent(text('real prompt'))) + }) +}) + +describe('V0/V2 envelope system prompt (regression: the header path stays intact)', () => { + test('a header carrying a prompt seeds the list; a later system-less header clears it', () => { + const { state } = driveTimeline([ + header(1, { system: 'You are an agent.', tools: [] }), + header(2, { tools: [] }), + ]) + assert.deepEqual(state.systems, []) + assert.equal(state.systemTokens, 0) + }) + + test('a system-less header before any prompt leaves the state untouched', () => { + const { state } = driveTimeline([header(1, { tools: [] })]) + assert.equal(state.systems, undefined) + assert.equal(state.systemTokens, 0) + }) + + test('the envelope prompt keeps its price and rides the wire', () => { + const { state, view } = driveTimeline([header(1, { system: 'You are an agent.', tools: [] })]) + const price = estimateSystemContent(text('You are an agent.')) + assert.equal(state.systemTokens, price) + assert.deepEqual(view.systems, [{ seq: 1, time: state.systems?.[0].time, tokens: price }]) + }) +}) + +describe('V2+ embedded assistant stream — the first-token source that replaced assistant/chunk', () => { + test('a raw chunk record prices TTFT and generation', () => { + const { state } = driveTimeline([ + stepStart(1, { time: 1000 }), + assistantMessage(2, { turn: 1, step: 1, stream: [token(1400), token(1500)], usage: { outputTokens: 3 }, time: 3000 }), + stepEnd(3, { time: 3100 }), + ]) + assert.equal(state.timing?.ttftMs, 400) + assert.equal(state.timing?.genMs, 1600) + assert.equal(state.timing?.wallMs, 2100) + }) + + test('a packed text run prices TTFT from its base time plus gaps', () => { + const { state } = driveTimeline([ + stepStart(1, { time: 1000 }), + assistantMessage(2, { + turn: 1, + step: 1, + stream: [{ type: 'text-chunks', time0: 1200, index: 0, dt: [30], texts: ['', 'hello'] }], + time: 2000, + }), + ]) + assert.equal(state.timing?.ttftMs, 230, 'the first NON-EMPTY fragment carries the stamp') + assert.equal(state.timing?.genMs, 770) + }) + + test('a streamless message leaves the call unattributed (the residue bucket)', () => { + const { state } = driveTimeline([ + stepStart(1, { time: 1000 }), + assistantMessage(2, { turn: 1, step: 1, time: 2000 }), + ]) + assert.equal(state.timing?.ttftMs, 0) + assert.equal(state.timing?.genMs, 0) + assert.equal(state.timing?.calls, 1) + }) + + test('assistant/attempt stamps the open step; the message then reuses it', () => { + const { state } = driveTimeline([ + stepStart(1, { time: 1000 }), + assistantAttempt(2, { turn: 1, step: 1, stream: [token(1300)], time: 1500 }), + assistantMessage(3, { turn: 1, step: 1, stream: [token(9000)], time: 2000 }), + stepEnd(4, { time: 2100 }), + ]) + assert.equal(state.timing?.ttftMs, 300, 'the attempt\'s first token wins over the later message stream') + assert.equal(state.timing?.genMs, 700) + }) + + test('an attempt without a token, without a slot, or over a stamped slot is uninteresting', () => { + const def = timelineDef() + // No slot armed. + assertStable(def.init(), assistantAttempt(1, { turn: 1, step: 1, stream: [token(100)] }), def) + // Slot armed but the stream carries no token. + const armed = def.apply(def.init(), stepStart(1, { time: 1000 })) + assertStable(armed, assistantAttempt(2, { turn: 1, step: 1, stream: [{ type: 'chunk', time: 1, chunk: { type: 'finish' } }] }), def) + // Slot already stamped (a V0 chunk got there first). + const stamped = def.apply(armed, { type: 'assistant/chunk', seq: 2, time: 1200, data: { chunk: { type: 'text-delta', text: 'x' } } }) + assertStable(stamped, assistantAttempt(3, { turn: 1, step: 1, stream: [token(1100)] }), def) + }) +}) + +describe('V3 vocabulary seams', () => { + test('tool/ptc-dispatch books nested file ops exactly like tool/code-dispatch', () => { + const nested = codeDispatch(1, { + rootCallId: 'c1', + name: 'read', + arguments: JSON.stringify({ path: 'src/a.ts' }), + type: 'tool/ptc-dispatch', + }) + const { state } = driveTimeline([ + toolCall(0, { callId: 'c1', name: 'run_code', arguments: JSON.stringify({ description: 'inspect' }) }), + nested, + toolResult(2, { callId: 'c1', content: text('done') }), + ]) + assert.deepEqual(state.fileOps.map(op => [op.path, op.kind, op.parent]), [['src/a.ts', 'read', 2]]) + assert.equal(state.fileOps[0].program, 'inspect') + }) + + test('a V3 range replacement (startSeq/endSeq) splices in place', () => { + const { state } = driveTimeline([ + userMessage(1, text('a'), { kind: 'user' }), + userMessage(2, text('bbbb'), { kind: 'user' }), + userMessage(3, text('cc'), { kind: 'user' }), + userMessage(4, text('summary'), { kind: 'user' }, { surfaceOp: { op: 'replace', startSeq: 1, endSeq: 2 } }), + ]) + assert.deepEqual(state.surface.map(n => n.seq), [4, 3], 'the replacement takes the span\'s position') + assert.equal(state.sums.user, state.surface.reduce((sum, n) => sum + n.tokens, 0)) + assert.deepEqual(state.archived.map(n => n.seq), [1, 2]) + }) +}) diff --git a/tests/host/helpers/events.ts b/tests/host/helpers/events.ts index 1d8523f..2753d98 100644 --- a/tests/host/helpers/events.ts +++ b/tests/host/helpers/events.ts @@ -91,12 +91,15 @@ export function toolResult(seq: number, opts: { return { type: 'tool/result', seq, time: at(opts.time), data, surfaceOp: 'append' } } +/** assistant/message: the durable payload nests the message under `data.message`. */ export function assistantMessage(seq: number, opts: { turn?: number step?: number content?: ContentBlock[] /** Widened: hostile fixtures ride the same field (the fold re-proves every bucket). */ usage?: Record + /** The V2+ embedded provider stream (raw `chunk` records and packed runs). */ + stream?: unknown time?: number surfaceOp?: TimelineEvent['surfaceOp'] }): TimelineEvent { @@ -104,9 +107,50 @@ export function assistantMessage(seq: number, opts: { if (opts.turn !== undefined) data.turn = opts.turn if (opts.step !== undefined) data.step = opts.step if (opts.usage !== undefined) data.usage = opts.usage + if (opts.stream !== undefined) data.stream = opts.stream return { type: 'assistant/message', seq, time: at(opts.time), data, surfaceOp: opts.surfaceOp ?? 'append' } } +/** assistant/attempt: a settled model attempt that committed no surface message (V2+). */ +export function assistantAttempt(seq: number, opts: { + turn?: number + step?: number + stream?: unknown + time?: number +}): TimelineEvent { + return { + type: 'assistant/attempt', + seq, + time: at(opts.time), + data: { turn: opts.turn, step: opts.step, stream: opts.stream }, + } +} + +/** system/message: the V3 system prompt as a surface node. */ +export function systemMessage(seq: number, opts: { + turn?: number + step?: number + content?: unknown + time?: number + surfaceOp?: TimelineEvent['surfaceOp'] +}): TimelineEvent { + return { + type: 'system/message', + seq, + time: at(opts.time), + data: { + turn: opts.turn, + step: opts.step, + message: { + role: 'system', + source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }, + content: opts.content ?? [{ type: 'text', text: 'You are an agent.' }], + }, + }, + surfaceOp: opts.surfaceOp ?? 'append', + } +} + export function compaction(seq: number, kind: 'summary' | 'prune', data?: Record): TimelineEvent { return { type: `compaction/${kind}`, seq, time: at(), ...(data === undefined ? {} : { data }) } } @@ -120,7 +164,7 @@ export function foreign(seq: number, type = 'assistant/chunk'): TimelineEvent { return { type, seq, time: at(), data: {} } } -/** tool/code-dispatch: one nested Code-Mode call settling inside a run_code program (PTC). */ +/** A nested PTC (Code Mode) dispatch settling inside a run_code program — both vocabulary generations. */ export function codeDispatch(seq: number, opts: { rootCallId?: unknown parentCallId?: unknown @@ -129,9 +173,11 @@ export function codeDispatch(seq: number, opts: { arguments?: unknown isError?: unknown time?: number + /** The V3 vocabulary tag (`tool/ptc-dispatch`); defaults to the V0/V2 `tool/code-dispatch`. */ + type?: string }): TimelineEvent { return { - type: 'tool/code-dispatch', + type: opts.type ?? 'tool/code-dispatch', seq, time: at(opts.time), data: { diff --git a/tests/host/logShapes.spec.ts b/tests/host/logShapes.spec.ts new file mode 100644 index 0000000..a094e41 --- /dev/null +++ b/tests/host/logShapes.spec.ts @@ -0,0 +1,106 @@ +// The shape-driven readers (src/host/logShapes.ts): every supported log +// generation's spelling of the three seams the fold reconciles — the embedded +// assistant stream's first token, the replacement op's endpoints, and the raw +// chunk token test. Hostile shapes are pinned beside the happy paths: a +// malformed record must read as "nothing here", never throw (the projection +// registry drives the fold with no error boundary of its own). + +import assert from 'node:assert/strict' +import { describe, test } from 'vitest' +import { firstTokenTimeOfStream, isTokenChunk, replaceRangeOf } from '../../src/host/logShapes' + +describe('isTokenChunk', () => { + test('accepts the three delta kinds the harness counts', () => { + assert.equal(isTokenChunk({ type: 'text-delta', text: 'x' }), true) + assert.equal(isTokenChunk({ type: 'reasoning-delta', text: 'x' }), true) + assert.equal(isTokenChunk({ type: 'tool-call-delta', argumentsDelta: '{}' }), true) + assert.equal(isTokenChunk({ type: 'tool-call-delta', name: 'bash', argumentsDelta: '' }), true) + }) + + test('rejects empty deltas, non-delta chunks, and hostile shapes', () => { + assert.equal(isTokenChunk({ type: 'text-delta', text: '' }), false) + assert.equal(isTokenChunk({ type: 'text-delta' }), false) + assert.equal(isTokenChunk({ type: 'reasoning-delta', text: 42 }), false) + assert.equal(isTokenChunk({ type: 'tool-call-delta', argumentsDelta: '', name: undefined }), false) + assert.equal(isTokenChunk({ type: 'tool-call-delta', argumentsDelta: 7 }), false) + assert.equal(isTokenChunk({ type: 'block-start' }), false) + assert.equal(isTokenChunk({}), false) + assert.equal(isTokenChunk(null), false) + assert.equal(isTokenChunk('text-delta'), false) + }) +}) + +describe('firstTokenTimeOfStream', () => { + test('reads a raw chunk record', () => { + assert.equal(firstTokenTimeOfStream([ + { type: 'chunk', time: 100, chunk: { type: 'block-start', index: 0, blockType: 'text' } }, + { type: 'chunk', time: 250, chunk: { type: 'text-delta', text: 'hi' } }, + { type: 'chunk', time: 300, chunk: { type: 'text-delta', text: 'there' } }, + ]), 250) + }) + + test('reads a packed text run, accumulating the inter-member gaps', () => { + // time0 anchors member 0; dt[k] is the gap BEFORE member k+1. + assert.equal(firstTokenTimeOfStream([ + { type: 'text-chunks', time0: 1000, index: 0, dt: [10, 20], texts: ['a', 'b', 'c'] }, + ]), 1000) + assert.equal(firstTokenTimeOfStream([ + { type: 'text-chunks', time0: 1000, index: 0, dt: [10, 20], texts: ['', 'b', 'c'] }, + ]), 1010, 'a leading empty fragment defers the stamp to the next member') + }) + + test('reads a reasoning run and a name-bearing tool-call run', () => { + assert.equal(firstTokenTimeOfStream([ + { type: 'reasoning-chunks', time0: 500, index: 0, dt: [5], texts: ['think', 'ing'] }, + ]), 500) + assert.equal(firstTokenTimeOfStream([ + { type: 'tool-call-chunks', time0: 700, index: 1, dt: [0], id: 'c1', name: 'bash', args: ['{}', '{}'] }, + ]), 700, 'a name-bearing tool run starts at its first member') + assert.equal(firstTokenTimeOfStream([ + { type: 'tool-call-chunks', time0: 700, index: 1, dt: [3], id: 'c1', args: ['', '{"a":1}'] }, + ]), 703, 'a nameless tool run defers to the first non-empty arguments fragment') + }) + + test('a packed run whose first member is empty and whose gaps are hostile reads as no token', () => { + assert.equal(firstTokenTimeOfStream([ + { type: 'text-chunks', time0: 1, index: 0, dt: [Number.NaN], texts: ['', 'b'] }, + ]), undefined) + assert.equal(firstTokenTimeOfStream([ + { type: 'text-chunks', time0: Number.POSITIVE_INFINITY, index: 0, dt: [], texts: ['a'] }, + ]), undefined) + }) + + test('malformed records and containers degrade to undefined', () => { + assert.equal(firstTokenTimeOfStream(undefined), undefined) + assert.equal(firstTokenTimeOfStream('stream'), undefined) + assert.equal(firstTokenTimeOfStream([]), undefined) + assert.equal(firstTokenTimeOfStream([null, 7, { type: 'chunk', time: 'x', chunk: { type: 'text-delta', text: 'a' } }]), undefined) + assert.equal(firstTokenTimeOfStream([{ type: 'text-chunks', time0: 1, index: 0, texts: 'not-an-array' }]), undefined) + assert.equal(firstTokenTimeOfStream([{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: [7] }]), undefined) + assert.equal(firstTokenTimeOfStream([{ type: 'chunk', time: 5, chunk: { type: 'finish' } }]), undefined) + }) + + test('a run with a missing dt array still stamps its first member', () => { + assert.equal(firstTokenTimeOfStream([{ type: 'text-chunks', time0: 9, index: 0, texts: ['a'] }]), 9) + }) +}) + +describe('replaceRangeOf', () => { + test('reads both endpoint spellings', () => { + assert.deepEqual(replaceRangeOf({ op: 'replace', startSeq: 3, endSeq: 7 }), { start: 3, end: 7 }) + assert.deepEqual(replaceRangeOf({ op: 'replace', start: 3, end: 7 }), { start: 3, end: 7 }) + assert.deepEqual(replaceRangeOf({ op: 'replace', startSeq: 3, end: 7 }), { start: 3, end: 7 }, 'a mixed op prefers the V3 spelling') + assert.deepEqual(replaceRangeOf({ op: 'replace', start: 3, endSeq: 7 }), { start: 3, end: 7 }, 'a mixed op falls back per endpoint') + }) + + test('append, unknown, and malformed ops read as no replacement', () => { + assert.equal(replaceRangeOf('append'), null) + assert.equal(replaceRangeOf(null), null) + assert.equal(replaceRangeOf(undefined), null) + assert.equal(replaceRangeOf({ op: 'insert', startSeq: 1, endSeq: 2 }), null) + assert.equal(replaceRangeOf({ op: 'replace', startSeq: 1 }), null) + assert.equal(replaceRangeOf({ op: 'replace', startSeq: Number.NaN, endSeq: 2 }), null) + assert.equal(replaceRangeOf({ op: 'replace', startSeq: '1', endSeq: 2 }), null) + assert.equal(replaceRangeOf({ op: 'replace', endSeq: 2 }), null) + }) +}) diff --git a/tests/shared/estimate.spec.ts b/tests/shared/estimate.spec.ts new file mode 100644 index 0000000..6938762 --- /dev/null +++ b/tests/shared/estimate.spec.ts @@ -0,0 +1,59 @@ +// The shared token heuristics (src/shared/estimate.ts): the fixed-density +// figures the host fold and the client boundary must agree on. The system +// prompt is priced from EITHER generation's payload — the V0/V2 envelope's +// rendered string or a V3 `system/message`'s content blocks — with the +// harness token-meter's own no-per-block-overhead rule. + +import assert from 'node:assert/strict' +import { describe, test } from 'vitest' +import { estimateSystemContent, estimateSystemTokens } from '../../src/shared/estimate' + +describe('estimateSystemTokens', () => { + test('prices text density plus role framing', () => { + assert.equal(estimateSystemTokens('abcdefgh'), 2 + 4) + assert.equal(estimateSystemTokens('abcde'), 2 + 4, 'partial tokens round up') + }) + + test('absent, empty, and non-string input prices 0', () => { + assert.equal(estimateSystemTokens(undefined), 0) + assert.equal(estimateSystemTokens(null), 0) + assert.equal(estimateSystemTokens(''), 0) + assert.equal(estimateSystemTokens(42), 0) + assert.equal(estimateSystemTokens({ text: 'x' }), 0) + }) +}) + +describe('estimateSystemContent', () => { + test('prices every text block with no per-block overhead', () => { + assert.equal(estimateSystemContent([{ type: 'text', text: 'abcdefgh' }]), 2 + 4) + assert.equal( + estimateSystemContent([{ type: 'text', text: 'abcd' }, { type: 'text', text: 'efgh' }]), + 2 + 4, + 'blocks are summed before the density divide, exactly like the harness estimator', + ) + }) + + test('empty content and non-array input price 0 (the harness reads "no system prompt")', () => { + assert.equal(estimateSystemContent([]), 0) + assert.equal(estimateSystemContent(undefined), 0) + assert.equal(estimateSystemContent('text'), 0) + }) + + test('a non-text block falls back to its JSON length', () => { + const block = { type: 'image', attachment: { width: 1, height: 1 } } + assert.equal(estimateSystemContent([block]), Math.ceil(JSON.stringify(block).length / 4) + 4) + }) + + test('hostile elements degrade instead of throwing', () => { + // A primitive element has no `text`; its JSON length is priced. + assert.equal(estimateSystemContent([7]), Math.ceil('7'.length / 4) + 4) + // `undefined` stringifies to undefined (not a string) and contributes nothing. + assert.equal(estimateSystemContent([undefined]), 0 + 4) + // A text-typed block whose text is not a string is priced by JSON, not trusted. + assert.equal(estimateSystemContent([{ type: 'text', text: 42 }]), Math.ceil('{"type":"text","text":42}'.length / 4) + 4) + // A cyclic block cannot be stringified: it contributes no characters. + const cyclic: Record = { type: 'weird' } + cyclic.self = cyclic + assert.equal(estimateSystemContent([cyclic]), 4, 'role framing survives, content prices 0') + }) +})