diff --git a/docs/compatibility.md b/docs/compatibility.md index 928fd5f..6d9f161 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -124,6 +124,8 @@ The timing totals' wait/generation boundary moving from the first token to the f Accounting for `developer/message` tool-registry changes bumps the timeline projection's `stateVersion` (24 → 25): earlier rows omitted these model-visible messages from their surface, request composition, and archives. On upgrade the derived rows refold from the durable log; the existing dashboard warm-up also rebuilds idle sessions. The wire shape stays unchanged. +The trend chart's duration overlay (the card's Duration toggle: each request's step active time drawn as a curve over the bars, read off the right-hand quartile axis) bumps the timeline projection's `stateVersion` (25 → 26): the per-request `activeMs` is stamped at `step/end` fold time — the whole step window (the model call plus its tool runs) minus the step's user waits, those being approval decision intervals (`approval/asked` → `approval/decided`, a crash-dangling pair closing at the step's own end) and `ask_user_question` answer windows (the tool's body is the wait itself, in both the blocking and the timed schema's foreground phase) — so requests already folded can never gain it later (the v9 `cacheRead` precedent), and on upgrade every cached timeline row is invalidated and refolded from the durable log, while the v20 warm-up rebuilds idle sessions' rows the first time the dashboard opens. On the wire the field is additive-optional: a request whose step is still open (the live tail) or that folded without one (an unpaired/hostile log) serves without it, and the overlay's curve simply breaks at that bar instead of faking a point — 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 b601bf2..344d704 100644 --- a/src/client/components/contextView.tsx +++ b/src/client/components/contextView.tsx @@ -118,6 +118,9 @@ export function makeContextView( // Adaptive scale (the title-adjacent toggle): the trend bars rescale to the visible window; like the two // toggles above, mount-local and never written back. const [adaptive, setAdaptive] = useState(false) + // The duration overlay (the title-adjacent toggle): each bar's step active time as a curve over the + // bars, read off the right-hand quartile axis; like the two toggles above, mount-local and never written back. + const [durationCurve, setDurationCurve] = useState(true) // Strip-clicked turn: chart switches to turn granularity and scroll-centers that turn's bar, then clears via onFocusTurnHandled. const [focusTurn, setFocusTurn] = useState(null) // Chat → Context jump: the assistant-action relay's one-shot request, held until the projection data is in, then resolved into a @@ -473,6 +476,15 @@ export function makeContextView( onClick={() => { setAdaptive(on => !on) }} >{t('trend.adaptive')} + {/* The duration overlay switch rides with the other title-adjacent toggles: each bar's step + active time as a curve over the bars, read off the right-hand quartile axis. */} + + + {focusCat !== null ? {t('trend.focus', { cat: kit.catLabel(focusCat) })} : null} @@ -524,6 +536,7 @@ export function makeContextView( hoverCat={trendHoverCat} focusCat={focusCat} adaptive={adaptive} + durationCurve={durationCurve} dna={dnaBands} onPickBand={revealBand} onSelect={setSelectedSeq} diff --git a/src/client/components/trendChart.tsx b/src/client/components/trendChart.tsx index 544c15b..b66a120 100644 --- a/src/client/components/trendChart.tsx +++ b/src/client/components/trendChart.tsx @@ -35,6 +35,13 @@ export interface TrendChartProps { * on screen. Off = the whole retained log scales the axis, the historical behavior. */ adaptive?: boolean + /** + * Duration overlay (the trend card's title-adjacent toggle): each bar's step active time (`activeMs`) + * drawn as a curve over the bars, read off the quartile axis on the chart's right. The curve plots the + * raw figure in every mode (total/delta/DNA) — duration is not a composition category — and follows the + * same scale window as the bars (whole log, or the visible window when adaptive is on). + */ + durationCurve?: boolean /** * DNA mode (the trend card's toggle): per bar, its assembled context decomposed into ONE band per item in * read order (dna.ts), aligned with `requests` by index. Non-null draws every bar as a single-gradient @@ -52,18 +59,24 @@ export interface TrendChartProps { /** * Collapse per-step requests into one bar per turn — each turn is represented by its LAST step's record, tagged `stepCount` for the bar's - * column width; the log keeps one turn's requests consecutive, so a run of equal turns collapses to its final record. + * column width; the log keeps one turn's requests consecutive, so a run of equal turns collapses to its final record. The duration + * overlay's `activeMs` is the run's steps SUMMED (the turn's total step active time), absent only when no step of the run carried one. */ export function aggregateByTurn(requests: RequestRecord[]): RequestRecord[] { const out: RequestRecord[] = [] let runSteps = 0 + let runActiveMs = 0 + let runHasActiveMs = false for (const req of requests) { const last = out.length > 0 ? out[out.length - 1] : null if (last !== null && (last.turn ?? 0) === (req.turn ?? 0)) { runSteps++ - out[out.length - 1] = { ...req, stepCount: runSteps } + if (req.activeMs !== undefined) { runActiveMs += req.activeMs; runHasActiveMs = true } + out[out.length - 1] = { ...req, stepCount: runSteps, ...(runHasActiveMs ? { activeMs: runActiveMs } : {}) } } else { runSteps = 1 + runActiveMs = req.activeMs ?? 0 + runHasActiveMs = req.activeMs !== undefined out.push({ ...req, stepCount: 1 }) } } @@ -113,7 +126,7 @@ export function jumpTargetOf(requests: RequestRecord[], seq: number): RequestRec } export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactElement { - const { t, fmt, eventLabel, eventAt, catLabel } = kit + const { t, fmt, fmtDuration, eventLabel, eventAt, catLabel } = kit const CHART_H = 112 // Quarter-mark label tops for the axis (mirrored to .lc-axis-q1/.lc-axis-q3 in trendChart.css): chart top 18 @@ -189,11 +202,12 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE return out } - /** The visible window's own maxima (adaptive scale): the total-mode peak, and the delta arms' up/down sums. */ + /** The visible window's own maxima (adaptive scale): the total-mode peak, the delta arms' up/down sums, the duration overlay's peak. */ interface VisibleMax { total: number up: number down: number + activeMs: number } interface ChartBarProps { @@ -498,6 +512,9 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE const dnaOn = dnaBands !== null const delta = props.mode === 'delta' const dnaDeltaOn = dnaOn && delta + // The duration overlay (durationCurve): each bar's step active time as a curve over the bars, + // read off the right-hand quartile axis. + const durationOn = props.durationCurve === true // An unrecognized focus key degrades to the unfocused chart instead of plotting an empty axis. const focus = !dnaOn && props.focusCat !== null && props.focusCat !== undefined && CATS.some(c => c.key === props.focusCat) ? props.focusCat @@ -537,6 +554,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE let total = 0 let up = 0 let down = 0 + let activeMs = 0 // The column holding the left edge through the one holding the right edge; the per-column test then drops // the neighbour whose column falls in the 2px gap just outside the viewport. const from = Math.max(0, Math.floor(sl / pitch)) @@ -545,6 +563,11 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE const col = i * pitch if (col >= vr || col + BAR_W <= sl) continue const req = requests[i] + // The duration overlay's window maximum rides the same pass, so the right axis + // follows the scroll exactly like the bars' axis (measured whether or not the + // overlay is currently on — a toggle-on then never re-measures). + const m = req.activeMs ?? 0 + if (m > activeMs) activeMs = m if (dnaDeltaOn && dnaDeltas !== null) { // DNA+delta: the band arms' own sums — the same figures the whole-log loop takes. let bu = 0 @@ -568,9 +591,9 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE total = req.total } } - setVisMax(prev => prev !== null && prev.total === total && prev.up === up && prev.down === down + setVisMax(prev => prev !== null && prev.total === total && prev.up === up && prev.down === down && prev.activeMs === activeMs ? prev - : { total, up, down }) + : { total, up, down, activeMs }) } // Whole-log maxima: the axis when adaptive is off, and the fallback for a delta window with no change at all // (it carries no scale of its own). @@ -613,6 +636,41 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE maxTotal = Math.max(1, visMax.total) } } + // The duration overlay's scale (the right-hand axis): mode-independent — the curve always plots the + // bar's raw active time, whether the bars read totals, deltas, or DNA bands. Whole-log by default; the + // visible window's own maximum when adaptive is on (measured on every scroll, durationCurve or not). + let maxActiveMs = 0 + if (durationOn) { + for (const req of requests) { + const m = req.activeMs ?? 0 + if (m > maxActiveMs) maxActiveMs = m + } + if (adaptive && visMax !== null) maxActiveMs = visMax.activeMs + } + // The overlay geometry: ONE polyline per contiguous run of bars carrying a duration (a bar without + // `activeMs` breaks the line instead of faking a value) and a dot for an isolated single point, which a + // one-point polyline would render invisible. X centers on each bar's column, y rides the right axis's + // floor-anchored scale; the svg rides the scrolling content, so scrolling needs no re-computation. + const durationRuns: string[] = [] + const durationDots: [number, number][] = [] + if (durationOn) { + const scale = CHART_H / Math.max(1, maxActiveMs) + let run: [number, number][] = [] + const flushRun = (): void => { + if (run.length >= 2) durationRuns.push(run.map(p => `${p[0]},${p[1]}`).join(' ')) + else if (run.length === 1) durationDots.push(run[0]) + run = [] + } + for (let i = 0; i < requests.length; i++) { + const m = requests[i].activeMs + if (m === undefined) { + flushRun() + continue + } + run.push([i * (BAR_W + BAR_GAP) + BAR_W / 2, Math.round((CHART_H - m * scale) * 100) / 100]) + } + flushRun() + } // The zero line splits the bar area PROPORTIONALLY to the larger side, so the px-per-token scale // is identical above and below it — a compaction's downward bar reads honestly against a growth bar. const span = Math.max(1, maxUp + maxDown) @@ -788,12 +846,12 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE return containHorizontalOverscroll(el) }, []) - // Compact 2-row hover tooltip, shown instantly by the custom `.lc-chart-tip` (the native title is delayed): + // Compact hover tooltip, shown instantly by the custom `.lc-chart-tip` (the native title is delayed): // identity and the bar's total — the SAME value the bar height and axis are scaled against (the fold's // heuristic figure, matching every other card). Identity phrasing follows the granularity — // turn bars always speak TURN (the aggregate's step count, singular for a 1-step turn; a record missing // stepCount degrades to that too), step bars carry the step index plus the turn's step total. Delta swaps - // the metric row for the net. + // the metric row for the net, and the duration overlay appends a third row while it is on. const stepsOf = useMemo(() => turnStepsOf(props.requests), [props.requests]) const hoveredIdx = props.hoveredSeq !== null ? requests.findIndex(r => r.seq === props.hoveredSeq) : -1 const hoveredReq = hoveredIdx >= 0 ? requests[hoveredIdx] : null @@ -836,7 +894,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE } return slices }, [dnaOn, dnaHit, dnaDeltaOn, dnaDeltas, dnaBands, downPx, deltaScale, maxTotal]) - const tipRowsOf = (req: RequestRecord): [string, string] => { + const tipRowsOf = (req: RequestRecord): string[] => { const n = req.stepCount ?? 1 const head = props.granularity === 'turn' ? (n > 1 ? t('tip.turn', { t: req.turn ?? 0, n }) : t('tip.turn1', { t: req.turn ?? 0 })) @@ -846,18 +904,23 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE // unless a band sits under the pointer (a hover on the bar's padding above the strip falls // through to the mode's own row: the delta's net, else the total). const band = hitBand ?? hoveredDelta + let metric: string if (band !== null) { - return [head, t('trend.dnaItem', { label: dnaBaseLabel(band, t, catLabel), n: dnaDeltaOn ? fmtSigned(band.tokens) : fmt(band.tokens) })] - } - if (delta) { + metric = t('trend.dnaItem', { label: dnaBaseLabel(band, t, catLabel), n: dnaDeltaOn ? fmtSigned(band.tokens) : fmt(band.tokens) }) + } else if (delta) { /* v8 ignore next 1 -- delta mode only receives records from deltaOf, which always assigns net; the fallback is defensive. */ - return [head, t('tip.delta', { n: fmtSigned(req.net ?? 0) })] + metric = t('tip.delta', { n: fmtSigned(req.net ?? 0) }) + } else { + // Focused: the metric row IS the focused category's figure, so the tip names it instead of claiming a total. + metric = focus !== null + ? t('tip.cat', { cat: catLabel(focus), n: fmt(req.total) }) + : t('tip.total', { n: fmt(req.total) }) } - // Focused: the metric row IS the focused category's figure, so the tip names it instead of claiming a total. - return [head, focus !== null - ? t('tip.cat', { cat: catLabel(focus), n: fmt(req.total) }) - : t('tip.total', { n: fmt(req.total) })] + const rows = [head, metric] + // The overlay's own reading: the bar's step active time, only while the curve is on. + if (durationOn && req.activeMs !== undefined) rows.push(t('tip.duration', { n: fmtDuration(req.activeMs) })) + return rows } // Column center (content px) of the currently hovered bar, for syncTip reads outside the render pass. @@ -983,6 +1046,21 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE /> ))} {dnaOn ? : null} + {/* The duration overlay: one svg riding the scrolling content (top 18px padding band excluded), + so it scrolls with the bars and needs no scroll handler. z-index parity with the DNA + highlight but later in DOM order, so the curve reads above the translucent slices; a bar + without `activeMs` breaks the line instead of faking a point. */} + {durationOn && (durationRuns.length > 0 || durationDots.length > 0) ? ( + + ) : null} {/* Turn strip: one COLOR BLOCK per turn spanning exactly its bars' columns, so the partition reads at a glance and lines up with the steps; hovering a block highlights that turn's bars and vice versa — one shared hover-only state. @@ -1006,7 +1084,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE })} - {/* Compact 2-row hover tooltip (identity / bar total), shown instantly by the custom `.lc-chart-tip` + {/* Compact hover tooltip (identity / bar total), shown instantly by the custom `.lc-chart-tip` (the native title is delayed); the per-category breakdown lives in the detail panel below. It floats ABOVE the plot (CSS bottom anchoring) so it never covers the bars, is capped at the wrapper's width and wrapped, and is positioned imperatively over its bar's visible slice (syncTip) so scrolling keeps @@ -1015,6 +1093,19 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
{tipRowsOf(hoveredReq).map((row, i) => {row})}
) : null} + {/* The duration overlay's own quartile axis rides the chart's RIGHT edge (the bars' token axis + keeps the left): the same five fixed marks, formatted as durations, floor-anchored in every + mode — the curve plots raw active time even while the bars read deltas. Only while the overlay + is on. */} + {durationOn ? ( +
+ {fmtDuration(maxActiveMs)} + {fmtDuration(Math.round(maxActiveMs * 3 / 4))} + {fmtDuration(Math.round(maxActiveMs / 2))} + {fmtDuration(Math.round(maxActiveMs / 4))} + {'0'} +
+ ) : null} ) } diff --git a/src/client/i18n.ts b/src/client/i18n.ts index da3112a..3b15ecb 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -94,6 +94,8 @@ export const DICT_ZH: Record = { 'trend.focus': '仅展示 {cat}', 'trend.adaptive': '自适应', 'trend.adaptiveHint': '按当前可视范围内的柱子重新缩放高度,滚动时随之变化', + 'trend.duration': '耗时', + 'trend.durationTip': '在柱子上叠加每步的活跃耗时曲线(步骤开始至结束,含模型与工具调用,不含等待你决策或回答的时间),按图中右侧的四分位坐标轴读数;轮次粒度下为一轮各步耗时之和', 'trend.dna': 'DNA 模式', 'trend.dnaTip': '把每根柱子画成该次请求的上下文指纹:按模型读取顺序逐条铺色,配合增量模式逐条展示每次请求的增减;悬停查看条目,点击让上下文浏览器定位到该条目,同一条目在所有柱子中亮出其存续变化', 'trend.dnaItem': '{label}:{n}', @@ -229,6 +231,7 @@ export const DICT_ZH: Record = { 'tip.total': '合计 ≈ {n}', 'tip.cat': '{cat} ≈ {n}', 'tip.delta': 'Δ {n}', + 'tip.duration': '耗时 {n}', 'ev.compaction': '压缩上下文(摘要替换 {n} 条消息)', 'ev.prune': '剪枝工具输出', 'ev.skill': 'Skill 注入({name})', @@ -495,6 +498,8 @@ export const DICT_EN: Record = { 'trend.focus': 'Showing {cat} only', 'trend.adaptive': 'Adaptive', 'trend.adaptiveHint': 'Rescale bar heights to the visible range, following the scroll', + 'trend.duration': 'Duration', + 'trend.durationTip': 'Overlay each step’s active time (step start to end — model and tool calls, excluding waits for your decisions or answers) as a curve over the bars, read off the quartile axis on the chart’s right; turn granularity sums a turn’s steps', 'trend.dna': 'DNA Mode', 'trend.dnaTip': 'Draw each bar as that request’s context fingerprint — one band per item in the order the model reads it; with Delta, each band shows that item’s change against the previous bar. Hover for the item, click to reveal it in the Context browser, and every bar lights the same item’s lifetime', 'trend.dnaItem': '{label}: {n}', @@ -630,6 +635,7 @@ export const DICT_EN: Record = { 'tip.total': 'Total ≈ {n}', 'tip.cat': '{cat} ≈ {n}', 'tip.delta': 'Δ {n}', + 'tip.duration': 'Duration {n}', 'ev.compaction': 'Context compacted (summary replaced {n} messages)', 'ev.prune': 'Tool output pruned', 'ev.skill': 'Skill injected ({name})', diff --git a/src/client/styles/trendChart.css b/src/client/styles/trendChart.css index bf47ed0..da1eca6 100644 --- a/src/client/styles/trendChart.css +++ b/src/client/styles/trendChart.css @@ -2,6 +2,11 @@ .lc-chartrow { display: flex; gap: 6px; align-items: stretch; } .lc-axis { position: relative; width: 40px; height: 150px; padding-top: 18px; box-sizing: border-box; color: var(--dsw-alias-label-secondary); font-size: 11px; } .lc-axis span { position: absolute; right: 0; line-height: 1; } +/* The duration overlay's right-hand axis: same fixed marks as the left, anchored to the LEFT edge of its + column instead (labels stream rightward away from the plot), and kept narrow — duration labels + (fmtDuration: "850ms" / "3.2s" / "1m5s") stay short. */ +.lc-axis-r { width: 34px; flex: none; } +.lc-axis-r span { left: 0; right: auto; } .lc-axis-top { top: 13px; } /* Quarter-mark labels (chart top 18 + ¼ / ¾ of the 112px bar area, minus half the 11px label box); tops are mirrored as Q3_TOP/Q1_TOP in trendChart.tsx for the delta mode's overlap check. */ @@ -112,6 +117,17 @@ .lc-dna-hl { position: absolute; top: 18px; right: 2px; bottom: 0; left: 2px; z-index: 1; display: flex; gap: 2px; align-items: stretch; pointer-events: none; } .lc-dna-cell { position: relative; width: 14px; flex: none; } .lc-dna-slice { position: absolute; left: 0; width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-brand-primary); border-radius: 1px; background: color-mix(in srgb, var(--dsw-alias-brand-primary) 26%, transparent); } +/* + The duration overlay (trendChart.tsx durationCurve): one svg absolutely positioned over the bar area (the 18px + top band and 2px side padding mirrored from .lc-chart), sized in content px so it scrolls with the bars. + Pointer-transparent; z-index 1 with DOM order after .lc-dna-hl keeps the curve above the bars and the + highlight slices, below the ✂ markers and step flags (z-index 2). `overflow: visible` lets an adaptive-mode + spike off the visible window poke above the plot exactly like an off-window bar's stack does. +*/ +.lc-duration { position: absolute; top: 18px; left: 2px; z-index: 1; overflow: visible; pointer-events: none; } +.lc-duration polyline, .lc-duration circle { fill: none; stroke: var(--dsw-alias-brand-primary); stroke-width: 1.5; stroke-linejoin: round; stroke-linecap: round; } +.lc-duration circle { fill: var(--dsw-alias-brand-primary); } +.lc-chart-dim .lc-duration { opacity: 0.35; } .lc-bar-marker { position: absolute; top: -16px; left: 50%; transform: translateX(-50%); z-index: 2; font-size: 11px; color: var(--dsw-alias-state-warn-primary); } /* Step flags (step granularity only): every 5th bar plants a flag at its RIGHT edge — a 1px pole rising from diff --git a/src/host/fold.ts b/src/host/fold.ts index 6f7aa1c..4b7cff9 100644 --- a/src/host/fold.ts +++ b/src/host/fold.ts @@ -170,6 +170,36 @@ export interface TimelineState { time: number firstToken?: number } + /** + * The open step's CLOSED user-wait intervals (real log instants), pushed + * replace-style as each wait settles: an approval decision (its asked → + * decided pair closes via `stepApprovals`) and an `ask_user_question` + * call's whole tool window (its body is the answer wait itself, in both + * the blocking and the timed schema's foreground phase). `step/end` + * subtracts their clamped union from the step window to price the + * request's `activeMs`, then DELETES the slot. Same arm/remove lifecycle + * as `stepStart` — never written in place, so no CloneKey covers it. + */ + stepWaits?: { start: number; end: number }[] + /** + * The open step's PENDING approval waits (request id → its `approval/asked` + * instant), armed only while a step is open and disarmed by the matching + * `approval/decided`. A crash mid-decision leaves the pair dangling — + * resume repair appends no synthetic decision — so `step/end` closes the + * survivors at its own instant. One record, not a map: pending approvals + * are a handful at most, and the rebuild-on-change copy stays trivial. + * Deleted at `step/start` (a superseded step's leftovers) and `step/end`. + */ + stepApprovals?: Record + /** + * The seq of the request record committed by the open step's + * `assistant/message` — the back-pointer `step/end` stamps `activeMs` + * onto. It stays the requests TAIL until then (nothing pushes between a + * step's message and its end, and the turn trim only drops the oldest), + * so the stamp is an O(1) tail write guarded by the seq. Same arm/remove + * lifecycle as `stepStart`. + */ + stepRequestSeq?: number /** * The timing strip's painted spans (shared/types.ts TimingSpan): every * completed step's time slices in log order, stamped with their REAL @@ -1041,10 +1071,15 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds // slot value), even over an un-consumed predecessor — sequential logs // 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). + // window and would clamp to nothing at the flush regardless). The + // wait/approval/request back-pointer slots die with it (their waits + // predate the new window the same way). const s = ensure([]) s.stepStart = { time: event.time } delete s.stepSpans + delete s.stepWaits + delete s.stepApprovals + delete s.stepRequestSeq break } case 'step/end': { @@ -1052,8 +1087,31 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds // refold) — nothing to price, and the state must stay reference-equal. const start = state.stepStart if (start === undefined) return state - const s = ensure(['timing']) + const s = ensure(state.stepRequestSeq !== undefined ? ['timing', 'requests'] : ['timing']) ensureTiming(s).wallMs += durOf(start.time, event.time) + // The step's ACTIVE time prices the trend chart's duration overlay: + // the whole step window (the model call plus its tool runs) minus the + // clamped union of the step's user waits (see TimelineState.stepWaits) + // — approval decisions, including any pair left dangling by a crash + // (closed at this end instant), and `ask_user_question` answer + // windows. The stamp lands on the step's committed request record + // (the requests tail while the seq guard holds — a hostile refold + // that lost the row just skips it). + const waits: { start: number; end: number }[] = [...(state.stepWaits ?? [])] + for (const id in state.stepApprovals) waits.push({ start: state.stepApprovals[id], end: event.time }) + waits.sort((a, b) => (a.start - b.start) || (a.end - b.end)) + let waitMs = 0 + let waitCursor = start.time + for (const wait of waits) { + const from = Math.max(waitCursor, wait.start) + const to = Math.min(wait.end, event.time) + if (to > from) { waitMs += to - from; waitCursor = to } + } + const activeMs = durOf(start.time, event.time) - waitMs + const last = state.requests.at(-1) + if (last !== undefined && last.seq === state.stepRequestSeq) { + s.requests[s.requests.length - 1] = { ...last, activeMs } + } // 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 @@ -1084,9 +1142,39 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds // would break the plain-JSON persisted-state precondition. delete s.stepStart delete s.stepSpans + delete s.stepWaits + delete s.stepApprovals + delete s.stepRequestSeq bumpDetailRev(s) break } + case 'approval/asked': { + // Arm the approval's wait interval (see TimelineState.stepApprovals): + // the harness awaits the user's decision inline between this event + // and its `approval/decided` — pure user time inside the step's tool + // window. Only an open step owns one (an out-of-step ask prices + // nothing here and must leave the state reference-equal). + const id = data?.id + if (state.stepStart === undefined || typeof id !== 'string') return state + const s = ensure([]) + s.stepApprovals = { ...state.stepApprovals, [id]: event.time } + break + } + case 'approval/decided': { + // Close the matching wait into `stepWaits`; a decided without its + // armed asked (an out-of-step pair, a refold window) prices nothing. + const id = data?.id + const open = state.stepApprovals + const askedAt = typeof id === 'string' ? open?.[id] : undefined + if (open === undefined || askedAt === undefined) return state + const s = ensure([]) + s.stepWaits = [...(state.stepWaits ?? []), { start: askedAt, end: event.time }] + const kept: Record = {} + for (const k in open) if (k !== id) kept[k] = open[k] + if (Object.keys(kept).length > 0) s.stepApprovals = kept + else delete s.stepApprovals + break + } case 'user/message': case 'developer/message': { // The V3 dependency's deriveEventMessage has no developer case; @@ -1162,6 +1250,14 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds // tally): the result only carries its tool name when it pairs with // the armed call, so an unpaired/foreign one counts nothing. if (node.tool === ASK_USER_TOOL) s.humanInputs = (s.humanInputs ?? 0) + 1 + // The Q&A tool's whole window is the user's answer wait — its body + // only awaits the reply (the blocking schema outright, the timed one + // through its foreground phase) — so the open step books it as a wait + // interval for the `activeMs` subtraction at `step/end`. Same pairing + // authority as the tally above: an unpaired result waits nothing. + if (pendingEntry !== undefined && pendingEntry.name === ASK_USER_TOOL && state.stepStart !== undefined) { + s.stepWaits = [...(state.stepWaits ?? []), { start: pendingEntry.start, end: event.time }] + } // The file-op derivation (shared/fileOps.ts): the armed call's // arguments + the result's presentation meta. Unpaired results book // nothing (parity with the surface node's missing tool label). @@ -1242,6 +1338,11 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds // materialize an `undefined` property (plain-JSON precondition, the trap that broke the projection cache here). if (data && typeof data.turn === 'number') record.turn = data.turn if (data && typeof data.step === 'number') record.step = data.step + // Arm the step's request back-pointer (see TimelineState.stepRequestSeq): + // `step/end` stamps the step's active time onto this record — the trend + // chart's duration overlay. A message folding without an open step slot + // arms nothing and its record never gains the field. + if (state.stepStart !== undefined) s.stepRequestSeq = record.seq // The provider-reported output tokens, hoisted for the timing seat // below: the throughput pairing needs this exact figure, and `null` // must mean "the message carried no readable output bucket" — the diff --git a/src/host/timeline.ts b/src/host/timeline.ts index e55069e..891371a 100644 --- a/src/host/timeline.ts +++ b/src/host/timeline.ts @@ -70,6 +70,11 @@ const requestRecordSchema = z.object({ skill: z.number().int().nonnegative().optional(), cacheRead: z.number().int().nonnegative().optional(), output: z.number().int().nonnegative().optional(), + /** + * The request's step active milliseconds (see RequestRecord.activeMs). + * Optional so a record whose step never closed (the live tail) still parses. + */ + activeMs: z.number().nonnegative().optional(), stepCount: z.number().int().positive().optional(), }).strict() @@ -268,6 +273,9 @@ const timelineStateSchema = z.object({ time: z.number(), firstToken: z.number().optional(), }).strict().optional(), + stepWaits: z.array(z.object({ start: z.number(), end: z.number() }).strict()).optional(), + stepApprovals: z.record(z.string(), z.number()).optional(), + stepRequestSeq: z.number().optional(), callNames: z.record(z.string(), z.object({ name: z.string(), start: z.number(), argsRaw: z.string().optional() }).strict()), pendingShadowedSeqs: z.array(z.number()).optional(), pendingShadowEventSeq: z.number().optional(), @@ -448,7 +456,13 @@ export function createContextTimelineDefinition(config: Config, slim: () => bool // 25: developer/message tool-registry changes now enter the injection // surface and request history. Later events cannot recover the omitted // nodes or tokens, so cached rows refold from the durable log. - stateVersion: 25, + // + // 26: the per-request step active time (`activeMs`) joined request + // records — the trend chart's duration overlay. Later events cannot + // backfill it for requests already folded (the v9 `cacheRead` precedent), + // so cached rows refold from the durable log; the v20 warm-up rebuilds + // idle sessions' rows the first time the dashboard opens. + stateVersion: 26, } return definition } diff --git a/src/shared/types.ts b/src/shared/types.ts index 53ccdc6..2722253 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -662,6 +662,15 @@ export interface RequestRecord { */ cacheRead?: number output?: number + /** + * The request's step ACTIVE milliseconds (`step/start` → `step/end`: the + * model call plus its tool runs) minus the in-step user waits — approval + * decisions and `ask_user_question` answer windows — the trend chart's + * duration overlay. Stamped at `step/end` onto the step's committed record; + * absent while that step is still open (the live tail) or when the step + * closed without one (an unpaired/hostile log). + */ + activeMs?: number /** * Turn-mode aggregate marker, set by the Client's aggregateByTurn (one bar * per turn shows its LAST step's record). The Host never sets it. diff --git a/tests/client/components/contextView.spec.ts b/tests/client/components/contextView.spec.ts index cb0c8f5..e13cddb 100644 --- a/tests/client/components/contextView.spec.ts +++ b/tests/client/components/contextView.spec.ts @@ -495,6 +495,44 @@ describe('ContextView — interactions', () => { await m.unmount() }) + test('the duration toggle overlays the active-time curve with a right-hand quartile axis', async () => { + const View = makeView(new TestClientCtx()) + const m = await mount(h(View, { + sessionId: 'sv-ms-curve', + useProjection: projectionsFor(timeline({ + requests: [ + { seq: 2, turn: 1, step: 1, time: T0 + 1000, system: 100, tools: 200, user: 10, inject: 0, assistant: 20, tool: 0, total: 330, activeMs: 1000 }, + { seq: 4, turn: 1, step: 2, time: T0 + 3000, system: 100, tools: 200, user: 10, inject: 0, assistant: 60, tool: 30, total: 400, activeMs: 2000 }, + ], + })), + })) + + // The switch rides the title's right, next after the adaptive one. + const msBtn = () => buttonByText(m.container, DICT_EN['trend.duration']) + const group = msBtn().parentElement as HTMLElement + assert.ok(group.className.includes('lc-trend-duration')) + assert.equal(group.getAttribute('title'), DICT_EN['trend.durationTip']) + const adaptiveGroup = buttonByText(m.container, DICT_EN['trend.adaptive']).parentElement as HTMLElement + assert.equal(group.previousElementSibling, adaptiveGroup, 'Duration rides right of the adaptive switch') + // The overlay mounts ON by default: the curve spans the two stamped bars and the right axis reads + // the 2s peak (jsdom's zero-width viewport keeps the whole-log scale, adaptive off anyway). + assert.ok(msBtn().className.includes('lc-gran-on'), 'on at mount') + assert.equal(queryAll(m.container, '.lc-duration polyline').length, 1) + assert.equal(text(query(m.container, '.lc-axis-r .lc-axis-top')), '2.0s') + assert.equal(text(query(m.container, '.lc-axis-r .lc-axis-mid')), '1.0s') + + // Off: the curve and its axis drop away; back on they return. Mount-local, never written back. + await click(msBtn()) + assert.ok(!msBtn().className.includes('lc-gran-on')) + assert.equal(queryAll(m.container, '.lc-duration').length, 0) + assert.equal(queryAll(m.container, '.lc-axis-r').length, 0) + + await click(msBtn()) + assert.equal(queryAll(m.container, '.lc-duration polyline').length, 1) + assert.equal(queryAll(m.container, '.lc-axis-r').length, 1) + await m.unmount() + }) + test('the trend card\'s DNA toggle fingerprints the bars, combinable with Delta', async () => { const m = await mountRich('sv-trend-dna') const dnaBtn = () => buttonByText(m.container, DICT_EN['trend.dna']) diff --git a/tests/client/components/trendChart.spec.ts b/tests/client/components/trendChart.spec.ts index aaced43..2ff2223 100644 --- a/tests/client/components/trendChart.spec.ts +++ b/tests/client/components/trendChart.spec.ts @@ -691,6 +691,104 @@ describe('TrendChart adaptive scale (the title-adjacent toggle)', () => { }) }) +describe('TrendChart duration overlay (durationCurve)', () => { + /** The overlay's y for a duration at the whole-log scale (the component's own rounding). */ + const activeMsY = (activeMs: number, maxActiveMs: number): number => Math.round((CHART_H - activeMs * (CHART_H / Math.max(1, maxActiveMs))) * 100) / 100 + + test('off by default: no curve, no right-hand axis', async () => { + const m = await mount(h(TrendChart, propsOf([req(1, { activeMs: 1000 }), req(2, { activeMs: 2000 })]))) + assert.equal(queryAll(m.container, '.lc-duration').length, 0) + assert.equal(queryAll(m.container, '.lc-axis-r').length, 0) + await m.unmount() + }) + + test('on: the curve rides the bar columns and the right axis carries duration quartiles', async () => { + const reqs = [req(1, { activeMs: 1000 }), req(2, { activeMs: 2000 }), req(3, { activeMs: 4000 })] + const m = await mount(h(TrendChart, propsOf(reqs, { durationCurve: true }))) + const svg = query(m.container, '.lc-duration') + assert.equal(svg.getAttribute('width'), String(3 * BAR_CELL - 2)) + assert.equal(svg.getAttribute('height'), String(CHART_H)) + const lines = queryAll(svg, 'polyline') + assert.equal(lines.length, 1, 'one contiguous run of stamped bars') + assert.equal( + lines[0].getAttribute('points'), + `7,${activeMsY(1000, 4000)} 23,${activeMsY(2000, 4000)} 39,${activeMsY(4000, 4000)}`, + ) + assert.equal(queryAll(svg, 'circle').length, 0) + // The right-hand axis: max, ¾, ½, ¼, 0 — formatted as durations. + const axis = query(m.container, '.lc-axis-r') + assert.equal(query(axis, '.lc-axis-top').textContent, '4.0s') + assert.equal(query(axis, '.lc-axis-q3').textContent, '3.0s') + assert.equal(query(axis, '.lc-axis-mid').textContent, '2.0s') + assert.equal(query(axis, '.lc-axis-q1').textContent, '1.0s') + assert.equal(query(axis, '.lc-axis-bot').textContent, '0') + await m.unmount() + }) + + test('a bar without ms breaks the line; an isolated single point draws a dot', async () => { + const reqs = [req(1, { activeMs: 1000 }), req(2), req(3, { activeMs: 2000 }), req(4, { activeMs: 4000 })] + const m = await mount(h(TrendChart, propsOf(reqs, { durationCurve: true }))) + const svg = query(m.container, '.lc-duration') + const lines = queryAll(svg, 'polyline') + assert.equal(lines.length, 1, 'the trailing pair re-joins after the gap') + assert.equal(lines[0].getAttribute('points'), `39,${activeMsY(2000, 4000)} 55,${activeMsY(4000, 4000)}`) + const dots = queryAll(svg, 'circle') + assert.equal(dots.length, 1, 'a one-point run would be an invisible polyline') + assert.equal(dots[0].getAttribute('cx'), '7') + assert.equal(dots[0].getAttribute('cy'), String(activeMsY(1000, 4000))) + await m.unmount() + }) + + test('turn granularity plots each turn\'s summed active time', async () => { + const agg = aggregateByTurn([ + req(1, { turn: 1, step: 0, activeMs: 1000 }), + req(2, { turn: 1, step: 1, activeMs: 500 }), + req(3, { turn: 2, step: 0, activeMs: 2000 }), + ]) + const m = await mount(h(TrendChart, propsOf(agg, { granularity: 'turn', durationCurve: true }))) + const lines = queryAll(query(m.container, '.lc-duration'), 'polyline') + assert.equal(lines[0].getAttribute('points'), `7,${activeMsY(1500, 2000)} 23,${activeMsY(2000, 2000)}`) + assert.equal(query(m.container, '.lc-axis-r .lc-axis-top').textContent, '2.0s') + await m.unmount() + }) + + test('delta mode keeps the floor-anchored duration axis; the hover tip appends the duration row', async () => { + const reqs = [req(1, { activeMs: 1000 }), req(2, { activeMs: 2000 })] + const m = await mount(h(TrendChart, propsOf(reqs, { mode: 'delta', durationCurve: true, hoveredSeq: 1 }))) + assert.equal(query(m.container, '.lc-axis-r .lc-axis-top').textContent, '2.0s') + assert.equal(queryAll(query(m.container, '.lc-duration'), 'polyline').length, 1) + const rows = queryAll(query(m.container, '.lc-chart-tip'), 'span').map(r => r.textContent) + assert.equal(rows.length, 3) + assert.equal(rows[2], kit.t('tip.duration', { n: '1.0s' })) + // Off again: the tip drops the duration row. + await m.update(h(TrendChart, propsOf(reqs, { mode: 'delta', hoveredSeq: 1 }))) + assert.equal(queryAll(query(m.container, '.lc-chart-tip'), 'span').length, 2) + await m.unmount() + }) + + test('adaptive scale rescales the curve to the visible window like the bars', async () => { + const reqs: RequestRecord[] = [] + for (let i = 0; i < 30; i++) reqs.push(req(i + 1, { turn: 1, step: i, activeMs: i === 0 ? 9000 : 1000 })) + const m = await mount(h(TrendChart, propsOf(reqs, { adaptive: true, durationCurve: true }))) + await flush() + const scroll = query(m.container, '.lc-chart-scroll') + // Anchored at the newest bars (scrollLeft 80): the 9s spike at bar 0 is off screen. + assert.equal(query(m.container, '.lc-axis-r .lc-axis-top').textContent, '1.0s') + await scrollTo(scroll, 0) + assert.equal(query(m.container, '.lc-axis-r .lc-axis-top').textContent, '9.0s') + await m.unmount() + }) + + test('no stamped durations at all: the axis shows the scale floor, the curve draws nothing', async () => { + const m = await mount(h(TrendChart, propsOf([req(1), req(2)], { durationCurve: true }))) + assert.equal(queryAll(m.container, '.lc-duration').length, 0) + const axis = query(m.container, '.lc-axis-r') + assert.equal(query(axis, '.lc-axis-top').textContent, '—') + assert.equal(query(axis, '.lc-axis-bot').textContent, '0') + await m.unmount() + }) +}) + describe('TrendChart markers', () => { test('compaction/prune markers render the ✂ glyph with a positioned or bare title', async () => { const reqs = [req(1, { turn: 1, step: 0 }), req(2, { turn: 1, step: 1 }), req(3, { turn: 2, step: 0 })] @@ -1099,6 +1197,22 @@ describe('aggregateByTurn', () => { test('an empty history aggregates to nothing', () => { assert.deepEqual(aggregateByTurn([]), []) }) + + test('the duration overlay\'s ms sums across a turn\'s steps; absent only when no step carried one', () => { + const agg = aggregateByTurn([ + req(1, { turn: 1, step: 0, activeMs: 1000 }), + req(2, { turn: 1, step: 1, activeMs: 2500 }), + req(3, { turn: 2, step: 0, activeMs: 800 }), + ]) + assert.equal(agg[0].activeMs, 3500, 'the turn bar carries its steps\' summed active time') + assert.equal(agg[1].activeMs, 800, 'a single-step turn keeps its own figure') + + // A step without a stamp contributes nothing but does not hide the run's known share. + const partial = aggregateByTurn([req(1, { turn: 1, activeMs: 1000 }), req(2, { turn: 1 })]) + assert.equal(partial[0].activeMs, 1000) + const none = aggregateByTurn([req(1, { turn: 1 }), req(2, { turn: 1 })]) + assert.ok(!('ms' in none[0]), 'no fabricated zero when no step reported') + }) }) describe('turnStepsOf', () => { diff --git a/tests/host/compat/registry.spec.ts b/tests/host/compat/registry.spec.ts index e7a1cdc..9e55404 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' ? 25 : 1) + assert.equal(row.ver, key === 'contextTimeline' ? 26 : 1) assert.equal(row.seq, 12) } // And the write-gate equivalent on every intermediate state of a fresh fold. diff --git a/tests/host/fold-timing.spec.ts b/tests/host/fold-timing.spec.ts index e4aab04..39eeaa0 100644 --- a/tests/host/fold-timing.spec.ts +++ b/tests/host/fold-timing.spec.ts @@ -43,6 +43,22 @@ describe('timing — step lifecycle', () => { assert.equal(state.stepStart, undefined, 'step/end consumes the pending slot') }) + test('the request record carries the step\'s active time (the trend overlay\'s activeMs)', () => { + // step(1, 10_000, 2_000): step/start at 10_000, message at 12_000, step/end + // at 16_000 — no user waits, so the whole step window prices active. + const drive = driveTimeline(step(1, 10_000, 2_000, { tokenMs: 600 })) + assert.equal(drive.state.requests.length, 1) + assert.equal(drive.state.requests[0].activeMs, 6_000, 'step start → step end') + // The stamp lands at step/end: the live tail after the message carries none. + assert.ok(!('activeMs' in drive.states[2].requests[0]), 'no stamp before step/end') + }) + + test('a message folded without an open step slot carries no activeMs', () => { + const { state } = driveTimeline([assistantMessage(1, {})]) + assert.equal(state.requests.length, 1) + assert.ok(!('activeMs' in state.requests[0]), 'no undefined-valued property (plain-JSON precondition)') + }) + test('steps accumulate; the pending slot prices assistant/message and step/end of the SAME step', () => { const { state } = driveTimeline([...step(1, 0, 1_000, { tokenMs: 200 }), ...step(4, 10_000, 3_000, { tokenMs: 1_500 })]) assert.equal(state.timing?.wallMs, 12_000) @@ -416,6 +432,133 @@ describe('timing — served wire view', () => { }) }) +describe('timing — the step\'s active time (user waits priced out)', () => { + const asked = (seq: number, time: number, id: unknown): TimelineEvent => ({ + type: 'approval/asked', seq, time, data: { id, toolName: 'bash' }, + }) + const decided = (seq: number, time: number, id: unknown): TimelineEvent => ({ + type: 'approval/decided', seq, time, data: { id, outcome: 'allowed-once' }, + }) + const askCall = (seq: number, time: number, callId = 'q1'): TimelineEvent => ({ + type: 'tool/call', seq, time, data: { callId, name: 'ask_user_question', arguments: '{}' }, + }) + + test('the step\'s tool runs price active (the whole window counts)', () => { + const { state } = driveTimeline([ + stepStart(1, { time: 0 }), + assistantMessage(2, { time: 2_000 }), + { type: 'tool/call', seq: 3, time: 3_000, data: { callId: 'c1', name: 'bash', arguments: '{}' } }, + toolResult(4, { callId: 'c1', content: text('ok'), time: 8_000 }), + stepEnd(5, { time: 9_000 }), + ]) + assert.equal(state.requests[0].activeMs, 9_000, 'the model call plus its tool run, start → end') + }) + + test('an approval decision wait prices out of the step', () => { + const { state } = driveTimeline([ + stepStart(1, { time: 0 }), + assistantMessage(2, { time: 2_000 }), + { type: 'tool/call', seq: 3, time: 3_000, data: { callId: 'c1', name: 'bash', arguments: '{}' } }, + asked(4, 3_500, 'a1'), + decided(5, 5_500, 'a1'), + toolResult(6, { callId: 'c1', content: text('ok'), time: 6_000 }), + stepEnd(7, { time: 7_000 }), + ]) + assert.equal(state.requests[0].activeMs, 5_000, 'the 7s window minus the 2s decision wait') + assert.equal(state.timing?.wallMs, 7_000, 'the timing card keeps the wall figure') + }) + + test('an ask_user_question window prices out as the user\'s answer wait', () => { + const { state } = driveTimeline([ + stepStart(1, { time: 0 }), + assistantMessage(2, { time: 2_000 }), + askCall(3, 3_000), + toolResult(4, { callId: 'q1', content: text('answer'), time: 8_000 }), + stepEnd(5, { time: 9_000 }), + ]) + assert.equal(state.requests[0].activeMs, 4_000, 'the 9s window minus the 5s answer wait') + }) + + test('an approval nested inside the answer wait prices once (the union, not the sum)', () => { + const { state } = driveTimeline([ + stepStart(1, { time: 0 }), + assistantMessage(2, { time: 2_000 }), + askCall(3, 3_000), + asked(4, 3_500, 'a1'), + decided(5, 4_000, 'a1'), + toolResult(6, { callId: 'q1', content: text('answer'), time: 8_000 }), + stepEnd(7, { time: 9_000 }), + ]) + assert.equal(state.requests[0].activeMs, 4_000, 'the nested 500ms decision rides inside the 5s wait') + }) + + test('concurrent decisions close independently; equal starts tie-break by end', () => { + const drive = driveTimeline([ + stepStart(1, { time: 0 }), + assistantMessage(2, { time: 2_000 }), + asked(3, 3_000, 'a1'), + asked(4, 3_000, 'a2'), + decided(5, 5_000, 'a1'), + decided(6, 6_000, 'a2'), + stepEnd(7, { time: 9_000 }), + ]) + assert.equal(drive.state.requests[0].activeMs, 6_000, 'the overlapping waits price their union (3000→6000)') + // Mid-step: the first decide leaves the second approval armed. + const mid = drive.states[5] + assert.deepEqual(mid.stepApprovals, { a2: 3_000 }) + assert.deepEqual(mid.stepWaits, [{ start: 3_000, end: 5_000 }]) + }) + + test('a decision wait left dangling (a crash tail) closes at step/end', () => { + const { state } = driveTimeline([ + stepStart(1, { time: 0 }), + assistantMessage(2, { time: 2_000 }), + asked(3, 3_000, 'a1'), + stepEnd(4, { time: 7_000 }), + ]) + assert.equal(state.requests[0].activeMs, 3_000, 'the unanswered wait runs to the step\'s end') + }) + + test('a step that committed no request stamps nothing (the wall figure still prices)', () => { + const { state } = driveTimeline([ + stepStart(1, { time: 0 }), + asked(2, 1_000, 'a1'), + stepEnd(3, { time: 5_000 }), + ]) + assert.equal(state.requests.length, 0) + assert.equal(state.timing?.wallMs, 5_000) + }) + + test('a requestless step after a real one stamps nothing onto its record', () => { + // The seq guard: the open step armed no back-pointer, so the earlier + // step's record keeps its own stamp untouched. + const { state } = driveTimeline([ + ...step(1, 0, 1_000, { tokenMs: 200 }), + stepStart(4, { time: 10_000 }), + stepEnd(5, { time: 12_000 }), + ]) + assert.equal(state.requests.length, 1) + assert.equal(state.requests[0].activeMs, 5_000, 'the first step\'s own window (0→5000)') + }) + + test('approval events outside an open step are uninteresting (same state reference)', () => { + const { state } = driveTimeline([]) + assertStable(state, asked(1, 0, 'a1')) + assertStable(state, decided(2, 1_000, 'a1')) + }) + + test('an asked without a usable id arms nothing, even inside a step', () => { + const { state } = driveTimeline([stepStart(1, { time: 0 })]) + assertStable(state, { type: 'approval/asked', seq: 2, time: 100, data: {} }) + }) + + test('a decided that names no armed asked prices nothing', () => { + const { state } = driveTimeline([stepStart(1, { time: 0 }), asked(2, 100, 'a1')]) + assertStable(state, decided(3, 200, 'ghost')) + assertStable(state, { type: 'approval/decided', seq: 4, time: 300, data: {} }) + }) +}) + describe('timing — the throughput seat', () => { test('a call with usage pairs its decode window and output tokens; a usage-less call stays out', () => { const { state } = driveTimeline([ diff --git a/tests/host/timeline.spec.ts b/tests/host/timeline.spec.ts index 2e06b43..833eadf 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, 25) + assert.equal(def.stateVersion, 26) assert.equal(typeof def.init, 'function') assert.equal(typeof def.apply, 'function') // The supported registry contract: stateSchema + a REQUIRED wire block. @@ -74,6 +74,11 @@ describe('createContextTimelineDefinition', () => { assert.ok(view.nodes.length > 0) assert.ok(view.requests.length > 0) assert.ok(view.events.length > 0) + // The duration overlay's per-request step active time (RequestRecord.activeMs): the first + // request's step ran start (tick 4s) → end (tick 9s) with its bash call inside and no user + // waits; the second folded after stepEnd consumed the slot. + assert.equal(view.requests[0].activeMs, 5000, 'step start → step end, the tool run included') + assert.equal(view.requests[1].activeMs, undefined, 'no open step slot, no stamp') for (const [index, state] of drive.states.entries()) { def.stateSchema.parse(structuredClone(state)) // throws on drift