feat(timing): the session-time strip — the session's active time as one packed band

The timing card gains a bottom band that reads where the session's ACTIVE
time went: every completed step's slices — the TTFT wait, the decode blocks
in stream order, the tool-run windows, and the in-step residue — packed
gapless in occurrence order, each band's width its share of the cumulative
active time (idle time between steps takes no track), with ticks at zero,
the quartiles, and the full span.

Host: the fold tiles each step at step/end (clamped into the step window,
first-wins de-overlap so parallel runs paint their union, holes filled with
the residue kind) into a new spans collection — bounded to the newest 2,000,
riding the detail channel and the inline view, never the slim head. The
decode window anchors at the first OBSERVABLE instant (the first token, or
an earlier block marker the token packing could not stamp), so a redacted
reasoning block keeps its span instead of being swallowed by the wait.
stateVersion 22 → 23: the per-step collection cannot be backfilled by later
events, so cached rows refold from the durable log.

Client: the strip joins the card's hover link by KIND — a band hover lights
every span of its kind plus the donut arc and the legend row, and the
floating tip answers only the strip's own pointer (the mirror rule).
This commit is contained in:
bowenliang123 committed 2026-09-30 17:56:09 +08:00
1 parent 084a2d7f54
commit 9bec22959b
21 files changed
+1100 -45

No files matched your search

