feat(compat): fold the V2 and V3 session-log generations alongside V0
The supported range (dsh 0.1.2-rc.1+) spans three durable-log generations, and the fold only read the V0 spellings — so on 0.1.5-alpha.1 sessions the system prompt priced 0, TTFT/generation stayed 0, and startSeq/endSeq replacements appended instead of replacing. Read SHAPES, never a detected version (the probe can be wrong: a healed profile mirror may name another release than the running harness): - system/message surface nodes feed systemTokens plus a bounded systems list (the harness's "last nonempty surviving system" rule), replacing the V0/V2 request/header.header.system envelope figure; the browser resolves the prompt in force per step and fetches its text from either event shape. - The first token comes from the embedded assistant stream (assistant/message.data.stream / assistant/attempt.data.stream) when the log carries no V0 assistant/chunk deltas, mirroring dsh-llm's assistantStreamFirstTokenTime. - Replacement endpoints accept both startSeq/endSeq and start/end. - tool/ptc-dispatch books nested file ops alongside tool/code-dispatch. Statistics were differentially verified against the harness's OWN projection values on real V0, V3-native, and V3-migrated logs: system/tools/message tokens, request and turn counts, TTFT, generation, tool time, and every billed cost bucket match exactly. The new state fields are additive-optional, so stateVersion stays 15: a bump would orphan the key for idle sessions (the #37 regression).
This commit is contained in:
1 parent
006a7cf70a
commit
6819fa7fe6
19 files changed
+1155
-108
No files matched your search
+36
-2
@@ -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 }
|
||||
}
|
||||
@@ -556,7 +556,7 @@ function byCatOf(asm: Assembled): Partial<Record<Category, SurfaceNode[]>> {
|
||||
}
|
||||
|
||||
function countOf(asm: Assembled, byCat: Partial<Record<Category, SurfaceNode[]>>, 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 <div className="lc-br-note">{t(headers === null ? 'browser.noHeader' : 'browser.noEpoch')}</div>
|
||||
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 <div className="lc-br-note">{t(headers === null ? 'browser.noHeader' : 'browser.noEpoch')}</div>
|
||||
}
|
||||
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 <div className="lc-br-note">{headerNote}</div>
|
||||
if (content.system === undefined) return <div className="lc-br-note">{t('browser.noSystem')}</div>
|
||||
|
||||
+13
-12
@@ -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 })
|
||||
}
|
||||
|
||||
+49
-19
@@ -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<string, unknown>): HeaderEpochContent | null {
|
||||
function headerContentOf(event: { type: string; data: Record<string, unknown> }): HeaderEpochContent | null {
|
||||
const { type, data } = event
|
||||
if (type === 'system/message') {
|
||||
const message = data.message !== null && typeof data.message === 'object'
|
||||
? data.message as Record<string, unknown>
|
||||
: 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<string, unknown>
|
||||
: null
|
||||
@@ -339,15 +369,15 @@ function headerContentOf(data: Record<string, unknown>): 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
|
||||
}
|
||||
|
||||
+22
-1
@@ -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<string, Record<string, string>>): () => 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<Record<string, unknown>>(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
|
||||
|
||||
+183
-48
@@ -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<Category, number>
|
||||
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<number>, 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<string, unknown> | 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<number>()
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>): 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<string, unknown>
|
||||
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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+28
-3
@@ -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
|
||||
|
||||
@@ -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> = {}): 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)
|
||||
})
|
||||
})
|
||||
@@ -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<HeaderEpochContent> => {
|
||||
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', () => {
|
||||
|
||||
@@ -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>): 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>): HeaderRecord {
|
||||
return { seq: 10, time: 9000, tools: [], ...over }
|
||||
}
|
||||
|
||||
function system(over: Partial<SystemPromptNode> = {}): 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'])
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
/** 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<string, unknown>): 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: {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> = { type: 'weird' }
|
||||
cyclic.self = cyclic
|
||||
assert.equal(estimateSystemContent([cyclic]), 4, 'role framing survives, content prices 0')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user