From 9bec22959ba9e4634c8c19e5a2ac1136b76d9b96 Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Wed, 30 Sep 2026 17:56:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(timing):=20the=20session-time=20strip=20?= =?UTF-8?q?=E2=80=94=20the=20session's=20active=20time=20as=20one=20packed?= =?UTF-8?q?=20band?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docs/compatibility.md | 2 + src/client/components/contextView.tsx | 2 +- src/client/components/statsTiming.tsx | 65 ++-- src/client/components/timingStrip.tsx | 155 +++++++++ src/client/i18n.ts | 2 + src/client/services.ts | 1 + src/client/styles/stats.css | 26 ++ src/client/timelineSource.ts | 2 + src/host/fold.ts | 104 +++++- src/host/logShapes.ts | 41 +++ src/host/timeline.ts | 19 +- src/shared/types.ts | 31 ++ tests/client/components/statsTiming.spec.ts | 90 +++++- tests/client/components/timingStrip.spec.ts | 170 ++++++++++ tests/client/services.spec.ts | 2 + tests/client/timelineSource.spec.ts | 9 + tests/host/compat/registry.spec.ts | 4 +- tests/host/fold-spans.spec.ts | 334 ++++++++++++++++++++ tests/host/fold-split.spec.ts | 13 +- tests/host/logShapes.spec.ts | 71 ++++- tests/host/timeline.spec.ts | 2 +- 21 files changed, 1100 insertions(+), 45 deletions(-) create mode 100644 src/client/components/timingStrip.tsx create mode 100644 tests/client/components/timingStrip.spec.ts create mode 100644 tests/host/fold-spans.spec.ts diff --git a/docs/compatibility.md b/docs/compatibility.md index 742f22a..cf307f3 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -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 diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx index 1a78fa7..b601bf2 100644 --- a/src/client/components/contextView.tsx +++ b/src/client/components/contextView.tsx @@ -590,7 +590,7 @@ export function makeContextView( )}
- +
{/* One arrangement for every host: composition over trend in the left diff --git a/src/client/components/statsTiming.tsx b/src/client/components/statsTiming.tsx index 731216e..4a1cfa8 100644 --- a/src/client/components/statsTiming.tsx +++ b/src/client/components/statsTiming.tsx @@ -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(null) const timing = props.timing @@ -188,21 +193,29 @@ export function makeStatsTiming(kit: ViewKit, Donut: (props: DonutProps) => Reac {rows.length === 0 ?
{t('timing.empty')}
: ( -
- {/* 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). */} - 0 ? fmtDuration(wall) : '—'} - centerSub={t('timing.total')} - hoverKey={hoverKey} - onHoverKey={setHoverKey} - /> - -
+ <> +
+ {/* 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). */} + 0 ? fmtDuration(wall) : '—'} + centerSub={t('timing.total')} + hoverKey={hoverKey} + onHoverKey={setHoverKey} + /> + +
+ {/* 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 + ? + : null} + )} ) diff --git a/src/client/components/timingStrip.tsx b/src/client/components/timingStrip.tsx new file mode 100644 index 0000000..f4ffe94 --- /dev/null +++ b/src/client/components/timingStrip.tsx @@ -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 = { + 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 = { + 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(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 ( +
+
+
{ + setTipKey(null) + props.onHoverKey?.(null) + }} + > + {bands.map((b, i) => ( +
{ + setTipKey(b.key) + props.onHoverKey?.(b.kind) + }} + /> + ))} +
+ {/* Always mounted (the tip class fades opacity) so it fades out on leave instead of unmounting instantly. */} +
{tip !== null ? tip.text : ''}
+
+
+ {TICK_FRACS.map((frac, i) => { + const ms = total * frac + return ( + {ms === 0 ? '0' : fmtDuration(ms)} + ) + })} +
+
+ ) + } +} diff --git a/src/client/i18n.ts b/src/client/i18n.ts index 3f3394a..da3112a 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -57,6 +57,7 @@ export const DICT_ZH: Record = { '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 = { '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', diff --git a/src/client/services.ts b/src/client/services.ts index f73cc47..7916c92 100644 --- a/src/client/services.ts +++ b/src/client/services.ts @@ -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 } diff --git a/src/client/styles/stats.css b/src/client/styles/stats.css index eb808b2..3206821 100644 --- a/src/client/styles/stats.css +++ b/src/client/styles/stats.css @@ -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; } diff --git a/src/client/timelineSource.ts b/src/client/timelineSource.ts index a7377d2..16d2709 100644 --- a/src/client/timelineSource.ts +++ b/src/client/timelineSource.ts @@ -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]) diff --git a/src/host/fold.ts b/src/host/fold.ts index a18556d..0f0f846 100644 --- a/src/host/fold.ts +++ b/src/host/fold.ts @@ -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 ({ ...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, …) diff --git a/src/host/logShapes.ts b/src/host/logShapes.ts index b5ee98f..f5ceaed 100644 --- a/src/host/logShapes.ts +++ b/src/host/logShapes.ts @@ -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 + 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 diff --git a/src/host/timeline.ts b/src/host/timeline.ts index 15b4d9c..f6d52c5 100644 --- a/src/host/timeline.ts +++ b/src/host/timeline.ts @@ -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 /** @@ -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 @@ -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 } diff --git a/src/shared/types.ts b/src/shared/types.ts index 26ffe70..a542c38 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 diff --git a/tests/client/components/statsTiming.spec.ts b/tests/client/components/statsTiming.spec.ts index 651e4e6..1967143 100644 --- a/tests/client/components/statsTiming.spec.ts +++ b/tests/client/components/statsTiming.spec.ts @@ -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() + }) +}) diff --git a/tests/client/components/timingStrip.spec.ts b/tests/client/components/timingStrip.spec.ts new file mode 100644 index 0000000..074b30d --- /dev/null +++ b/tests/client/components/timingStrip.spec.ts @@ -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() + }) +}) diff --git a/tests/client/services.spec.ts b/tests/client/services.spec.ts index 025deb7..223b8da 100644 --- a/tests/client/services.spec.ts +++ b/tests/client/services.spec.ts @@ -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', () => { diff --git a/tests/client/timelineSource.spec.ts b/tests/client/timelineSource.spec.ts index 0569346..5f2516d 100644 --- a/tests/client/timelineSource.spec.ts +++ b/tests/client/timelineSource.spec.ts @@ -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() }) diff --git a/tests/host/compat/registry.spec.ts b/tests/host/compat/registry.spec.ts index 7fcbc78..5f6c3c8 100644 --- a/tests/host/compat/registry.spec.ts +++ b/tests/host/compat/registry.spec.ts @@ -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) }) }) } diff --git a/tests/host/fold-spans.spec.ts b/tests/host/fold-spans.spec.ts new file mode 100644 index 0000000..8fd9cc7 --- /dev/null +++ b/tests/host/fold-spans.spec.ts @@ -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 + 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) + }) +}) diff --git a/tests/host/fold-split.spec.ts b/tests/host/fold-split.spec.ts index 2021ebb..15279df 100644 --- a/tests/host/fold-split.spec.ts +++ b/tests/host/fold-split.spec.ts @@ -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) diff --git a/tests/host/logShapes.spec.ts b/tests/host/logShapes.spec.ts index 75a276d..caf11ed 100644 --- a/tests/host/logShapes.spec.ts +++ b/tests/host/logShapes.spec.ts @@ -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) diff --git a/tests/host/timeline.spec.ts b/tests/host/timeline.spec.ts index f1747d2..6cec95e 100644 --- a/tests/host/timeline.spec.ts +++ b/tests/host/timeline.spec.ts @@ -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.