+2
View File
@@ -116,6 +116,8 @@ The Context Dashboard (the sidebar-foot panel) reads its `contextActivity` proje
The dashboard's per-day usage chart (the first row's right half) extends the `contextActivity` ledger with per-day pricing records — each day's billed buckets keyed provider → model → pricing period, the same `SessionCostUsage` raw material the timeline's session-cost totals carry, booked at each turn's initiation instant and priced client-side by the same book and estimator as the KPI band. Booking fees per (provider, model, period) changes the fold's state shape, so the unit's `stateVersion` bumps (1 → 2): on upgrade every cached `contextActivity` row reads version-stale, and the dashboard's existing warm-up (above) cold-refolds the corpus from the durable logs, rebuilding every day's tokens AND fees exactly the first time the panel opens. On the wire the field is additive-optional: a day folded before it existed (or whose settlements carried no model header or no usage) serves without it, and the chart degrades to tokens-only bars for those days — the same sanitizer contract every other projection value rides.
The timing card's session-time strip (the card's bottom band: every completed step's TTFT wait, decode blocks in stream order, tool-run windows, and in-step residue packed gapless in occurrence order — each slice's width is its share of the session's cumulative active time, with ticks at zero, the quartiles, and the full span) bumps the timeline projection's `stateVersion` (22 → 23): the strip's painted spans are a per-step collection the fold flushes at `step/end`, which later events cannot backfill for steps already folded (the timing totals' own v11 precedent), so on upgrade every cached timeline row is invalidated and refolded from the durable log, which rebuilds the spans for sessions started under older plugin builds; the v20 warm-up rebuilds idle sessions' rows the first time the dashboard opens. On the wire the collection is additive-optional — it rides the detail channel (and the inline value on channel-less hosts), never the slim head — so a payload folded before it existed serves without it, and the timing card shows no strip then. The spans are bounded to the newest 2,000 records (a plain count cap), so an outsize session's strip paints its recent window; untrusted spans are re-proved per item at the client's render boundary, the same sanitizer contract every collection rides.
To force a full refold of an existing session sooner, delete its cached projection row — it is a derived cache and is rebuilt from the durable log:
```bash
+1 -1
View File
@@ -590,7 +590,7 @@ export function makeContextView(
)}
<div className="lc-cols lc-head">
<StatsTokens usage={usage} current={data.current} breakdown={breakdown} />
<StatsTiming timing={data.timing ?? null} />
<StatsTiming timing={data.timing ?? null} spans={data.spans} />
</div>
{/* One arrangement for every host: composition over trend in the left
+39 -26
View File
@@ -15,15 +15,19 @@
* read as "no time, many calls". The donut and the rows sit side by side so
* the head row stays half-height. Parallel tool calls each count, so the tools
* figure can overlap — the ring clamps it into the post-model window while the
* row numbers stay true.
* row numbers stay true. Below them the session-time strip packs every
* completed step's slices gapless in occurrence order (the DNA idiom applied
* to time consumed — each band's width is its share of the cumulative active
* time, idle time takes no track), joining the same hover link by KIND.
*/
import { useState, type ReactElement } from 'react'
import type { TimingTotals } from '../../shared/types'
import type { TimingSpan, TimingTotals } from '../../shared/types'
import type { ViewKit } from '../viewkit'
import { makeSliceList } from './sliceList'
import type { SliceRow } from './sliceList'
import { makeTimingStrip, TIMING_COLOR } from './timingStrip'
import type { DonutProps, DonutSegment } from './donut'
/** One ring/legend entry before the shares are computed. */
@@ -37,14 +41,8 @@ interface Slice {
times?: string
}
const COLOR = {
ttft: 'var(--color-blue-500)',
reasoning: 'var(--color-violet-500)',
text: 'var(--color-pink-500)',
toolarg: 'var(--color-amber-500)',
tools: 'var(--color-teal-500)',
other: 'var(--color-slate-400)',
} as const
/** The card's slice palette (timingStrip.tsx owns it — the strip paints the same kinds). */
const COLOR = TIMING_COLOR
/**
* The decode-throughput figure, formatted exactly as the harness's own
@@ -57,10 +55,17 @@ function formatTokensPerSecond(tps: number): string {
export function makeStatsTiming(kit: ViewKit, Donut: (props: DonutProps) => ReactElement): (props: {
timing: TimingTotals | null
/**
* The timing strip's painted spans (the detail channel's per-step time
* slices). Absent on the overview's cross-session aggregate card and on
* rows folded before the collection existed — the card then shows no strip.
*/
spans?: TimingSpan[]
}) => ReactElement {
const { t, fmt, fmtDuration, fmtShare } = kit
const SliceList = makeSliceList(kit)
return function StatsTiming(props: { timing: TimingTotals | null }): ReactElement {
const TimingStrip = makeTimingStrip(kit)
return function StatsTiming(props: { timing: TimingTotals | null; spans?: TimingSpan[] }): ReactElement {
// The legend row ↔ donut segment hover link (shared key, set from either side).
const [hoverKey, setHoverKey] = useState<string | null>(null)
const timing = props.timing
@@ -188,21 +193,29 @@ export function makeStatsTiming(kit: ViewKit, Donut: (props: DonutProps) => Reac
{rows.length === 0
? <div className="lc-empty">{t('timing.empty')}</div>
: (
<div
className="lc-donut-row flex items-center justify-start gap-3 min-w-0 @max-[320px]/lc-card:gap-2 @max-[240px]/lc-card:flex-wrap"
>
{/* donut + legend row: the gap folds at a 320px card, below 240px the row wraps
and the ring centers over the full-width legend (keyed to the lc-card container). */}
<Donut
segments={segments}
size={96}
centerTop={wall > 0 ? fmtDuration(wall) : '—'}
centerSub={t('timing.total')}
hoverKey={hoverKey}
onHoverKey={setHoverKey}
/>
<SliceList rows={rows} hoverKey={hoverKey} onHoverKey={setHoverKey} />
</div>
<>
<div
className="lc-donut-row flex items-center justify-start gap-3 min-w-0 @max-[320px]/lc-card:gap-2 @max-[240px]/lc-card:flex-wrap"
>
{/* donut + legend row: the gap folds at a 320px card, below 240px the row wraps
and the ring centers over the full-width legend (keyed to the lc-card container). */}
<Donut
segments={segments}
size={96}
centerTop={wall > 0 ? fmtDuration(wall) : '—'}
centerSub={t('timing.total')}
hoverKey={hoverKey}
onHoverKey={setHoverKey}
/>
<SliceList rows={rows} hoverKey={hoverKey} onHoverKey={setHoverKey} />
</div>
{/* The session-time strip joins the hover link: a span hover lights
every span of its KIND plus the donut arc and the legend row,
and a legend/donut hover lights the strip's matching spans. */}
{props.spans !== undefined && props.spans.length > 0
? <TimingStrip spans={props.spans} hoverKey={hoverKey} onHoverKey={setHoverKey} />
: null}
</>
)}
</div>
)
+155
View File
@@ -0,0 +1,155 @@
/**
* The timing card's session-time strip: the fold's painted spans (every
* completed step's TTFT wait, decode blocks in stream order, tool-run
* windows, and in-step residue) packed shoulder-to-shoulder in occurrence
* order — the strip reads as WHERE THE TIME WENT, not when: each band's
* width is its share of the session's cumulative active time (the donut's
* Active Time), and idle time between steps takes no track at all. The DNA
* idiom applied to time consumed. The axis runs 0 → the total active time
* with quartile ticks between (0, ¼, ½, ¾, full). Hover relays the span's
* KIND through the card's shared hover key, so every span of a category
* lights together with its donut arc and legend row (the DNA
* lifetime-highlight idiom); the floating tip answers only the strip's OWN
* pointer — a mirrored legend/donut hover lights the matching bands without
* floating a second tip over a surface the pointer is not on (the stacked
* bar's mirror rule).
*/
import { useState, type CSSProperties, type ReactElement } from 'react'
import type { TimingSpan } from '../../shared/types'
import type { ViewKit } from '../viewkit'
/** The strip's palette: the timing card's own slice colors, keyed by span kind (statsTiming.tsx shares it). */
export const TIMING_COLOR: Record<TimingSpan['kind'], string> = {
ttft: 'var(--color-blue-500)',
reasoning: 'var(--color-violet-500)',
text: 'var(--color-pink-500)',
toolarg: 'var(--color-amber-500)',
tools: 'var(--color-teal-500)',
other: 'var(--color-slate-400)',
}
/** The legend-label key of each span kind (the timing card's own vocabulary). */
const KIND_LABEL: Record<TimingSpan['kind'], string> = {
ttft: 'timing.ttft',
reasoning: 'timing.reasoning',
text: 'timing.text',
toolarg: 'timing.toolArgs',
tools: 'timing.tools',
other: 'timing.other',
}
/** Entrance stagger cap (the stacked bar's own rule): late bands join within the cap so a long span list settles fast. */
const STAGGER_CAP = 8
/** The axis's fixed tick fractions: zero, the three quartiles, and the full span. */
const TICK_FRACS: readonly number[] = [0, 0.25, 0.5, 0.75, 1]
/** One positioned band of the strip, precomputed from a sanitized span. */
interface StripBand {
key: number
kind: TimingSpan['kind']
leftPct: number
widthPct: number
/** Preformatted hover line ('Tool runs 5.0s · 14:23:01'). */
tip: string
}
export function makeTimingStrip(kit: ViewKit): (props: {
/** The fold's painted spans, in log order (untrusted — re-proved per item). */
spans: TimingSpan[]
/** The hovered slice KIND — the band ↔ donut arc ↔ legend row hover link. */
hoverKey?: string | null
/** Hover relay; absent renders the bands inert. */
onHoverKey?: (key: string | null) => void
}) => ReactElement | null {
const { t, fmtDuration, fmtTime } = kit
return function TimingStrip(props: {
spans: TimingSpan[]
hoverKey?: string | null
onHoverKey?: (key: string | null) => void
}): ReactElement | null {
// The strip's own pointer hover: the floating tip answers this alone.
const [tipKey, setTipKey] = useState<number | null>(null)
// Sanitize the untrusted collection: an unknown kind or an unusable/
// inverted instant drops the span whole instead of poisoning the axis.
const valid = props.spans.filter(s =>
Object.hasOwn(TIMING_COLOR, s.kind)
&& Number.isFinite(s.start)
&& Number.isFinite(s.end)
&& s.end > s.start)
if (valid.length === 0) return null
// The axis domain: 0 → the session's cumulative ACTIVE time (the spans
// packed gapless in log order — idle time between them takes no track).
let total = 0
for (const s of valid) total += s.end - s.start
let cursor = 0
const bands: StripBand[] = valid.map((s, i) => {
const leftPct = cursor / total * 100
cursor += s.end - s.start
return {
key: i,
kind: s.kind,
leftPct,
widthPct: (s.end - s.start) / total * 100,
tip: `${t(KIND_LABEL[s.kind])} ${fmtDuration(s.end - s.start)} · ${fmtTime(s.start)}`,
}
})
const hoverKey = props.hoverKey
const hovering = hoverKey !== null && hoverKey !== undefined && bands.some(b => b.kind === hoverKey)
// The tip centers on its band's rendered span, pinned 10% off both edges
// so a hairline band's bubble stays inside the card.
let tip: { text: string; leftPct: number } | null = null
for (const b of bands) {
if (b.key === tipKey) {
tip = { text: b.tip, leftPct: Math.max(10, Math.min(b.leftPct + b.widthPct / 2, 90)) }
break
}
}
return (
<div className="lc-tstrip" role="img" aria-label={t('timing.strip')}>
<div className="lc-tstrip-wrap">
<div
className={'lc-tstrip-bar' + (hovering ? ' lc-tstrip-dim' : '')}
onMouseLeave={() => {
setTipKey(null)
props.onHoverKey?.(null)
}}
>
{bands.map((b, i) => (
<div
key={b.key}
className={'lc-tstrip-seg animate-lc-stacked-in motion-reduce:animate-none' + (hoverKey === b.kind ? ' lc-tstrip-seg-on' : '')}
// Grow-in stagger slot (the shared stacked-in token's delay): the
// bands scaleX-open in log order on mount, capped so a long span
// list settles fast.
style={{ left: `${b.leftPct}%`, width: `${b.widthPct}%`, background: TIMING_COLOR[b.kind], '--lc-i': Math.min(i, STAGGER_CAP) } as CSSProperties}
onMouseEnter={() => {
setTipKey(b.key)
props.onHoverKey?.(b.kind)
}}
/>
))}
</div>
{/* Always mounted (the tip class fades opacity) so it fades out on leave instead of unmounting instantly. */}
<div
className={'lc-tip lc-bar-tip' + (tip !== null ? ' lc-bar-tip-on' : '')}
style={{ left: `${tip !== null ? tip.leftPct : 50}%` }}
>{tip !== null ? tip.text : ''}</div>
</div>
<div className="lc-tstrip-axis">
{TICK_FRACS.map((frac, i) => {
const ms = total * frac
return (
<span
key={frac}
className={'lc-tstrip-tick' + (i === 0 ? ' lc-tstrip-tick-first' : i === TICK_FRACS.length - 1 ? ' lc-tstrip-tick-last' : '')}
style={{ left: `${frac * 100}%` }}
>{ms === 0 ? '0' : fmtDuration(ms)}</span>
)
})}
</div>
</div>
)
}
}
+2
View File
@@ -57,6 +57,7 @@ export const DICT_ZH: Record<string, string> = {
'timing.empty': '暂无耗时数据 · 对话开始后这里会显示时长分布',
'timing.tps': '{tps} tok/s',
'timing.tpsTip': '输出速度(TPS):累计解码输出 tokens ÷ 累计解码时长,与 DeepSeek Harness 会话统计同口径。',
'timing.strip': '耗时时间轴:各段按发生次序紧凑铺排,宽度为耗时占比,刻度为零、四分位与全程',
'tokens.title': 'Token 统计',
'tokens.total': '总用量',
'tokens.output': '输出',
@@ -457,6 +458,7 @@ export const DICT_EN: Record<string, string> = {
'timing.empty': 'No timing data yet — durations appear as the conversation runs',
'timing.tps': '{tps} tok/s',
'timing.tpsTip': 'Output speed (TPS): summed decoded output tokens over summed decode time — the same pairing as the harness session stats.',
'timing.strip': 'Session-time strip: slices packed in occurrence order, width as share of the active time; ticks at zero, the quartiles, and the full span',
'tokens.title': 'Token Stats',
'tokens.total': 'Total',
'tokens.output': 'Output',
+1
View File
@@ -429,6 +429,7 @@ export function timelineOf(value: unknown): ContextTimeline | null {
...(typeof data.archiveFloor === 'number' ? { archiveFloor: data.archiveFloor } : {}),
...(data.fileOps !== undefined ? { fileOps: objectsOf(data.fileOps) } : {}),
...(typeof data.fileOpsFloor === 'number' ? { fileOpsFloor: data.fileOpsFloor } : {}),
...(data.spans !== undefined ? { spans: objectsOf(data.spans) } : {}),
}
return safe
}
+26
View File
@@ -108,3 +108,29 @@ a.lc-stat:hover { border-color: var(--dsw-alias-label-secondary); }
/* The stats grid's cell count is intrinsic (`repeat(auto-fit, minmax(108px,1fr))` on the element, see
statsContext.tsx); the donut row's 320px gap fold and 240px stack still ride
`@max-[…]/lc-card` utilities — statsTokens.tsx / statsTiming.tsx / sliceList.tsx / donut.tsx. */
/*
---- Timing card's session-time strip (timingStrip.tsx): the fold's painted
spans packed gapless in occurrence order — each band's width is its share of
the cumulative active time, idle time takes no track. The bands sit
absolutely positioned at their cumulative percent offsets; the neutral track
only reads through sub-pixel rounding seams and during the grow-in. Hover
chrome mirrors the stacked bar: staggered grow-in, dim rest.
*/
.lc-tstrip { margin-top: 10px; }
.lc-tstrip-wrap { position: relative; }
.lc-tstrip-bar { position: relative; width: 100%; height: 12px; border-radius: 4px; overflow: hidden; background: color-mix(in srgb, var(--color-neutral-500) 18%, transparent); }
.lc-tstrip-seg { position: absolute; top: 0; height: 100%; transform-origin: 0 50%; transition: opacity var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), filter var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-tstrip-seg-on { filter: brightness(1.18); }
.lc-tstrip-dim .lc-tstrip-seg { opacity: 0.35; }
.lc-tstrip-dim .lc-tstrip-seg-on { opacity: 1; }
/*
The axis: tick labels centered under hairline marks at the fixed quartiles,
the ends pinned outward — 0 hugs the left edge, the final active time the
right — so width pressure squeezes the interior labels, never the endpoints. */
.lc-tstrip-axis { position: relative; height: 13px; margin-top: 5px; font-size: 10px; line-height: 13px; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
.lc-tstrip-tick { position: absolute; transform: translateX(-50%); white-space: nowrap; }
.lc-tstrip-tick::before { content: ''; position: absolute; left: 50%; top: -4px; width: 1px; height: 3px; background: var(--dsw-alias-border-l1); }
.lc-tstrip-tick-first { transform: none; }
.lc-tstrip-tick-first::before { left: 0; }
.lc-tstrip-tick-last { transform: translateX(-100%); }
.lc-tstrip-tick-last::before { left: auto; right: 0; }
+2
View File
@@ -72,6 +72,7 @@ export function detailOf(value: unknown): ContextTimelineDetail | null {
...(typeof data.archiveFloor === 'number' ? { archiveFloor: data.archiveFloor } : {}),
...(data.fileOps !== undefined ? { fileOps: objectsOf(data.fileOps) } : {}),
...(typeof data.fileOpsFloor === 'number' ? { fileOpsFloor: data.fileOpsFloor } : {}),
...(data.spans !== undefined ? { spans: objectsOf(data.spans) } : {}),
}
}
@@ -348,6 +349,7 @@ export function useTimelineSource(ctx: ClientCtx, props: SessionStandardProps):
...(detail.archiveFloor !== undefined ? { archiveFloor: detail.archiveFloor } : {}),
...(detail.fileOps !== undefined ? { fileOps: detail.fileOps } : {}),
...(detail.fileOpsFloor !== undefined ? { fileOpsFloor: detail.fileOpsFloor } : {}),
...(detail.spans !== undefined ? { spans: detail.spans } : {}),
}
return { data, detailState: 'ready', retryDetail: retry }
}, [head, slim, store, snap])
+100 -4
View File
@@ -19,7 +19,7 @@
* the request/event records are the raw material of `buildTimelineView`.
*/
import type { Category, ContextEventRecord, ContextTimelineDetail, CostModelUsage, FileOpRecord, RequestRecord, SessionCostUsage, Snapshot, SurfaceNode, SystemPromptNode, TimingTotals, ToolTimingTotals } from '../shared/types'
import type { Category, ContextEventRecord, ContextTimelineDetail, CostModelUsage, FileOpRecord, RequestRecord, SessionCostUsage, Snapshot, SurfaceNode, SystemPromptNode, TimingSpan, TimingTotals, ToolTimingTotals } from '../shared/types'
import { isDeepSeekProvider } from '../shared/providers'
import { estimateSystemContent } from '../shared/estimate'
import type { FoldBounds } from './config'
@@ -34,7 +34,7 @@ import {
} from './pricing'
import type { ContentBlock, MessageSource } from './pricing'
import { deriveEventMessage } from '@deepseek-ai/dsh-session'
import { decodeTallyOfStream, firstTokenTimeOfStream, replaceRangeOf } from './logShapes'
import { decodeSpansOfStream, decodeTallyOfStream, firstTokenTimeOfStream, replaceRangeOf } from './logShapes'
import type { DecodeKind } from './logShapes'
import { opBearingTool, opsOfCall, parseCallArgs, rawArgsNeeded } from '../shared/fileOps'
@@ -170,6 +170,28 @@ export interface TimelineState {
time: number
firstToken?: number
}
/**
* The timing strip's painted spans (shared/types.ts TimingSpan): every
* completed step's time slices in log order, stamped with their REAL
* instants — the TTFT wait, the decode blocks in stream order, the tool-run
* windows, and the in-step residue. The client packs them gapless, each
* band's width its share of the cumulative active time (idle time BETWEEN
* steps carries no span and takes no track). Bounded by TIMING_SPANS_MAX
* (newest tail kept). Flushed per step at `step/end` from the `stepSpans`
* accumulator.
*/
spans: TimingSpan[]
/**
* The open step's accumulated painted spans: the assistant message pushes
* the model slices (TTFT + the decode blocks), each paired tool result its
* run window; `step/end` clamps them into the step window, de-overlaps
* first-wins (parallel runs paint their union), fills the holes with the
* residue kind, and flushes the tiling into `spans`. Absent until the first
* in-step span; DELETED at the flush (the plain-JSON precondition). Same
* arm/remove lifecycle as `stepStart` — never written in place, always
* replaced, so no CloneKey covers it.
*/
stepSpans?: TimingSpan[]
/**
* Tool callId → the call's name, start instant, and raw arguments, armed by
* `tool/call` and DELETED when its `tool/result` folds in (one result per
@@ -266,6 +288,9 @@ function trimState(st: TimelineState, bounds: FoldBounds): void {
st.turnRuns = countTurnRuns(st.requests)
}
if (st.events.length > bounds.maxEvents) st.events = st.events.slice(-bounds.maxEvents)
// The timing strip's spans: newest tail (a plain count cap — see
// TIMING_SPANS_MAX; the strip paints the retained window's time axis).
if (st.spans.length > TIMING_SPANS_MAX) st.spans = st.spans.slice(-TIMING_SPANS_MAX)
// The file-op log: newest tail; the newest dropped op's seq rides
// `fileOpsFloor` (the same coverage-floor family as archiveFloor).
if (st.fileOps.length > bounds.maxFileOps) {
@@ -297,6 +322,14 @@ function trimState(st: TimelineState, bounds: FoldBounds): void {
}
}
/**
* Bound on the timing strip's painted spans (TimelineState.spans): a count
* cap, newest tail kept — the same hardcoded-cap family as SYSTEM_NODES_MAX.
* At ~6–8 spans per step the cap covers a few hundred recent steps, far past
* the strip's pixel resolution on any real pane.
*/
const TIMING_SPANS_MAX = 2_000
export function createTimelineState(): TimelineState {
return {
surface: [],
@@ -309,6 +342,7 @@ export function createTimelineState(): TimelineState {
archived: [],
callNames: {},
fileOps: [],
spans: [],
}
}
@@ -549,6 +583,13 @@ function applySurface(
timing.toolsMs += dur
timing.toolCalls += 1
bumpToolTotals(timing, toolEntry.name, dur)
// The strip's tool window paints only while a step is open to own it
// (a cross-step/foreign result still prices the totals above); the
// step/end flush clamps and de-overlaps it. Replace-style push — the
// accumulator is never written in place (see TimelineState.stepSpans).
if (st.stepStart !== undefined) {
st.stepSpans = [...(st.stepSpans ?? []), { kind: 'tools', start: toolEntry.start, end: ev.time }]
}
}
// Consume-once: the entry is never looked up again after its result
// folds in (see TimelineState.callNames). Rebuild without the used ids
@@ -1001,9 +1042,12 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
// assistant/message and step/end price the model wait/generation and
// the whole step against this instant. Always a state change (a new
// slot value), even over an un-consumed predecessor — sequential logs
// never hit that, hostile ones just supersede it.
// never hit that, hostile ones just supersede it. A superseded step's
// leftover span accumulator dies here too (its spans predate the new
// window and would clamp to nothing at the flush regardless).
const s = ensure([])
s.stepStart = { time: event.time }
delete s.stepSpans
break
}
case 'step/end': {
@@ -1013,9 +1057,37 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
if (start === undefined) return state
const s = ensure(['timing'])
ensureTiming(s).wallMs += durOf(start.time, event.time)
// Consume-once: DELETE the optional field — assigning `undefined`
// Flush the open step's accumulated spans into the strip's painted
// list (see TimelineState.stepSpans): clamp every span into the step
// window, then tile it left to right — first-wins de-overlap
// (parallel tool runs paint their UNION, never double wall time) and
// every hole fills with the residue kind, so [step start, step end]
// always tiles gapless. A zero-width step (hostile times) flushes
// nothing. The spans ride the detail channel, so the flush dirties
// the detail revision for open tabs.
const painted: TimingSpan[] = []
for (const span of state.stepSpans ?? []) {
const from = Math.max(start.time, span.start)
const to = Math.min(event.time, span.end)
if (to > from) painted.push({ kind: span.kind, start: from, end: to })
}
painted.sort((a, b) => (a.start - b.start) || (a.end - b.end))
const flushed: TimingSpan[] = []
let cursor = start.time
for (const span of painted) {
const from = Math.max(cursor, span.start)
if (span.end <= from) continue
if (from > cursor) flushed.push({ kind: 'other', start: cursor, end: from })
flushed.push({ kind: span.kind, start: from, end: span.end })
cursor = span.end
}
if (event.time > cursor) flushed.push({ kind: 'other', start: cursor, end: event.time })
s.spans = [...state.spans, ...flushed]
// Consume-once: DELETE the optional fields — assigning `undefined`
// would break the plain-JSON persisted-state precondition.
delete s.stepStart
delete s.stepSpans
bumpDetailRev(s)
break
}
case 'user/message': {
@@ -1230,6 +1302,28 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
if (firstToken !== undefined) {
timing.ttftMs += durOf(stepStart.time, firstToken)
timing.genMs += durOf(firstToken, event.time)
// The strip's model slices: the silent wait, then the decode
// blocks in STREAM order. The decode window opens at the first
// OBSERVABLE instant — the first token, or an earlier block
// marker the token packing could not stamp: a redacted reasoning
// block leaves no chunk behind, so clamping to the token would
// let the wait swallow the whole decode window (and the legend's
// reasoning row, which the markers tile independently, would
// paint nothing). The FIRST surviving block reaches back to that
// instant (a marker can sit marginally past the token); the
// later blocks keep their marker-stamped starts. A marker-less
// stream leaves the window to the step/end residue fill.
// Replace-style push (see stepSpans).
const decodeBlocks = decodeSpansOfStream(data?.stream, event.time)
let decodeStart = firstToken
for (const block of decodeBlocks) decodeStart = Math.min(decodeStart, block.start)
const modelSpans: TimingSpan[] = [{ kind: 'ttft', start: stepStart.time, end: decodeStart }]
for (const block of decodeBlocks) {
const to = Math.min(event.time, block.end)
if (to <= block.start) continue
modelSpans.push({ kind: block.kind, start: modelSpans.length === 1 ? decodeStart : block.start, end: to })
}
s.stepSpans = [...(s.stepSpans ?? []), ...modelSpans]
// The throughput seat, paired exactly as the harness's session-stats
// fold pairs them: a call counts ONLY when both its decode window
// (this branch) and its provider-reported output tokens (the usage
@@ -1429,6 +1523,8 @@ function detailCollectionsOf(state: TimelineState, bounds: FoldBounds): Omit<Con
// the detail payload share this builder) — COPIES, never state aliases.
fileOps: state.fileOps.map(o => ({ ...o })),
...(state.fileOpsFloor !== undefined ? { fileOpsFloor: state.fileOpsFloor } : {}),
// The timing strip's painted spans ride the same collections — COPIES too.
spans: state.spans.map(s => ({ ...s })),
}
// The served slice: the newest `maxNodes` tail PLUS every live inject/skill
// node older than the tail. Injections (AGENTS.md, session-start context, …)
+41
View File
@@ -97,6 +97,47 @@ export function decodeTallyOfStream(stream: unknown, endTime: number): DecodeTal
return tally
}
/** One ordered decode span of a stream's block sequence (see {@link decodeSpansOfStream}). */
export interface DecodeSpan {
kind: DecodeKind
start: number
end: number
}
/**
* The ORDERED decode spans of one embedded assistant stream — the same
* ownership rule as {@link decodeTallyOfStream} (each `block-start` marker
* owns the interval up to the next one, the last one up to `endTime`), but
* keeping the sequence: the timing strip paints the decode window in stream
* order. Total over untrusted input, mirroring the tally: a malformed record
* is skipped whole, an unknown `blockType` leaves its interval unspanned
* (the caller's residue fill owns it), a zero/negative-length span drops
* out, and a stream with no marker (or not an array) yields none.
*/
export function decodeSpansOfStream(stream: unknown, endTime: number): DecodeSpan[] {
const spans: DecodeSpan[] = []
if (!Array.isArray(stream) || !Number.isFinite(endTime)) return spans
let kind: DecodeKind | undefined
let since = 0
const close = (to: number): void => {
if (kind !== undefined && to > since) spans.push({ kind, start: since, end: to })
}
for (const record of stream) {
if (record === null || typeof record !== 'object') continue
const r = record as Record<string, unknown>
if (r.type !== 'chunk' || r.chunk === null || typeof r.chunk !== 'object') continue
const chunk = r.chunk as { type?: unknown; blockType?: unknown }
if (chunk.type !== 'block-start') continue
const time = r.time
if (typeof time !== 'number' || !Number.isFinite(time)) continue
close(time)
kind = decodeKindOfBlock(chunk.blockType)
since = time
}
close(endTime)
return spans
}
/**
* The first token's instant inside one PACKED delta run (`text-chunks` /
* `reasoning-chunks` / `tool-call-chunks`): the run's base time plus the
+18 -1
View File
@@ -113,6 +113,13 @@ const fileOpSchema = z.object({
gone: z.number().int().nonnegative().optional(),
}).strict()
/** One painted span of the timing strip (shared/types.ts TimingSpan). */
const timingSpanSchema = z.object({
kind: z.enum(['ttft', 'reasoning', 'text', 'toolarg', 'tools', 'other']),
start: z.number(),
end: z.number(),
}).strict()
const currentSchema = z.object({
system: z.number().int().nonnegative(),
tools: z.number().int().nonnegative(),
@@ -223,6 +230,7 @@ export const contextTimelineSchema = z.object({
archiveFloor: z.number().int().nonnegative().optional(),
fileOps: z.array(fileOpSchema).optional(),
fileOpsFloor: z.number().int().nonnegative().optional(),
spans: z.array(timingSpanSchema).optional(),
}).strict() as unknown as z.ZodType<ContextTimeline>
/**
@@ -266,6 +274,8 @@ const timelineStateSchema = z.object({
detailRev: z.number().int().nonnegative().optional(),
fileOps: z.array(fileOpSchema),
fileOpsFloor: z.number().int().nonnegative().optional(),
spans: z.array(timingSpanSchema),
stepSpans: z.array(timingSpanSchema).optional(),
pendingCodeOps: z.record(z.string(), z.array(fileOpSchema)).optional(),
}) as unknown as z.ZodType<TimelineState>
@@ -419,7 +429,14 @@ export function createContextTimelineDefinition(config: Config, slim: () => bool
// priced by the session it forked from (issue #94). Cached rows for
// seeded forks refold from the log; untagged resume markers reset
// nothing, so ordinary rows refold to the same totals.
stateVersion: 22,
//
// 23: the timing strip's painted spans joined the state (`spans` + the
// per-step `stepSpans` accumulator) — the completed steps' time slices
// positioned by their real instants, flushed at `step/end`. A running
// per-step collection that later events cannot backfill (the timing
// totals' own v11 precedent), so cached rows refold from the log, which
// rebuilds the spans for sessions started under older plugin builds.
stateVersion: 23,
}
return definition
}
+31
View File
@@ -304,6 +304,13 @@ export interface Snapshot {
*/
fileOps?: FileOpRecord[]
fileOpsFloor?: number
/**
* The timing strip's painted spans (see TimingSpan) — present on the INLINE
* wire value and on the detail payload, absent from the slim head (they ride
* the detail channel there) and from rows folded before the collection
* existed (older hosts; the client then shows no strip).
*/
spans?: TimingSpan[]
}
/**
@@ -345,6 +352,13 @@ export interface ContextTimelineDetail {
fileOps?: FileOpRecord[]
/** The newest dropped op's seq when the op log trimmed (coverage honesty, same family as archiveFloor). */
fileOpsFloor?: number
/**
* The timing strip's painted spans (see TimingSpan): the completed steps'
* time slices in log order, positioned by their real instants. Additive-
* optional: a payload folded before the collection existed (older hosts)
* serves without it, and the timing card shows no strip then.
*/
spans?: TimingSpan[]
}
/**
@@ -448,6 +462,23 @@ export interface TokenUsage {
cacheWriteTokens: number
}
/**
* One painted span of the timing card's session-time strip (the card's bottom
* band): a completed step's time slice — the TTFT wait, one decode block (in
* stream order), one tool-run window, or an in-step residue gap — folded
* host-side at `step/end`. The instants are the slice's REAL occurrence times
* (the hover tip reads them); the strip packs the spans gapless in log order,
* each band's width its share of the session's cumulative active time (idle
* time BETWEEN steps carries no span and takes no track). `end` > `start`
* always (the fold drops zero/negative slices).
*/
export interface TimingSpan {
/** The slice kind — the timing card's own slice vocabulary (same keys, same colors). */
kind: 'ttft' | 'reasoning' | 'text' | 'toolarg' | 'tools' | 'other'
start: number
end: number
}
/**
* Cumulative billed-token totals for one pricing bucket of the session-cost
* estimate (host-folded, never trimmed — running totals over the COMPLETE
+87 -3
View File
@@ -1,14 +1,16 @@
// StatsTiming (src/client/components/statsTiming.tsx) rendered with real
// React: the active-time donut (TTFT + generation vs tools vs overhead) and
// the pct-led slice rows with call counts and true durations — plus the empty
// and hostile-timing degrades.
// the pct-led slice rows with call counts and true durations — plus the
// bottom session-time strip (the fold's per-step spans packed by their share
// of the active time in occurrence order, joined into the hover link by
// KIND), and the empty and hostile-timing degrades.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { makeDonut } from '../../../src/client/components/donut'
import { makeStatsTiming } from '../../../src/client/components/statsTiming'
import type { TimingTotals } from '../../../src/shared/types'
import type { TimingSpan, TimingTotals } from '../../../src/shared/types'
import { makeKit, mount, query, queryAll, text, hover } from '../helpers/kit'
const kit = makeKit()
@@ -16,6 +18,9 @@ const kitZh = makeKit('zh')
const StatsTiming = makeStatsTiming(kit, makeDonut(kit))
const StatsTimingZh = makeStatsTiming(kitZh, makeDonut(kitZh))
/** Local HH:MM:SS exactly as the kit's fmtTime renders it (a timezone-free assertion). */
const timeOf = (ms: number): string => new Date(ms).toLocaleTimeString('en-GB', { hour12: false })
const TIMING: TimingTotals = {
wallMs: 600_000, ttftMs: 100_000, genMs: 140_000, calls: 10, toolsMs: 300_000, toolCalls: 25,
tools: { bash: { calls: 15, ms: 200_000 }, read: { calls: 8, ms: 80_000 } },
@@ -248,3 +253,82 @@ describe('StatsTiming — the throughput chip', () => {
}
})
})
describe('StatsTiming — the session-time strip', () => {
// The generation-split timing: genMs 140s splits into thinking 90s, answer 30s, tool args 20s.
const SPLIT: TimingTotals = {
wallMs: 600_000, ttftMs: 100_000, genMs: 140_000, reasoningMs: 90_000, textMs: 30_000, toolArgMs: 20_000,
calls: 10, toolsMs: 300_000, toolCalls: 25, tools: { bash: { calls: 15, ms: 200_000 } },
}
// A 50-second session window (epoch 1s → 51s) holding 40s of ACTIVE time:
// the decode slices, then tools and overhead — with 5s idle gaps between
// them (21s → 26s and 41s → 46s) that take no track on the strip.
const SPANS: TimingSpan[] = [
{ kind: 'ttft', start: 1_000, end: 6_000 },
{ kind: 'reasoning', start: 6_000, end: 16_000 },
{ kind: 'text', start: 16_000, end: 21_000 },
{ kind: 'tools', start: 26_000, end: 41_000 },
{ kind: 'other', start: 46_000, end: 51_000 },
]
test('the strip packs the folded spans by their share of the active time over the zero/quartile/full axis', async () => {
const m = await mount(h(StatsTiming, { timing: TIMING, spans: SPANS }))
const segs = queryAll(m.container, '.lc-tstrip-seg')
assert.equal(segs.length, 5)
assert.deepEqual(segs.map(s => parseFloat((s as HTMLElement).style.left)), [0, 12.5, 37.5, 50, 87.5])
assert.deepEqual(segs.map(s => parseFloat((s as HTMLElement).style.width)), [12.5, 25, 12.5, 37.5, 12.5])
const ticks = queryAll(m.container, '.lc-tstrip-tick')
assert.deepEqual(ticks.map(t => t.textContent), ['0', '10.0s', '20.0s', '30.0s', '40.0s'])
await m.unmount()
})
test('an absent or empty spans collection paints no strip (the aggregate overview card)', async () => {
const collections: (TimingSpan[] | undefined)[] = [undefined, []]
for (const spans of collections) {
const m = await mount(h(StatsTiming, { timing: TIMING, spans }))
assert.equal(queryAll(m.container, '.lc-sl-row').length, 4, 'the rows still render')
assert.equal(queryAll(m.container, '.lc-tstrip').length, 0)
await m.unmount()
}
})
test('the empty timing state shows no strip even with spans', async () => {
const m = await mount(h(StatsTiming, { timing: null, spans: SPANS }))
assert.ok(text(m.container).includes('No timing data yet'))
assert.equal(queryAll(m.container, '.lc-tstrip').length, 0)
await m.unmount()
})
test('a strip band hover lights its donut arc and legend row by KIND — the shared hover link', async () => {
const m = await mount(h(StatsTiming, { timing: SPLIT, spans: SPANS }))
const segs = queryAll(m.container, '.lc-tstrip-seg')
await hover(segs[3])
const tip = query(m.container, '.lc-bar-tip')
assert.ok(tip.className.includes('lc-bar-tip-on'))
assert.equal(tip.textContent, `Tool runs 15.0s · ${timeOf(26_000)}`)
assert.ok(segs[3].className.includes('lc-tstrip-seg-on'))
assert.ok(query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
assert.ok((queryAll(m.container, '.lc-donut-seg')[4]?.getAttribute('class') ?? '').includes('lc-donut-seg-on'))
assert.ok(queryAll(m.container, '.lc-sl-row')[4].className.includes('lc-sl-row-on'))
await m.unmount()
})
test('a legend row hover lights the strip spans of that kind WITHOUT floating the strip tip (the mirror rule)', async () => {
const m = await mount(h(StatsTiming, { timing: SPLIT, spans: SPANS }))
const rows = queryAll(m.container, '.lc-sl-row')
await hover(rows[1])
assert.ok(query(m.container, '.lc-tstrip-bar').className.includes('lc-tstrip-dim'))
const on = queryAll(m.container, '.lc-tstrip-seg-on')
assert.equal(on.length, 1)
assert.equal(on[0], queryAll(m.container, '.lc-tstrip-seg')[1])
assert.equal(query(m.container, '.lc-bar-tip').className, 'lc-tip lc-bar-tip')
await m.unmount()
})
test('the strip tip localizes', async () => {
const m = await mount(h(StatsTimingZh, { timing: SPLIT, spans: SPANS }))
await hover(queryAll(m.container, '.lc-tstrip-seg')[1])
assert.equal(query(m.container, '.lc-bar-tip').textContent, `模型思考 10.0s · ${timeOf(6_000)}`)
await m.unmount()
})
})
+170
View File
@@ -0,0 +1,170 @@
// TimingStrip (src/client/components/timingStrip.tsx) rendered with real
// React: the session-time strip packs every span shoulder-to-shoulder in
// occurrence order on the 0 → cumulative-active-time axis (each band's width
// is its share of the active time; idle time takes no track), the fixed
// zero/quartile/full ticks, the kind-keyed hover link (every span of a kind
// lights together while the tip answers only the strip's OWN pointer), and
// the hostile-span sanitize — an unknown kind or a NaN/inverted instant drops
// out whole instead of poisoning the axis.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import type { TimingSpan } from '../../../src/shared/types'
import { makeTimingStrip } from '../../../src/client/components/timingStrip'
import { makeKit, mount, query, queryAll, hover, unhover } from '../helpers/kit'
const kit = makeKit()
const kitZh = makeKit('zh')
const Strip = makeTimingStrip(kit)
const StripZh = makeTimingStrip(kitZh)
/** Local HH:MM:SS exactly as the kit's fmtTime renders it (a timezone-free assertion). */
const timeOf = (ms: number): string => new Date(ms).toLocaleTimeString('en-GB', { hour12: false })
// A 50-second session window (epoch 1s → 51s) holding 40s of ACTIVE time: the
// three decode slices, then tools and overhead — with 5s idle gaps between
// them (21s → 26s and 41s → 46s) that must take NO track on the strip.
const SPANS: TimingSpan[] = [
{ kind: 'ttft', start: 1_000, end: 6_000 },
{ kind: 'reasoning', start: 6_000, end: 16_000 },
{ kind: 'text', start: 16_000, end: 21_000 },
{ kind: 'tools', start: 26_000, end: 41_000 },
{ kind: 'other', start: 46_000, end: 51_000 },
]
const segsOf = (container: HTMLElement): HTMLElement[] => queryAll(container, '.lc-tstrip-seg')
const leftsOf = (container: HTMLElement): number[] => segsOf(container).map(s => parseFloat(s.style.left))
const widthsOf = (container: HTMLElement): number[] => segsOf(container).map(s => parseFloat(s.style.width))
describe('TimingStrip', () => {
test('every span packs by its share of the active time in occurrence order; the axis ticks the quartiles', async () => {
const m = await mount(h(Strip, { spans: SPANS }))
const segs = segsOf(m.container)
assert.equal(segs.length, 5)
// Width reads HOW LONG (5/10/5/15/5s of the 40s active total), position
// reads the running share — the idle gaps (21s→26s, 41s→46s) take no
// track: tools sits at 50%, not at the wall-clock 62.5%.
assert.deepEqual(leftsOf(m.container), [0, 12.5, 37.5, 50, 87.5])
assert.deepEqual(widthsOf(m.container), [12.5, 25, 12.5, 37.5, 12.5])
assert.deepEqual(segs.map(s => s.style.background), [
'var(--color-blue-500)',
'var(--color-violet-500)',
'var(--color-pink-500)',
'var(--color-teal-500)',
'var(--color-slate-400)',
])
// The axis: zero, the three quartiles, and the full span of the 40s
// ACTIVE total (not the 50s wall window).
const ticks = queryAll(m.container, '.lc-tstrip-tick')
assert.deepEqual(ticks.map(t => t.textContent), ['0', '10.0s', '20.0s', '30.0s', '40.0s'])
assert.deepEqual(ticks.map(t => (t as HTMLElement).style.left), ['0%', '25%', '50%', '75%', '100%'])
assert.ok(ticks[0].className.includes('lc-tstrip-tick-first'))
assert.ok(!ticks[2].className.includes('lc-tstrip-tick-first') && !ticks[2].className.includes('lc-tstrip-tick-last'))
assert.ok(ticks[4].className.includes('lc-tstrip-tick-last'))
// The strip names itself for assistive tech; the tip rests hidden.
assert.equal(query(m.container, '.lc-tstrip').getAttribute('role'), 'img')
assert.equal(query(m.container, '.lc-bar-tip').className, 'lc-tip lc-bar-tip')
await m.unmount()
})
test('a span hover floats its tip — slice label, true duration, start instant — and relays the kind; leaving clears both', async () => {
let relayed: string | null = 'unset'
const m = await mount(h(Strip, { spans: SPANS, hoverKey: null, onHoverKey: (k) => { relayed = k } }))
await hover(segsOf(m.container)[1])
assert.equal(relayed, 'reasoning')
const tip = query(m.container, '.lc-bar-tip')
assert.ok(tip.className.includes('lc-bar-tip-on'))
assert.equal(tip.textContent, `Thinking 10.0s · ${timeOf(6_000)}`)
// Centered on the band's rendered span: 12.5% + 25%/2 = 25%.
assert.equal((tip as HTMLElement).style.left, '25%')
await unhover(query(m.container, '.lc-tstrip-bar'))
assert.equal(relayed, null)
assert.equal(query(m.container, '.lc-bar-tip').className, 'lc-tip lc-bar-tip')
await m.unmount()
})
test('the tip pins 10% off both edges for hairline end spans', async () => {
const m = await mount(h(Strip, { spans: SPANS }))
const segs = segsOf(m.container)
await hover(segs[0])
// 6.25% center clamps to the 10% floor.
assert.equal((query(m.container, '.lc-bar-tip') as HTMLElement).style.left, '10%')
await unhover(query(m.container, '.lc-tstrip-bar'))
await hover(segs[4])
// 93.75% center clamps to the 90% ceiling.
assert.equal((query(m.container, '.lc-bar-tip') as HTMLElement).style.left, '90%')
await m.unmount()
})
test('every span of the hovered KIND lights together and dims the rest, WITHOUT floating the tip (the mirror rule)', async () => {
// A second ttft span (the next step's wait, sitting inside the wall-clock
// idle window — it packs all the same).
const spans: TimingSpan[] = [...SPANS, { kind: 'ttft', start: 21_000, end: 26_000 }]
const m = await mount(h(Strip, { spans, hoverKey: 'ttft' }))
const on = queryAll(m.container, '.lc-tstrip-seg-on')
assert.equal(on.length, 2)
assert.equal(on[0], segsOf(m.container)[0])
assert.equal(on[1], segsOf(m.container)[5])
assert.ok(query(m.container, '.lc-tstrip-bar').className.includes('lc-tstrip-dim'))
assert.equal(query(m.container, '.lc-bar-tip').className, 'lc-tip lc-bar-tip')
await m.unmount()
})
test('a hover key naming no painted kind leaves the strip at rest', async () => {
for (const hoverKey of ['toolarg', 'zz']) {
const m = await mount(h(Strip, { spans: SPANS, hoverKey }))
assert.ok(!query(m.container, '.lc-tstrip-bar').className.includes('lc-tstrip-dim'), hoverKey)
assert.equal(queryAll(m.container, '.lc-tstrip-seg-on').length, 0)
await m.unmount()
}
})
test('an inert strip (no relay) still floats its own tip', async () => {
const m = await mount(h(Strip, { spans: SPANS }))
await hover(segsOf(m.container)[3])
assert.equal(query(m.container, '.lc-bar-tip').textContent, `Tool runs 15.0s · ${timeOf(26_000)}`)
await unhover(query(m.container, '.lc-tstrip-bar'))
assert.equal(query(m.container, '.lc-bar-tip').className, 'lc-tip lc-bar-tip')
await m.unmount()
})
test('a hostile span — unknown kind, NaN or inverted instant — drops out whole', async () => {
const hostile: TimingSpan[] = [
{ kind: 'nope', start: 2_000, end: 4_000 } as unknown as TimingSpan,
{ kind: 'ttft', start: Number.NaN, end: 4_000 },
{ kind: 'ttft', start: 2_000, end: Number.NaN },
{ kind: 'ttft', start: 4_000, end: 2_000 },
{ kind: 'ttft', start: 3_000, end: 3_000 },
...SPANS,
]
const m = await mount(h(Strip, { spans: hostile }))
assert.equal(segsOf(m.container).length, 5)
assert.deepEqual(leftsOf(m.container), [0, 12.5, 37.5, 50, 87.5])
await m.unmount()
})
test('an empty or all-hostile collection renders nothing', async () => {
const collections: TimingSpan[][] = [
[],
[
{ kind: 'nope', start: 1, end: 2 } as unknown as TimingSpan,
{ kind: 'ttft', start: Number.NaN, end: 2 },
{ kind: 'ttft', start: 2, end: 1 },
],
]
for (const spans of collections) {
const m = await mount(h(Strip, { spans }))
assert.equal(queryAll(m.container, '.lc-tstrip').length, 0, JSON.stringify(spans.length))
await m.unmount()
}
})
test('the aria label and the tip label localize', async () => {
const m = await mount(h(StripZh, { spans: SPANS }))
assert.equal(query(m.container, '.lc-tstrip').getAttribute('aria-label'), '耗时时间轴:各段按发生次序紧凑铺排,宽度为耗时占比,刻度为零、四分位与全程')
await hover(segsOf(m.container)[0])
assert.equal(query(m.container, '.lc-bar-tip').textContent, `模型等待 5.0s · ${timeOf(1_000)}`)
await m.unmount()
})
})
+2
View File
@@ -298,6 +298,7 @@ describe('timelineOf', () => {
detailRev: 12,
fileOps: [{ seq: 2, path: 'a.ts', kind: 'read', tool: 'read', err: false, added: 0, removed: 0 }],
fileOpsFloor: 5,
spans: [{ kind: 'ttft', start: 0, end: 100 }, null],
}
const out = timelineOf(head)
assert.ok(out !== null)
@@ -309,6 +310,7 @@ describe('timelineOf', () => {
assert.deepEqual(out.archive, [])
assert.equal(out.fileOps?.length, 1, 'the op log survives the head sanitize')
assert.equal(out.fileOpsFloor, 5)
assert.equal(out.spans?.length, 1, 'the strip spans survive, junk entries dropped')
})
test('the slim head fields re-prove: partial counts zero per field, shapeless last/detailRev drop', () => {
+9
View File
@@ -102,8 +102,14 @@ describe('detailOf', () => {
}))
assert.equal(withOps?.fileOps?.length, 1, 'junk entries drop')
assert.equal(withOps?.fileOpsFloor, 4)
// The timing strip's painted spans ride through, per-item guarded too.
const withSpans = detailOf(detail(1, {
spans: [{ kind: 'ttft', start: 0, end: 100 }, null],
}))
assert.equal(withSpans?.spans?.length, 1, 'junk span entries drop')
// Absent stays absent (the legacy inline generation's marker).
assert.equal(detailOf(detail(1))?.fileOps, undefined)
assert.equal(detailOf(detail(1))?.spans, undefined)
})
test('the slim head rides through sanitized; a malformed head drops alone', () => {
@@ -414,6 +420,7 @@ function SourceProbe(props: { ctx: ClientCtx; sessionId: string; value?: unknown
h('span', { 'data-k': 'rev' }, String(data?.detailRev ?? 'x')),
h('span', { 'data-k': 'floors' }, `${String(data?.surfaceFloor ?? 'x')}/${String(data?.archiveFloor ?? 'x')}`),
h('span', { 'data-k': 'ops' }, String(data?.fileOps?.length ?? 'x')),
h('span', { 'data-k': 'spans' }, String(data?.spans?.length ?? 'x')),
h('span', { 'data-k': 'model' }, data?.model ?? 'x'),
h('button', { 'data-k': 'retry', onClick: source.retryDetail }, 'retry'))
}
@@ -550,6 +557,7 @@ describe('useTimelineSource', () => {
archiveFloor: 5,
fileOps: [{ seq: 2, path: 'a.ts', kind: 'read', tool: 'read', err: false, added: 0, removed: 0 }],
fileOpsFloor: 4,
spans: [{ kind: 'ttft', start: 0, end: 100 }, { kind: 'other', start: 100, end: 1_400 }],
}),
},
}
@@ -565,6 +573,7 @@ describe('useTimelineSource', () => {
assert.equal(probeRead(m.container, 'rev'), '3')
assert.equal(probeRead(m.container, 'floors'), '2/5', 'the detail floors merge')
assert.equal(probeRead(m.container, 'ops'), '1', 'the op log merges')
assert.equal(probeRead(m.container, 'spans'), '2', 'the strip spans merge')
assert.ok(calls >= 1)
await m.unmount()
})
+2 -2
View File
@@ -152,7 +152,7 @@ for (const [index, baseline] of BASELINES.entries()) {
assert.ok(rows !== undefined, 'checkpoint rows are losslessly JSON-serializable')
for (const key of ['contextTimeline', 'contextHeaders']) {
const row = rows[key] as { ver: number; seq: number }
assert.equal(row.ver, key === 'contextTimeline' ? 22 : 1)
assert.equal(row.ver, key === 'contextTimeline' ? 23 : 1)
assert.equal(row.seq, 12)
}
// And the write-gate equivalent on every intermediate state of a fresh fold.
@@ -292,7 +292,7 @@ for (const [index, baseline] of BASELINES.entries()) {
driver.register(def)
// Same key at the SAME stateVersion shares the registration (preset ref-counting).
assert.doesNotThrow(() => driver.register(createContextTimelineDefinition({}, () => false)))
assert.throws(() => driver.register({ ...def, stateVersion: 23 }), RegistryViolationError)
assert.throws(() => driver.register({ ...def, stateVersion: 24 }), RegistryViolationError)
})
})
}
+334
View File
@@ -0,0 +1,334 @@
// The timing strip's span fold (src/host/fold.ts): every completed step
// flushes its painted time slices — the TTFT wait, the decode blocks in
// stream order, the tool-run windows, and the in-step residue — into the
// persisted `spans` collection the client lays out on one true time axis.
// Pinned here: the step/end tiling (clamp into the step window, first-wins
// de-overlap, residue gap-fill, so the step always tiles gapless while IDLE
// time between steps carries no span), the supersede/consume lifecycle of
// the accumulator, the detail-revision bump, the newest-tail cap, and the
// wire/state schema faces. No mocks: the real fold runs.
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import type { TimelineEvent } from '../../src/host/fold'
import { buildTimelineDetail } from '../../src/host/fold'
import { resolveBounds } from '../../src/host/config'
import { assistantMessage, stepEnd, stepStart, toolResult } from './helpers/events'
import { assertPlainJson, driveTimeline, timelineDef } from './helpers/projection'
const text = (t: string) => [{ type: 'text', text: t }]
/** One raw `chunk` record of an embedded assistant stream, at an absolute time. */
const chunkRec = (time: number, chunk: unknown): unknown => ({ type: 'chunk', time, chunk })
/** tool/call at an explicit instant (the events helper's clock is shared, so the spans specs pin times raw). */
const toolCallAt = (seq: number, callId: string, time: number): TimelineEvent => ({
type: 'tool/call', seq, time, data: { callId, name: 'bash', arguments: '{}' },
})
describe('spans — the step flush', () => {
test('a full step tiles gapless: TTFT, the decode blocks in stream order, the tool window, residue fills the holes', () => {
const stream = [
chunkRec(200, { type: 'block-start', index: 0, blockType: 'reasoning' }),
{ type: 'reasoning-chunks', time0: 210, index: 0, dt: [], texts: ['think'] },
chunkRec(700, { type: 'block-start', index: 0, blockType: 'text' }),
{ type: 'text-chunks', time0: 710, index: 1, dt: [], texts: ['answer'] },
chunkRec(1_200, { type: 'block-start', index: 0, blockType: 'tool-call' }),
{ type: 'tool-call-chunks', time0: 1_210, index: 2, dt: [], id: 'c1', args: ['{}'] },
]
const { state } = driveTimeline([
stepStart(1, { time: 0 }),
assistantMessage(2, { time: 2_000, stream }),
toolCallAt(3, 'c1', 2_500),
toolResult(4, { callId: 'c1', content: text('ok'), time: 3_000 }),
stepEnd(5, { time: 4_000 }),
])
assert.deepEqual(state.spans, [
{ kind: 'ttft', start: 0, end: 200 },
{ kind: 'reasoning', start: 200, end: 700 },
{ kind: 'text', start: 700, end: 1_200 },
{ kind: 'toolarg', start: 1_200, end: 2_000 },
{ kind: 'other', start: 2_000, end: 2_500 },
{ kind: 'tools', start: 2_500, end: 3_000 },
{ kind: 'other', start: 3_000, end: 4_000 },
])
assert.equal(Object.hasOwn(state, 'stepStart'), false, 'step/end consumed the pending slot')
assert.equal(Object.hasOwn(state, 'stepSpans'), false, '…and the span accumulator (never undefined-valued)')
})
test('the decode window opens at the first marker when it beats the token; the wait covers only the silent prefix', () => {
// The reasoning marker (100) sits BEFORE the first stamped chunk (110):
// the decode window opens at the marker, so the wait band is the 100ms
// of true silence and the block owns its whole marker-tiled interval.
const { state } = driveTimeline([
stepStart(1, { time: 0 }),
assistantMessage(2, {
time: 900,
stream: [
chunkRec(100, { type: 'block-start', index: 0, blockType: 'reasoning' }),
{ type: 'reasoning-chunks', time0: 110, index: 0, dt: [], texts: ['x'] },
],
}),
stepEnd(3, { time: 1_000 }),
])
assert.deepEqual(state.spans, [
{ kind: 'ttft', start: 0, end: 100 },
{ kind: 'reasoning', start: 100, end: 900 },
{ kind: 'other', start: 900, end: 1_000 },
])
})
test('a redacted reasoning block still paints: the marker tiles its window even though no chunk stamps a token', () => {
// The provider's durable stream carries the reasoning BLOCK markers but
// packs no reasoning chunks (the content is redacted): the first token
// lands on a tool-call fragment at 952. Metering (ttftMs) keeps the
// first-token definition — but the strip must not let the wait swallow
// the 588ms of reasoning the legend's own tally reports.
const { state } = driveTimeline([
stepStart(1, { time: 0 }),
assistantMessage(2, {
time: 1_063,
stream: [
chunkRec(364, { type: 'block-start', index: 0, blockType: 'reasoning' }),
chunkRec(952, { type: 'block-start', index: 1, blockType: 'tool-call' }),
{ type: 'tool-call-chunks', time0: 952, index: 1, dt: [0], id: 'c1', args: ['{', '}'] },
],
}),
toolCallAt(3, 'c1', 1_064),
toolResult(4, { callId: 'c1', content: text('ok'), time: 1_095 }),
stepEnd(5, { time: 1_095 }),
])
assert.equal(state.timing?.ttftMs, 952, 'metering keeps the first-token definition')
assert.equal(state.timing?.reasoningMs, 588, 'the tally tiles the marker window regardless')
assert.deepEqual(state.spans, [
{ kind: 'ttft', start: 0, end: 364 },
{ kind: 'reasoning', start: 364, end: 952 },
{ kind: 'toolarg', start: 952, end: 1_063 },
{ kind: 'other', start: 1_063, end: 1_064 },
{ kind: 'tools', start: 1_064, end: 1_095 },
])
})
test('blocks stamped wholly past the message instant drop out of the tile', () => {
// Hostile stream: both markers sit beyond the assistant message's own
// instant — their claimed intervals assert decode after the message
// landed, so they paint nothing.
const { state } = driveTimeline([
stepStart(1, { time: 0 }),
assistantMessage(2, {
time: 300,
stream: [
chunkRec(100, { type: 'text-delta', text: 'x' }),
chunkRec(400, { type: 'block-start', index: 0, blockType: 'text' }),
chunkRec(500, { type: 'block-start', index: 1, blockType: 'reasoning' }),
],
}),
stepEnd(3, { time: 600 }),
])
assert.deepEqual(state.spans, [
{ kind: 'ttft', start: 0, end: 100 },
{ kind: 'other', start: 100, end: 600 },
])
})
test('a marker-less stream leaves the generation window to the residue fill', () => {
const { state } = driveTimeline([
stepStart(1, { time: 0 }),
assistantMessage(2, {
time: 1_000,
stream: [chunkRec(100, { type: 'text-delta', text: 'x' })],
}),
stepEnd(3, { time: 1_400 }),
])
assert.deepEqual(state.spans, [
{ kind: 'ttft', start: 0, end: 100 },
{ kind: 'other', start: 100, end: 1_400 },
])
})
test('an unstamped call paints no model slices — the whole step is residue', () => {
const { state } = driveTimeline([
stepStart(1, { time: 0 }),
assistantMessage(2, { time: 1_000 }),
stepEnd(3, { time: 1_400 }),
])
assert.deepEqual(state.spans, [{ kind: 'other', start: 0, end: 1_400 }])
})
test('idle time BETWEEN steps carries no span', () => {
const step = (seq: number, start: number): TimelineEvent[] => [
stepStart(seq, { time: start }),
assistantMessage(seq + 1, { time: start + 1_000, stream: [chunkRec(start + 100, { type: 'text-delta', text: 'x' })] }),
stepEnd(seq + 2, { time: start + 1_400 }),
]
const { state } = driveTimeline([...step(1, 0), ...step(4, 10_000)])
assert.deepEqual(state.spans, [
{ kind: 'ttft', start: 0, end: 100 },
{ kind: 'other', start: 100, end: 1_400 },
// 1_400 → 10_000: the session was idle — nothing paints.
{ kind: 'ttft', start: 10_000, end: 10_100 },
{ kind: 'other', start: 10_100, end: 11_400 },
])
})
test('parallel tool runs paint their UNION, never double wall time — same-instant and shadowed windows included', () => {
const { state } = driveTimeline([
stepStart(1, { time: 0 }),
assistantMessage(2, { time: 1_000 }),
// Three overlapping windows: two identical (one shadows whole), one longer.
toolCallAt(3, 'c1', 2_000),
toolCallAt(4, 'c2', 2_000),
toolCallAt(5, 'c3', 2_000),
toolResult(6, { callId: 'c1', content: text('ok'), time: 3_000 }),
toolResult(7, { callId: 'c2', content: text('ok'), time: 3_000 }),
toolResult(8, { callId: 'c3', content: text('ok'), time: 3_500 }),
stepEnd(9, { time: 4_000 }),
])
assert.deepEqual(state.spans, [
{ kind: 'other', start: 0, end: 2_000 },
{ kind: 'tools', start: 2_000, end: 3_000 },
{ kind: 'tools', start: 3_000, end: 3_500 },
{ kind: 'other', start: 3_500, end: 4_000 },
])
// The totals keep the TRUE per-call sums (3_500ms over 3 calls) — only the
// strip de-overlaps.
assert.equal(state.timing?.toolsMs, 1_000 + 1_000 + 1_500)
assert.equal(state.timing?.toolCalls, 3)
})
test('spans clamp into the step window; a window wholly outside drops', () => {
// A run whose result arrives after the step's end instant (out-of-order
// log) paints only up to the end…
const past = driveTimeline([
stepStart(1, { time: 0 }),
toolCallAt(2, 'c1', 3_000),
toolResult(3, { callId: 'c1', content: text('ok'), time: 5_000 }),
stepEnd(4, { time: 4_000 }),
])
assert.deepEqual(past.state.spans, [
{ kind: 'other', start: 0, end: 3_000 },
{ kind: 'tools', start: 3_000, end: 4_000 },
])
// …and a run stamped wholly BEFORE the step's window paints nothing.
const before = driveTimeline([
stepStart(1, { time: 3_000 }),
toolCallAt(2, 'c1', 100),
toolResult(3, { callId: 'c1', content: text('ok'), time: 200 }),
stepEnd(4, { time: 4_000 }),
])
assert.deepEqual(before.state.spans, [{ kind: 'other', start: 3_000, end: 4_000 }])
})
test('a zero-width step flushes nothing; an unpaired step/end stays uninteresting', () => {
const { state } = driveTimeline([
stepStart(1, { time: 5_000 }),
stepEnd(2, { time: 5_000 }),
])
assert.deepEqual(state.spans, [])
const bare = driveTimeline([stepEnd(1, { time: 5_000 })])
assert.deepEqual(bare.state.spans, [])
})
test('a tool result folded without an open step prices the totals but paints no span', () => {
const { state } = driveTimeline([
toolCallAt(1, 'c1', 1_000),
toolResult(2, { callId: 'c1', content: text('ok'), time: 2_000 }),
])
assert.equal(state.timing?.toolsMs, 1_000)
assert.deepEqual(state.spans, [])
})
test('a second step/start supersedes the open accumulator — its un-flushed spans die with it', () => {
const { state } = driveTimeline([
stepStart(1, { time: 0 }),
assistantMessage(2, { time: 1_000, stream: [chunkRec(100, { type: 'text-delta', text: 'x' })] }),
// No step/end: the first step's accumulated spans are dropped, not flushed.
stepStart(3, { time: 10_000 }),
assistantMessage(4, { time: 11_000, stream: [chunkRec(10_100, { type: 'text-delta', text: 'y' })] }),
stepEnd(5, { time: 11_400 }),
])
assert.deepEqual(state.spans, [
{ kind: 'ttft', start: 10_000, end: 10_100 },
{ kind: 'other', start: 10_100, end: 11_400 },
])
})
test('the flush marks the detail revision (open tabs refetch the strip)', () => {
const { state } = driveTimeline([
stepStart(1, { time: 0 }),
stepEnd(2, { time: 1_000 }),
])
assert.equal(state.detailRev, 1)
})
})
describe('spans — retention, wire, and schema faces', () => {
test('the collection keeps the newest tail past the 2_000-span cap', () => {
// 1_001 steps × 2 spans each: the cap must drop the oldest step whole.
const events: TimelineEvent[] = []
for (let i = 0; i < 1_001; i++) {
const start = i * 10_000
events.push(
stepStart(i * 3 + 1, { time: start }),
assistantMessage(i * 3 + 2, { time: start + 1_000, stream: [chunkRec(start + 100, { type: 'text-delta', text: 'x' })] }),
stepEnd(i * 3 + 3, { time: start + 1_400 }),
)
}
const { state } = driveTimeline(events)
assert.equal(state.spans.length, 2_000)
assert.deepEqual(state.spans[0], { kind: 'ttft', start: 10_000, end: 10_100 }, 'the oldest step left the window')
assert.deepEqual(state.spans.at(-1), { kind: 'other', start: 10_000_100, end: 10_001_400 })
})
test('the inline wire view serves COPIES; the slim head omits the collection whole', () => {
const events: TimelineEvent[] = [
stepStart(1, { time: 0 }),
assistantMessage(2, { time: 1_000, stream: [chunkRec(100, { type: 'text-delta', text: 'x' })] }),
stepEnd(3, { time: 1_400 }),
]
const drive = driveTimeline(events)
assert.deepEqual(drive.view.spans, drive.state.spans)
assert.notEqual(drive.view.spans, drive.state.spans, 'the served array never aliases the state')
assert.notEqual(drive.view.spans?.[0], drive.state.spans[0], '…nor its items')
// The detail payload (the channel's serve) carries the same copies.
const detail = buildTimelineDetail(drive.state, resolveBounds({}))
assert.deepEqual(detail.spans, drive.state.spans)
assert.notEqual(detail.spans[0], drive.state.spans[0])
// The slim head (detail-channel deployments) has no spans key at all.
const slimDef = timelineDef({}, true)
let slimState = slimDef.init()
for (const ev of events) slimState = slimDef.apply(slimState, ev)
assert.equal(Object.hasOwn(slimDef.wire.view(slimState), 'spans'), false)
})
test('every intermediate state validates against the state schema; a spans-less state fails it (the v23 gate)', () => {
const drive = driveTimeline([
stepStart(1, { time: 0 }),
assistantMessage(2, { time: 1_000, stream: [chunkRec(100, { type: 'text-delta', text: 'x' })] }),
stepEnd(3, { time: 1_400 }),
])
for (const state of drive.states) drive.def.stateSchema.parse(structuredClone(state))
// A row folded before the collection existed has no `spans` key: the
// required-key schema rejects it, so the projection cache refolds it from
// the durable log (the stateVersion 22 → 23 bump).
const stale = structuredClone(drive.state) as unknown as Record<string, unknown>
delete stale.spans
assert.throws(() => drive.def.stateSchema.parse(stale))
})
test('span-bearing states stay plain JSON', () => {
const drive = driveTimeline([
stepStart(1, { time: 0 }),
assistantMessage(2, { time: 1_000, stream: [chunkRec(100, { type: 'text-delta', text: 'x' })] }),
toolCallAt(3, 'c1', 1_100),
toolResult(4, { callId: 'c1', content: text('ok'), time: 1_200 }),
stepEnd(5, { time: 1_400 }),
// A step left OPEN mid-accumulation: the intermediate states carry the
// armed accumulator through the same gate (driveTimeline pins the
// no-absent-members rule on every one).
stepStart(6, { time: 2_000 }),
assistantMessage(7, { time: 3_000, stream: [chunkRec(2_100, { type: 'text-delta', text: 'y' })] }),
])
for (const state of drive.states) assertPlainJson(state)
})
})
+7 -6
View File
@@ -50,9 +50,10 @@ function canonicalLog(): TimelineEvent[] {
describe('the detailRev ledger', () => {
test('bumps exactly on the detail-mutating folds of the canonical log', () => {
// Mutations: userMessage(5), assistantMessage(6), toolResult(8),
// assistantMessage(10), compaction(11), planMode(12) — six in total.
// stepEnd(9, the strip's span flush), assistantMessage(10), compaction(11),
// planMode(12) — seven in total.
const { state } = driveTimeline(canonicalLog())
assert.equal(state.detailRev, 6)
assert.equal(state.detailRev, 7)
})
test('the working-slot and envelope folds do NOT bump (nothing to refetch)', () => {
@@ -68,7 +69,7 @@ describe('the detailRev ledger', () => {
]
let st = state
for (const ev of follow) st = def.apply(st, ev)
assert.equal(st.detailRev, 6, 'no detail mutation, no bump')
assert.equal(st.detailRev, 7, 'no detail mutation, no bump')
// The capacity metadata and the header DID fold (state changed) — only the rev stayed.
assert.notEqual(st, state)
})
@@ -76,7 +77,7 @@ describe('the detailRev ledger', () => {
test('a real model switch bumps (the events collection gains a row)', () => {
const { def, state } = driveTimeline(canonicalLog())
const switched = def.apply(state, header(13, { model: 'deepseek-v4-pro', provider: 'deepseek', reason: 'change' }))
assert.equal(switched.detailRev, 7)
assert.equal(switched.detailRev, 8)
})
test('the rev is plain JSON and survives the state gate', () => {
@@ -104,7 +105,7 @@ describe('buildTimelineHead', () => {
// The headline anchor is the newest retained request's billing summary.
const lastReq = state.requests.at(-1)!
assert.deepEqual(head.last, { seq: lastReq.seq, total: lastReq.total, prompt: 20 })
assert.equal(head.detailRev, 6)
assert.equal(head.detailRev, 7)
// The collections stay empty on the head — the detail channel serves them.
assert.deepEqual(head.requests, [])
assert.deepEqual(head.events, [])
@@ -150,7 +151,7 @@ describe('buildTimelineDetail', () => {
test('serves the same collections the inline view serves, plus the revision and the head', () => {
const { state, view } = driveTimeline(canonicalLog())
const detail = buildTimelineDetail(state, resolveBounds({}))
assert.equal(detail.rev, 6)
assert.equal(detail.rev, 7)
assert.deepEqual(detail.requests, view.requests)
assert.deepEqual(detail.events, view.events)
assert.deepEqual(detail.nodes, view.nodes)
+70 -1
View File
@@ -7,7 +7,7 @@
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { decodeKindOfBlock, decodeTallyOfStream, firstTokenTimeOfStream, isTokenChunk, replaceRangeOf } from '../../src/host/logShapes'
import { decodeKindOfBlock, decodeSpansOfStream, decodeTallyOfStream, firstTokenTimeOfStream, isTokenChunk, replaceRangeOf } from '../../src/host/logShapes'
describe('decodeKindOfBlock', () => {
test('maps the three block kinds the harness emits', () => {
@@ -92,6 +92,75 @@ describe('decodeTallyOfStream', () => {
})
})
describe('decodeSpansOfStream', () => {
const chunk = (time: number, blockType: string) => ({ type: 'chunk', time, chunk: { type: 'block-start', blockType } })
test('keeps the block sequence: each marker owns the interval to the next, the last one to endTime', () => {
assert.deepEqual(decodeSpansOfStream([
chunk(1000, 'reasoning'),
{ type: 'reasoning-chunks', time0: 1010, index: 0, dt: [], texts: ['think'] },
chunk(1300, 'text'),
{ type: 'text-chunks', time0: 1310, index: 1, dt: [], texts: ['answer'] },
chunk(1500, 'tool-call'),
{ type: 'tool-call-chunks', time0: 1510, index: 2, dt: [], id: 'c1', args: ['{}'] },
], 2000), [
{ kind: 'reasoning', start: 1000, end: 1300 },
{ kind: 'text', start: 1300, end: 1500 },
{ kind: 'toolarg', start: 1500, end: 2000 },
])
})
test('a repeated kind stays two spans — the strip paints stream order, not a tally', () => {
assert.deepEqual(decodeSpansOfStream([
chunk(1000, 'reasoning'), chunk(1300, 'text'), chunk(1500, 'reasoning'),
], 2000), [
{ kind: 'reasoning', start: 1000, end: 1300 },
{ kind: 'text', start: 1300, end: 1500 },
{ kind: 'reasoning', start: 1500, end: 2000 },
])
})
test('an unknown blockType closes the previous span but leaves its own interval unspanned', () => {
assert.deepEqual(decodeSpansOfStream([chunk(1000, 'reasoning'), chunk(1400, 'image')], 1900), [
{ kind: 'reasoning', start: 1000, end: 1400 },
])
})
test('a zero-length or backwards interval drops out', () => {
assert.deepEqual(decodeSpansOfStream([chunk(500, 'reasoning'), chunk(500, 'text')], 900), [
{ kind: 'text', start: 500, end: 900 },
])
assert.deepEqual(decodeSpansOfStream([chunk(1000, 'text'), chunk(900, 'reasoning')], 950), [
{ kind: 'reasoning', start: 900, end: 950 },
])
})
test('a marker with an unusable time is skipped WHOLE — the previous block keeps its interval', () => {
assert.deepEqual(decodeSpansOfStream([
chunk(1000, 'reasoning'),
{ type: 'chunk', time: 'x', chunk: { type: 'block-start', blockType: 'text' } },
chunk(1500, 'text'),
], 2000), [
{ kind: 'reasoning', start: 1000, end: 1500 },
{ kind: 'text', start: 1500, end: 2000 },
])
})
test('malformed records, hostile containers, no markers, and a non-finite endTime yield none', () => {
assert.deepEqual(decodeSpansOfStream(undefined, 100), [])
assert.deepEqual(decodeSpansOfStream('stream', 100), [])
assert.deepEqual(decodeSpansOfStream([], 100), [])
assert.deepEqual(decodeSpansOfStream([{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['a'] }], 100), [])
assert.deepEqual(decodeSpansOfStream([chunk(500, 'text')], Number.NaN), [])
assert.deepEqual(decodeSpansOfStream([
null, 7, 'x',
{ type: 'chunk' },
{ type: 'chunk', time: 1, chunk: null },
{ type: 'chunk', time: 2, chunk: { type: 'text-delta', text: 'x' } },
], 100), [])
})
})
describe('isTokenChunk', () => {
test('accepts the three delta kinds the harness counts', () => {
assert.equal(isTokenChunk({ type: 'text-delta', text: 'x' }), true)
+1 -1
View File
@@ -50,7 +50,7 @@ describe('createContextTimelineDefinition', () => {
test('carries the supported projection contract on one unit', () => {
const def = createContextTimelineDefinition({}, () => false)
assert.equal(def.key, 'contextTimeline')
assert.equal(def.stateVersion, 22)
assert.equal(def.stateVersion, 23)
assert.equal(typeof def.init, 'function')
assert.equal(typeof def.apply, 'function')
// The supported registry contract: stateSchema + a REQUIRED wire block.