perf: split the context timeline into a slim wire head plus an on-demand detail channel

The contextTimeline projection value rode every harness delivery channel
WHOLE: each session.list row, every control baseline, every follow
snapshot, and a full-value broadcast on every state change (with a
full-value schema parse per push). The heavy collections (request records,
context events, the served surface window, the removed-node archive) are
only ever rendered by an OPEN Context tab/modal, so they moved off the
wire value.

Wire head (buildTimelineHead, ~1KB): the composition breakdown, the stats
board's precomputed counters (turns/steps/injects/compactions/prunes), the
newest request's billing summary (the headline's derived anchor), the
timing/cost totals, and `detailRev` — a revision marker that bumps exactly
on detail-mutating folds, the open tab's refetch signal.

Detail channel (host/detail.ts): the pre-v0.9 generic Connection RPC shape
returns as one narrow endpoint, `/dsh-context` `detail`, serving the
collections off the registry's live fold state (`stateOf` — no second
fold, no private copy). A nested `ctx.inject(['connection', 'sessions'])`
arms it load-order-free; a deployment whose faces never compose keeps the
inline wire value unchanged. The baseline-gate fallback path is untouched.

Client (client/timelineSource.ts): one per-session detail store shared by
the tab and the /context modal — fetch on open, then debounced
trailing-edge refetches whenever the pushed detailRev outruns the served
detail (single-flight, latest-wins by revision, refold reset, failure
backoff, absence stops trailing). Detail-driven cards show a pending/retry
note instead of a misleading empty state; the counters and headline render
off the head immediately. Both wire generations are reconciled per value
(new client + old host renders the inline value untouched; old client + new
host degrades to empty charts in the stale-watch window, never a white
screen). The chat-jump relay holds its one-shot pin until the detail lands.

Fold state keeps the full shape: no stateVersion bump, no cached-row
invalidation (the slim view is recomputed per read for cold checkpoints).
Compat matrix gains the detail-channel seam probes (HostConnectionRpc,
the browser caller, the registry stateOf), verified against the
0.1.2-rc.1 tag.

Measured over 263 real session logs from a live deployment, plus live RPC:
- per-session wire value: 84–150KB (top sessions) -> 0.6–0.9KB (~100-165x)
- all sessions aggregate (session.list rows / control baselines):
  4.4MB -> 0.2MB (~22x)
- per-client push traffic over one big session's lifetime:
  157MB -> 1.8MB (~87x), and the per-push full-value zod parse shrinks
  by the same factor
- detail payloads now flow only for the session being viewed, only while
  its tab/modal is open (120KB measured live for the biggest session);
  background sessions cost zero detail traffic

Verified visually against the running harness (big/small/cold sessions,
a live turn refreshing in place, past-step reconstruction, header epoch
and tool schema reads, the /context modal, the Agent network card) with
byte-identical card output. 1110 tests, per-file 100% coverage.
This commit is contained in:
Bowen Liang committed 2026-09-06 04:08:20 +08:00
1 parent 3faf027330
commit 62ae0f9c69
37 files changed
+2407 -108

No files matched your search

+4 -1
View File
@@ -198,7 +198,10 @@ export function agentStatsOf(values: Record<string, unknown> | undefined): Agent
: null
return {
head,
requests: timeline !== null ? timeline.requests.length : 0,
// The split-generation wire head carries the tally precomputed (the
// request records ride the detail channel); the inline generation's rows
// count their served records.
requests: timeline !== null ? (timeline.counts?.steps ?? timeline.requests.length) : 0,
billed,
durationMs: agentDurationOf(values?.subagentTiming),
identity: agentIdentityOf(values?.subagent),
+16
View File
@@ -7,6 +7,8 @@ import { React } from '../react'
import type { ContentFetcher, ConversationNodeLike, HeaderFetcher } from '../services'
import type { ViewKit } from '../viewkit'
import { blockSummaryOf, callSummaryOf, parseCallArgs } from '../callSummary'
import type { DetailState } from '../timelineSource'
import { makeDetailNote } from './detailNote'
import { makeNodeText } from './nodes'
import { fetchMissNote, useFetchOnMiss } from './fetchOnMiss'
import { imageRefOf, makeImageCard } from './images'
@@ -51,6 +53,13 @@ export interface ContextBrowserProps {
hoverKey?: string | null
onHoverKey?: (key: string | null) => void
loadImage?: ImageLoader
/**
* The timeline source's detail state (split generation): while the first
* detail read is pending or settled without data, a note strip names the
* state (the picker/sections otherwise show a misleading empty surface).
*/
detailState?: DetailState
onDetailRetry?: () => void
}
interface ParamSchema {
@@ -556,6 +565,7 @@ export function makeContextBrowser(
StackedBar: (props: StackedBarProps) => ReactNS.ReactElement,
): (props: ContextBrowserProps) => ReactNS.ReactElement {
const { t, fmt, fmtTime, catLabel } = kit
const DetailNote = makeDetailNote(kit)
const nodeText = makeNodeText(kit)
const rich = makeRichText(kit)
const ImageCard = makeImageCard(kit)
@@ -985,6 +995,12 @@ export function makeContextBrowser(
{view.approximate
? <div className="lc-br-note">{t('browser.approx')}</div>
: null}
{props.detailState === 'loading'
? <DetailNote state="loading" className="lc-br-note" />
: null}
{props.detailState === 'failed' && props.onDetailRetry !== undefined
? <DetailNote state="failed" onRetry={props.onDetailRetry} className="lc-br-note" />
: null}
<div className="lc-br-cats">
{CATS.map((c) => {
+8 -2
View File
@@ -9,8 +9,9 @@ import { measureDock } from '../dockMeasure'
import { headlineOf } from '../headline'
import { modalStoreOf, takePendingConsume } from '../modalStore'
import type { ClientCtx, SessionStandardProps, SessionsFace } from '../services'
import { contextBreakdownOf, contextPressureOf, conversationNodesOf, headersOf, projectionOf, timelineOf } from '../services'
import { contextBreakdownOf, contextPressureOf, conversationNodesOf, headersOf, projectionOf } from '../services'
import { makeContentFetcher, makeHeaderFetcher } from '../historyPage'
import { useTimelineSource } from '../timelineSource'
import type { ViewKit } from '../viewkit'
import { makeContextBrowser } from './browser'
import { makeCurrentComposition } from './currentComposition'
@@ -36,7 +37,10 @@ export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextM
function ContextModalBody(props: ContextModalProps): ReactNS.ReactElement | null {
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : ''
const open = typeof props.useContextModal === 'function' ? props.useContextModal(s => s) : false
const data = projectionOf(props, 'contextTimeline', timelineOf)
// The timeline source (timelineSource.ts) — shares the tab's per-session
// detail store, so an open tab's detail serves the modal with no refetch.
const source = useTimelineSource(ctx, props)
const data = source.data
const pressure = projectionOf(props, 'contextPressure', contextPressureOf)
const breakdown = projectionOf(props, 'contextBreakdown', contextBreakdownOf)
const headers = projectionOf(props, 'contextHeaders', headersOf)
@@ -124,6 +128,8 @@ export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextM
fetchHeader={fetchHeader}
hoverKey={hoverCat}
onHoverKey={setHoverCat}
detailState={source.detailState}
onDetailRetry={source.retryDetail}
/>
</div>
)}
+31 -11
View File
@@ -1,6 +1,8 @@
/**
* Context tab root: renders the harness-pushed `contextTimeline` projection and composes stats, composition, history, events and messages;
* never calls RPC and holds no cache — the harness owns the projection pipeline end to end.
* Context tab root: renders the `contextTimeline` projection and composes stats, composition, history, events and messages.
* The data plane is the harness's projection pipeline end to end; the one addition is the split generation's on-demand
* detail read (timelineSource.ts) — the pushed wire value is the slim head, and the heavy collections arrive from the
* host's detail endpoint only while the tab (or modal) is open.
*/
import type * as ReactNS from 'react'
@@ -8,9 +10,11 @@ import type { ContextEventRecord, RequestRecord, SurfaceNode } from '../../share
import { briefNodes, briefOf } from '../brief'
import { headlineOf } from '../headline'
import type { SessionStandardProps } from '../services'
import { contextBreakdownOf, contextPressureOf, conversationNodesOf, headersOf, imageLoaderOf, numOf, projectionOf, timelineOf, tokenUsageOf, unsupportedOf } from '../services'
import { contextBreakdownOf, contextPressureOf, conversationNodesOf, headersOf, imageLoaderOf, numOf, projectionOf, tokenUsageOf, unsupportedOf } from '../services'
import type { ClientCtx, ConversationNodeLike } from '../services'
import { makeContentFetcher, makeHeaderFetcher } from '../historyPage'
import { useTimelineSource } from '../timelineSource'
import { makeDetailNote } from './detailNote'
import { canOpenPathsOf, openPathVia, workspaceOf } from '../services'
import { activityOf, locateStepOf } from '../fileActivity'
import type { FileOp } from '../fileActivity'
@@ -25,7 +29,7 @@ import { makeFileCard } from './fileCard'
import { makePluginInfo } from './pluginInfo'
import { makeUpgradeGate } from './upgradeGate'
import { makeRequestDetail } from './requestDetail'
import { makeStatsContext } from './statsContext'
import { countsOfRecords, makeStatsContext } from './statsContext'
import { makeStatsTiming } from './statsTiming'
import { makeStatsTokens } from './statsTokens'
import { makeLegend, makeStackedBar } from './stackedBar'
@@ -61,6 +65,7 @@ export function makeContextView(
const StatsTokens = makeStatsTokens(kit, Donut)
const PluginInfo = makePluginInfo(kit)
const UpgradeGate = makeUpgradeGate(kit)
const DetailNote = makeDetailNote(kit)
const ContextBrowser = makeContextBrowser(kit, StackedBar)
const AgentGraph = makeAgentGraph(ctx, kit)
const ErrorBoundary = makeErrorBoundary(t)
@@ -69,7 +74,12 @@ export function makeContextView(
// card, not a white screen; the boundary itself has NO hooks, so the body's hook order and loading/data flow stay unchanged.
function ContextViewBody(props: SessionStandardProps): ReactNS.ReactElement {
const sessionId = props.sessionId
const data = projectionOf(props, 'contextTimeline', timelineOf)
// The timeline source (timelineSource.ts): the pushed value on the inline
// generation, or the slim head merged with the on-demand detail on the
// split generation. `detailState`/`retryDetail` drive the detail cards'
// loading/failed notes.
const source = useTimelineSource(ctx, props)
const data = source.data
// Official token-meter `contextPressure` projection — the same key the chat's context ring reads; token-meter owns estimation, the Host
// no longer mirrors it. Absent → derived fallback.
const pressure = projectionOf(props, 'contextPressure', contextPressureOf)
@@ -188,8 +198,11 @@ export function makeContextView(
// Leg 2: the action row belongs to the reply that CLOSED a turn, so the jump is turn-level — flip the chart to turn bars, pin that
// turn's aggregate (the relayed seq is the turn's last request, exactly the aggregate's record), and center it: the same flow as a
// turn-strip click. An aged-out turn clamps to the oldest retained bar. No page-scroller anchor → the reset degrades quietly.
// The split generation waits for the detail read first: the relayed seq resolves against the served request records, so the
// one-shot request must not be consumed while the collections are still pending (it re-fires when they land).
const detailReady = source.detailState === 'ready' || source.detailState === 'legacy'
React.useEffect(() => {
if (jumpSeq === null || data === null) return
if (jumpSeq === null || data === null || !detailReady) return
setJumpSeq(null)
const target = jumpTargetOf(aggregateByTurn(requests), jumpSeq)
if (target === null) return
@@ -198,7 +211,7 @@ export function makeContextView(
setFocusTurn(target.turn ?? 0)
// The restore layout effect resolved the shared scroller on this same data render (layout effects precede this one).
if (scrollerRef.current !== null) scrollerRef.current.scrollTop = 0
}, [jumpSeq, data, requests])
}, [jumpSeq, data, requests, detailReady])
// Step-brief raw material: every served node seq-sorted (live tail + archive), and the conversation-snapshot
// join the brief uses for call-argument enrichment (same join the Context browser builds).
@@ -322,7 +335,7 @@ export function makeContextView(
<div className="lc-root" ref={rootRef}>
<div className="lc-cols lc-head">
<StatsContext requests={requests} events={events} toolCalls={data.toolCalls} images={data.images}
<StatsContext counts={data.counts ?? countsOfRecords(requests, events)} toolCalls={data.toolCalls} images={data.images}
cost={data.cost} locale={activeLocale} />
<StatsTokens usage={usage} />
<StatsTiming timing={data.timing ?? null} locale={activeLocale} />
@@ -366,7 +379,11 @@ export function makeContextView(
</div>
</div>
{displayRequests.length === 0
? <div className="lc-empty">{t('trend.empty')}</div>
// Split generation, first detail read pending or failed: say
// so instead of claiming the session has no history.
? (detailReady
? <div className="lc-empty">{t('trend.empty')}</div>
: <DetailNote state={source.detailState === 'failed' ? 'failed' : 'loading'} onRetry={source.retryDetail} />)
: (
<div>
<TrendChart
@@ -425,6 +442,8 @@ export function makeContextView(
nodeFocus={nodeFocus}
onNodeFocusHandled={clearNodeFocus}
loadImage={loadImage}
detailState={source.detailState}
onDetailRetry={source.retryDetail}
/>
</div>
</div>
@@ -443,9 +462,10 @@ export function makeContextView(
))}
</div>
</div>
<EventList events={shownEvents} />
<EventList events={shownEvents} state={source.detailState} onRetry={source.retryDetail} />
</div>
<FileCard activity={fileActivity} scope={fileScope} workspace={workspace} onOpen={fileOpener} onLocate={locateFileOp} />
<FileCard activity={fileActivity} scope={fileScope} workspace={workspace} onOpen={fileOpener} onLocate={locateFileOp}
state={source.detailState} onRetry={source.retryDetail} />
</div>
<AgentGraph
+40
View File
@@ -0,0 +1,40 @@
/**
* The detail collections' pending/failed note — the split generation's
* visible loading story (timelineSource.ts): while the first detail read is
* in flight the detail-driven cards show this strip instead of a misleading
* empty state, and a settled-without-data read arms the retry button (never
* a spinner that never resolves, never a silent empty chart). Callers guard
* the failed state on a retry callback being wired (an unwired failure keeps
* the plain text).
*/
import type * as ReactNS from 'react'
import type { ViewKit } from '../viewkit'
import { React } from '../react'
export function makeDetailNote(kit: ViewKit): (props: {
/** The pending kind: the first read in flight, or settled without data. */
state: 'loading' | 'failed'
/** The retry affordance for the failed read; absent = the note renders inert text. */
onRetry?: () => void
/** The container's style class: cards use the shared empty well, the browser its inline note strip. */
className?: string
}) => ReactNS.ReactElement {
const { t } = kit
return function DetailNote(props: {
state: 'loading' | 'failed'
onRetry?: () => void
className?: string
}): ReactNS.ReactElement {
const cls = props.className ?? 'lc-empty'
if (props.state === 'loading') return <div className={cls}>{t('detail.loading')}</div>
return (
<div className={cls}>
{props.onRetry !== undefined
? <button type="button" className="lc-br-retry" onClick={props.onRetry}>{t('detail.loadFailed')}</button>
: t('detail.loadFailed')}
</div>
)
}
}
+17 -1
View File
@@ -7,13 +7,24 @@ import type * as ReactNS from 'react'
import type { ContextEventRecord } from '../../shared/types'
import { IconBranchOutline16, IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Translate } from '../i18n'
import type { DetailState } from '../timelineSource'
import type { ViewKit } from '../viewkit'
import { makeDetailNote } from './detailNote'
import { React } from '../react'
const EVENT_ICONS: Record<string, string> = { compaction: '✂', prune: '✂', inject: '+', model: '⇄', mode: '⇄' }
export interface EventListProps { events: ContextEventRecord[] }
export interface EventListProps {
events: ContextEventRecord[]
/**
* The timeline source's detail state (split generation): with no events to
* list, `loading`/`failed` replace the empty claim with the pending note /
* retry button — an un-fetched list is not an empty one.
*/
state?: DetailState
onRetry?: () => void
}
export function makeEventText(t: Translate): {
eventLabel: (ev: ContextEventRecord) => string
@@ -70,6 +81,7 @@ function syncTitles(root: HTMLElement): void {
export function makeEventList(kit: ViewKit): (props: EventListProps) => ReactNS.ReactElement {
const { t, fmt, fmtTime, eventLabel, eventAt } = kit
const DetailNote = makeDetailNote(kit)
return function EventList(props: EventListProps): ReactNS.ReactElement {
// Hooks stay unconditional (Rules of Hooks): events going empty ->
// non-empty in one mounted instance must not grow the hook count — an
@@ -92,6 +104,10 @@ export function makeEventList(kit: ViewKit): (props: EventListProps) => ReactNS.
return () => { window.removeEventListener('resize', onResize) }
}, [])
if (props.events.length === 0) {
if (props.state === 'loading') return <DetailNote state="loading" />
if (props.state === 'failed' && props.onRetry !== undefined) {
return <DetailNote state="failed" onRetry={props.onRetry} />
}
return <div className="lc-empty">{t('events.empty')}</div>
}
const sorted = props.events.slice().reverse()
+15 -1
View File
@@ -15,7 +15,9 @@ import type * as ReactNS from 'react'
import { absPathOf, displayPathOf, glyphOf } from '../fileActivity'
import type { FileActivity, FileEntry, FileOp, FileOpKind } from '../fileActivity'
import type { ContextSettings, DefaultFileSort } from '../settings'
import type { DetailState } from '../timelineSource'
import type { ViewKit } from '../viewkit'
import { makeDetailNote } from './detailNote'
import { React } from '../react'
@@ -31,10 +33,18 @@ export interface FileCardProps {
onOpen?: (absPath: string) => void
/** Reveal one operation's result node in the Context browser; absent = op lines render inert. */
onLocate?: (op: FileOp) => void
/**
* The timeline source's detail state (split generation): the activity fold
* reads the detail collections, so a pending/failed first read replaces the
* empty claim with the pending note / retry button.
*/
state?: DetailState
onRetry?: () => void
}
export function makeFileCard(kit: ViewKit, settings: ContextSettings): ReactNS.ComponentType<FileCardProps> {
const { t, fmt, fmtTime } = kit
const DetailNote = makeDetailNote(kit)
function matches(e: FileEntry, f: FileFilter): boolean {
if (f === 'all') return true
@@ -130,7 +140,11 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): ReactNS.C
<span className="lc-card-title-text">{t('files.title')}</span>
<span className="lc-card-sub">{props.scope}</span>
</div>
{activity.entries.length === 0 ? (
{activity.entries.length === 0 && props.state === 'loading' ? (
<DetailNote state="loading" />
) : activity.entries.length === 0 && props.state === 'failed' && props.onRetry !== undefined ? (
<DetailNote state="failed" onRetry={props.onRetry} />
) : activity.entries.length === 0 ? (
<div className="lc-empty">{t('files.empty')}</div>
) : (
<div>
+34 -22
View File
@@ -10,19 +10,44 @@
* marker + styled DOM tip) explains the whole-session estimate and lists the
* per-1M-token table straight from cost.ts, so printed rates can never drift
* from the math.
*
* The counts arrive precomputed: the split-generation wire head carries them
* (shared/types.ts `TimelineCounts` — computed over the retained records),
* and the caller derives them from the collections on the inline generation
* (`countsOfRecords`). The card itself never touches the collections.
*/
import type * as ReactNS from 'react'
import type { ContextEventRecord, RequestRecord, SessionCostUsage } from '../../shared/types'
import type { ContextEventRecord, RequestRecord, SessionCostUsage, TimelineCounts } from '../../shared/types'
import { estimateSessionCost, formatCost, formatPriceRate, sessionPrices } from '../cost'
import type { CostCurrency } from '../cost'
import type { ViewKit } from '../viewkit'
import { React } from '../react'
/**
* The inline generation's counter derivation — the exact tally the card ran
* over the served collections before the split (distinct turn values, record
* count, per-kind event tallies). The host's split-generation counts match
* it by construction (fold.ts buildTimelineHead).
*/
export function countsOfRecords(requests: readonly RequestRecord[], events: readonly ContextEventRecord[]): TimelineCounts {
const turns = new Set<number>()
for (const req of requests) turns.add(req.turn ?? 0)
let injects = 0
let compactions = 0
let prunes = 0
for (const ev of events) {
if (ev.kind === 'inject') injects++
else if (ev.kind === 'compaction') compactions++
else if (ev.kind === 'prune') prunes++
}
return { turns: turns.size, steps: requests.length, injects, compactions, prunes }
}
export function makeStatsContext(kit: ViewKit): (props: {
requests: RequestRecord[]
events: ContextEventRecord[]
/** The session-shape and event tallies (host-precomputed on the split generation). */
counts: TimelineCounts
/** Tool calls with a result live in the current context (absent on older hosts). */
toolCalls?: number
/** Image blocks live in the current context (absent on older hosts). */
@@ -32,25 +57,12 @@ export function makeStatsContext(kit: ViewKit): (props: {
}) => ReactNS.ReactElement {
const { t, fmt } = kit
return function StatsContext(props: {
requests: RequestRecord[]
events: ContextEventRecord[]
counts: TimelineCounts
toolCalls?: number
images?: number
cost?: SessionCostUsage
locale: string
}): ReactNS.ReactElement {
const turns = new Set<number>()
let steps = 0
for (const req of props.requests) {
turns.add(req.turn ?? 0)
steps++
}
let injects = 0, compactions = 0, prunes = 0
for (const ev of props.events) {
if (ev.kind === 'inject') injects++
else if (ev.kind === 'compaction') compactions++
else if (ev.kind === 'prune') prunes++
}
const currency: CostCurrency = props.locale === 'zh' ? 'cny' : 'usd'
const cost = estimateSessionCost(props.cost, currency)
const fmtRate = (n: number): string => formatPriceRate(n, currency)
@@ -85,14 +97,14 @@ export function makeStatsContext(kit: ViewKit): (props: {
<span className="lc-card-sub">{t('stats.hint')}</span>
</div>
<div className="lc-stats">
{cell(t('stats.turns'), turns.size)}
{cell(t('stats.steps'), steps)}
{cell(t('stats.turns'), props.counts.turns)}
{cell(t('stats.steps'), props.counts.steps)}
{cell(t('stats.toolCalls'), props.toolCalls ?? 0)}
{cell(t('stats.images'), props.images ?? 0)}
{cell(t('stats.cost'), cost === null ? '—' : formatCost(cost, currency), costTip)}
{cell(t('stats.injects'), injects)}
{cell(t('stats.compactions'), compactions)}
{cell(t('stats.prunes'), prunes)}
{cell(t('stats.injects'), props.counts.injects)}
{cell(t('stats.compactions'), props.counts.compactions)}
{cell(t('stats.prunes'), props.counts.prunes)}
</div>
</div>
)
+9 -5
View File
@@ -43,13 +43,17 @@ export function headlineOf(
const projected = pressure !== null && typeof pressure.projectedTokens === 'number'
? pressure.projectedTokens
: undefined
const requests = data.requests
const lastReq = requests.length > 0 ? requests[requests.length - 1] : null
// Fallback anchor: the newest request's provider prompt plus the heuristic
// surface movement since it was logged (same shape as the projection, one
// request behind).
const derived = lastReq !== null && typeof lastReq.prompt === 'number'
? lastReq.prompt + (current.total - lastReq.total)
// request behind). The split-generation wire head carries that record's
// billing summary as `last` (the request records ride the detail channel);
// the inline generation reads its newest record directly.
const last = data.last
const requests = data.requests
const lastReq = requests.length > 0 ? requests[requests.length - 1] : null
const anchor = last !== undefined ? last : lastReq
const derived = anchor !== null && typeof anchor.prompt === 'number'
? anchor.prompt + (current.total - anchor.total)
: undefined
const occupancyTokens = projected ?? derived ?? null
const window = pressure !== null && typeof pressure.contextWindow === 'number'
+4
View File
@@ -137,6 +137,8 @@ export const DICT_ZH: Record<string, string> = {
'agents.billed': '累计计费 {n}',
'agents.open': '点击打开会话 →',
'loading': '正在读取会话日志…',
'detail.loading': '正在加载历史数据…',
'detail.loadFailed': '历史数据加载失败,点击重试',
'error': '上下文数据读取失败:',
'error.retry': '重试',
'gate.title': '请更新 DeepSeek Harness 版本',
@@ -359,6 +361,8 @@ export const DICT_EN: Record<string, string> = {
'agents.billed': '{n} billed',
'agents.open': 'click to open →',
'loading': 'Reading the session log…',
'detail.loading': 'Loading history…',
'detail.loadFailed': 'History failed to load — click to retry',
'error': 'Failed to read context data: ',
'error.retry': 'Retry',
'gate.title': 'Update DeepSeek Harness',
+7 -6
View File
@@ -6,12 +6,13 @@
* context-composition timeline: current makeup, per-request stacked-bar
* history, context events, and the live message list.
*
* Since v0.9 the tab needs no custom data plane: the Host half pushes its
* fold through the harness's session-projection pipeline
* (`contextTimeline` projection key), and this half reads the finished value
* from the framework standard kit (`useProjection('contextTimeline')`, a
* standard prop on every session-scope slot component). No polling, no RPC,
* no client-side cache.
* Since v0.9 the tab rides the harness's session-projection pipeline
* (`contextTimeline` projection key), read from the framework standard kit
* (`useProjection('contextTimeline')`, a standard prop on every session-scope
* slot component). The wire value is the split generation's slim head; the
* heavy collections arrive on demand from the host's detail endpoint, one
* read per viewing client (timelineSource.ts). No polling, no stale-while-
* revalidate cache.
*
* This module is the body of the package's `./client` bundle: tsdown
* (tsdown.config.ts) bundles it (external `react` — the browser module table
+41 -2
View File
@@ -211,7 +211,8 @@ export function numOf(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
function objectsOf<T>(value: unknown): T[] {
/** Shared per-item collection guard: drop non-object entries, keep the rest. */
export function objectsOf<T>(value: unknown): T[] {
if (!Array.isArray(value)) return []
return value.filter((v): v is T => v !== null && typeof v === 'object')
}
@@ -279,7 +280,10 @@ export function timelineOf(value: unknown): ContextTimeline | null {
// The baseline-gate record survives sanitizing: a fallback payload that
// somehow fails the fast path must still pop the gate modal.
const unsupported = unsupportedOf(data.unsupported)
return {
// The split-generation head fields survive sanitizing too.
const counts = countsOf(data.counts)
const last = lastOf(data.last)
const safe: ContextTimeline = {
ok: true,
...(unsupported !== null ? { unsupported } : {}),
...(typeof data.model === 'string' ? { model: data.model } : {}),
@@ -301,11 +305,46 @@ export function timelineOf(value: unknown): ContextTimeline | null {
...(typeof data.images === 'number' ? { images: data.images } : {}),
...(typeof data.toolCalls === 'number' ? { toolCalls: data.toolCalls } : {}),
archive: objectsOf(data.archive),
...(counts !== undefined ? { counts } : {}),
...(last !== undefined ? { last } : {}),
...(typeof data.detailRev === 'number' && Number.isFinite(data.detailRev) ? { detailRev: data.detailRev } : {}),
...(cost !== undefined ? { cost } : {}),
...(timing !== null ? { timing } : {}),
...(typeof data.surfaceFloor === 'number' ? { surfaceFloor: data.surfaceFloor } : {}),
...(typeof data.archiveFloor === 'number' ? { archiveFloor: data.archiveFloor } : {}),
}
return safe
}
/**
* The split head's count figures, re-proved field by field: a present-but-
* partial record zeroes its unreadable fields (the stats board's no-NaN
* guarantee), an absent or non-record value stays absent (legacy generation
* — callers derive the counts from the collections instead).
*/
function countsOf(value: unknown): ContextTimeline['counts'] {
const data = asRecord(value)
if (data === null) return undefined
return {
turns: numOf(data.turns),
steps: numOf(data.steps),
injects: numOf(data.injects),
compactions: numOf(data.compactions),
prunes: numOf(data.prunes),
}
}
/** The split head's newest-request summary; absent or shapeless stays absent. */
function lastOf(value: unknown): ContextTimeline['last'] {
const data = asRecord(value)
if (data === null) return undefined
if (typeof data.seq !== 'number' || !Number.isFinite(data.seq)) return undefined
if (typeof data.total !== 'number' || !Number.isFinite(data.total)) return undefined
return {
seq: data.seq,
total: data.total,
...(typeof data.prompt === 'number' && Number.isFinite(data.prompt) ? { prompt: data.prompt } : {}),
}
}
/**
+315
View File
@@ -0,0 +1,315 @@
/**
* The timeline source behind the Context tab and the /context modal —
* reconciles the two `contextTimeline` wire generations into the single
* value the cards have always rendered.
*
* Generations (the marker is `detailRev` on the delivered value):
* - INLINE (older hosts, channel-less deployments, the baseline-gate
* fallback): the wire value carries the collections in place. It passes
* through untouched — no fetch ever happens.
* - SPLIT (current host with the detail channel live): the wire value is the
* slim head (~1KB — every session.list row, control baseline, and push
* frame carries it whole). The collections arrive from the host's
* `/dsh-context` `detail` endpoint (host/detail.ts): one targeted read
* when the tab/modal first opens, then a debounced refetch whenever the
* pushed `detailRev` outruns the served detail. Closed tabs fetch nothing.
*
* The no-stale-content guarantees: the store is per session and shared by
* the tab and the modal; a refetch is single-flight with a trailing edge
* (a rev bumped mid-flight re-reads after settle); responses race-safe by
* revision (latest wins, with the refold exception — a host that refolded
* restarts the revision, and a response matching the requested rev is
* accepted regardless of order); a transport failure keeps the last good
* detail and backs off; an absent answer (the session left the live set)
* stops the trailing until the head moves again. With no detail at all,
* failure surfaces as a retryable note on the detail cards instead of an
* empty chart.
*/
import type { ContextTimeline, ContextTimelineDetail } from '../shared/types'
import { React } from './react'
import type { ClientCtx, SessionStandardProps } from './services'
import { asRecord, numOf, objectsOf, projectionOf, timelineOf } from './services'
// The channel/endpoint pair of host/detail.ts — re-declared here: the client
// bundle inlines every import, and the host module must never reach it.
const DETAIL_CHANNEL = '/dsh-context'
const DETAIL_ENDPOINT = 'detail'
/**
* Narrow the detail endpoint's payload to a render-safe value (the same
* boundary rigor as `timelineOf`): collections re-proved per item, scalars
* zeroed, a missing/NaN revision rejects the whole payload (the caller then
* shows the retryable failure note instead of half-merged data).
*/
export function detailOf(value: unknown): ContextTimelineDetail | null {
const data = asRecord(value)
if (data === null) return null
if (typeof data.rev !== 'number' || !Number.isFinite(data.rev) || data.rev < 0) return null
return {
rev: data.rev,
requests: objectsOf(data.requests),
events: objectsOf(data.events),
nodes: objectsOf(data.nodes),
droppedNodes: numOf(data.droppedNodes),
archive: objectsOf(data.archive),
...(typeof data.surfaceFloor === 'number' ? { surfaceFloor: data.surfaceFloor } : {}),
...(typeof data.archiveFloor === 'number' ? { archiveFloor: data.archiveFloor } : {}),
}
}
/**
* The detail reader over the harness's generic Connection RPC
* (`ctx.connection.rpc.call`) — resolved through the inject-free reflect
* read (the same seam openPathVia/workspaceOf use), so a hostile or absent
* connection service degrades to `undefined` instead of throwing. The
* returned thunk resolves the session's current detail, `null` when the
* session is not live anymore, and rejects on transport failure or a
* malformed payload (the store turns the two into the retryable state).
*/
export function makeDetailFetcher(
ctx: ClientCtx,
sessionId: string,
): (() => Promise<ContextTimelineDetail | null>) | undefined {
if (sessionId === '') return undefined
let call: ((channel: string, endpoint: string, payload: unknown) => Promise<unknown>) | undefined
try {
const rpc = asRecord(asRecord(ctx.get('connection'))?.rpc)
const fn = rpc?.call
if (rpc !== null && typeof fn === 'function') {
call = (fn as (channel: string, endpoint: string, payload: unknown) => Promise<unknown>).bind(rpc)
}
} catch { /* a hostile service read — no fetcher, the cards keep their note */ }
if (call === undefined) return undefined
return async () => {
const result = await call(DETAIL_CHANNEL, DETAIL_ENDPOINT, { sessionId })
const r = asRecord(result)
if (r === null || r.ok !== true) throw new Error('dsh-context: detail rpc failed')
if (r.value === null) return null
const detail = detailOf(r.value)
if (detail === null) throw new Error('dsh-context: detail rpc malformed')
return detail
}
}
/** The store's observable snapshot, rebuilt on every transition (identity-gated for useSyncExternalStore). */
export interface DetailSnap {
/** The newest accepted detail (kept while a refetch is in flight — the cards never flicker back to loading). */
detail: ContextTimelineDetail | null
/** The last read settled without data (transport failure or absence) and there is no detail to show. */
failed: boolean
/** A read is scheduled or in flight. */
pending: boolean
}
const EMPTY_SNAP: DetailSnap = { detail: null, failed: false, pending: false }
/** The fetch debounce base; each consecutive failure doubles the wait, capped at 3 doublings. */
const DETAIL_DEBOUNCE_MS = 300
/**
* One session's detail ledger. Exported for tests (a zero debounce makes the
* machine synchronous-ish); the app reaches it through `detailStoreOf`.
*/
export class DetailStore {
private detail: ContextTimelineDetail | null = null
/** The revision of `detail` (latest-wins cursor); -1 before the first landing. */
private acceptedRev = -1
/** The newest revision the head has asked for. */
private wantedRev = -1
/** The head rev the IN-FLIGHT (or scheduled) read targets — the refold exception's acceptance key. */
private targetRev = -1
/** The head rev seen last — a DECREASE means the host refolded (revisions restart). */
private lastHeadRev = -1
private failed = false
private inFlight = false
private timer: ReturnType<typeof setTimeout> | null = null
private failures = 0
private readonly listeners = new Set<() => void>()
private snap: DetailSnap = EMPTY_SNAP
constructor(
private readonly fetcher: (() => Promise<ContextTimelineDetail | null>) | undefined,
private readonly baseDelay: number = DETAIL_DEBOUNCE_MS,
) {}
readonly subscribe = (fn: () => void): (() => void) => {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
}
readonly getSnapshot = (): DetailSnap => this.snap
/**
* The head's current revision arrived: schedule the trailing-edge read
* when it outruns the served detail. A rev DECREASE means the host
* refolded the session (a discarded checkpoint, a restart): revisions are
* no longer comparable, so the ledger resets and refetches.
*/
request(rev: number): void {
if (rev < this.lastHeadRev) {
this.acceptedRev = -1
this.wantedRev = -1
this.detail = null
this.failed = false
}
this.lastHeadRev = rev
if (rev <= this.acceptedRev || rev <= this.wantedRev) return
this.wantedRev = rev
this.schedule()
}
/** Re-arm after a failure (the cards' retry note): immediate, backoff reset. */
readonly retry = (): void => {
this.failures = 0
if (this.detail !== null) return
if (this.timer !== null) {
clearTimeout(this.timer)
this.timer = null
}
if (!this.inFlight) void this.fire()
}
private schedule(): void {
if (this.timer !== null) return
this.timer = setTimeout(() => {
this.timer = null
void this.fire()
}, this.baseDelay * 2 ** Math.min(this.failures, 3))
this.emit()
}
private async fire(): Promise<void> {
// A trailing timer fired while an earlier read is still in flight — that
// read's settle re-arms when the wanted rev still outruns the served one.
if (this.inFlight) return
if (this.fetcher === undefined) {
// No connection face on this deployment: the typed failure arms the
// cards' note. (The slim head is only served when the host channel
// went live, so this is the exotic path.)
this.failed = true
this.emit()
return
}
this.inFlight = true
this.targetRev = this.wantedRev
this.emit()
try {
const d = await this.fetcher()
if (d !== null) {
// Latest-wins, with the refold exception: a response matching the
// requested rev is the current truth even when its number trails a
// pre-refold landing.
if (d.rev >= this.acceptedRev || d.rev === this.targetRev) {
this.detail = d
this.acceptedRev = d.rev
}
this.failures = 0
this.failed = false
} else {
// Absent (the session left the live set): keep the last detail, stop
// the trailing until the head moves again (a disposed session's rev
// never does), and arm the note only when nothing is showable.
this.wantedRev = this.acceptedRev
this.failed = this.detail === null
this.failures++
}
} catch {
this.failed = this.detail === null
this.failures++
}
this.inFlight = false
// The head moved while the read settled (or an earlier read lost the
// race): trail once more, debounced.
if (this.wantedRev > this.acceptedRev) this.schedule()
this.emit()
}
private emit(): void {
const pending = this.timer !== null || this.inFlight
const next: DetailSnap = { detail: this.detail, failed: this.failed, pending }
if (next.detail === this.snap.detail && next.failed === this.snap.failed && next.pending === this.snap.pending) return
this.snap = next
for (const fn of this.listeners) fn()
}
}
/** Page-lifetime per-session stores (the tab and the modal share one). */
const stores = new Map<string, DetailStore>()
export function detailStoreOf(ctx: ClientCtx, sessionId: string): DetailStore {
let store = stores.get(sessionId)
if (store === undefined) {
store = new DetailStore(makeDetailFetcher(ctx, sessionId))
stores.set(sessionId, store)
}
return store
}
/** Test isolation: drop every cached store. */
export function resetTimelineDetailStores(): void {
stores.clear()
}
export type DetailState = 'legacy' | 'loading' | 'ready' | 'failed'
export interface TimelineSource {
/**
* The value the cards render — the inline generation's value untouched, or
* the slim head merged with the fetched detail collections (empty while
* the first read is in flight; `detailState` names that).
*/
data: ContextTimeline | null
detailState: DetailState
retryDetail: () => void
}
const noopSubscribe = (): (() => void) => () => {}
const noopRetry = (): void => {}
/**
* The view's one read of the timeline (see the module header for the
* generation rules). Hook-order safe: every hook runs unconditionally, the
* branches below only shape the returned record.
*/
export function useTimelineSource(ctx: ClientCtx, props: SessionStandardProps): TimelineSource {
const head = projectionOf(props, 'contextTimeline', timelineOf)
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : ''
// The split marker: the slim head carries the detail revision; anything
// else (the inline value, the gate fallback, a corrupt payload) is the
// inline generation — `headRev` null means no detail channel work at all.
const headRev = head !== null && typeof head.detailRev === 'number' ? head.detailRev : null
const slim = headRev !== null
const store = React.useMemo(
() => (slim ? detailStoreOf(ctx, sessionId) : null),
[ctx, sessionId, slim],
)
const snap = React.useSyncExternalStore(
store !== null ? store.subscribe : noopSubscribe,
store !== null ? store.getSnapshot : () => EMPTY_SNAP,
)
React.useEffect(() => {
if (store !== null && headRev !== null) store.request(headRev)
}, [store, headRev])
return React.useMemo<TimelineSource>(() => {
if (head === null) return { data: null, detailState: 'loading', retryDetail: noopRetry }
if (!slim || store === null) return { data: head, detailState: 'legacy', retryDetail: noopRetry }
const detail = snap.detail
const data: ContextTimeline = detail === null
? head
: {
...head,
requests: detail.requests,
events: detail.events,
nodes: detail.nodes,
droppedNodes: detail.droppedNodes,
archive: detail.archive,
// Slim heads serve no floors; the detail's pair lands whole here.
...(detail.surfaceFloor !== undefined ? { surfaceFloor: detail.surfaceFloor } : {}),
...(detail.archiveFloor !== undefined ? { archiveFloor: detail.archiveFloor } : {}),
}
const detailState: DetailState = detail !== null ? 'ready' : snap.failed ? 'failed' : 'loading'
return { data, detailState, retryDetail: store.retry }
}, [head, slim, store, snap])
}
+135
View File
@@ -0,0 +1,135 @@
/**
* The on-demand DETAIL channel of the split `contextTimeline` generation.
*
* The projection's wire value is the slim head (fold.ts `buildTimelineHead`);
* the heavy collections (per-request records, context events, the served
* surface window, the removed-node archive) are served HERE instead — one
* targeted read per viewing client, only while its Context tab or /context
* modal is open, instead of riding every session.list row, control baseline,
* follow snapshot, and push frame whole (see shared/types.ts
* `ContextTimelineDetail`).
*
* The channel is the harness's generic Connection RPC (`ctx.connection.rpc`),
* the same transport the plugin used before the v0.9 projection migration —
* verified present on every supported baseline (0.1.2-rc.1+). The handler
* reads the unit's CURRENT fold state through the registry's `stateOf` (no
* second fold, no private copy): the client only ever details a session it
* is viewing, and a viewed session is attached by definition, so a live
* Session always backs the read. A session that left the live set
* mid-request (disposed) or a unit that never registered resolves to a
* typed `null` — the client keeps its last detail and offers a retry,
* never an unhandled rejection.
*
* Load order is never assumed: `watchDetailChannel` nests a `ctx.inject` on
* the two faces, so a connection service that activates AFTER this plugin
* still arms the channel (cordis replays the inject when the dependency set
* completes). The returned gate is read by the timeline unit's view at every
* serve, so the wire generation flips to slim the moment the channel goes
* live and flips back if it unloads — the client reconciles both (it detects
* the generation per value, timelineSource.ts). A deployment whose
* connection/sessions services never compose keeps the gate closed forever
* and serves the inline value unchanged.
*/
import type { Context } from '@deepseek-ai/cordis'
import type { FoldBounds } from './config'
import { buildTimelineDetail } from './fold'
/** The plugin's generic Connection RPC channel (the pre-v0.9 name, kept). */
export const DETAIL_CHANNEL = '/dsh-context'
/** The one endpoint the channel serves: the session's timeline detail. */
export const DETAIL_ENDPOINT = 'detail'
/** The channel's liveness, read by the timeline unit's view at every serve. */
export interface DetailChannelGate {
readonly live: boolean
}
/** The host `connection` service, as far as the channel consumes it. */
interface ConnectionHostFace {
rpc?: {
// The handler's result is awaited by the transport, so the synchronous
// handler below (a plain envelope read off the live fold state) is valid.
handle?(
channel: string,
handler: (endpoint: string, payload: unknown) => unknown,
): () => void
}
}
/** The host `sessions` service, as far as the channel consumes it (the strict-global-read idiom). */
interface SessionsHostFace {
get?(id: string): unknown
}
/** The RPC failure envelope the transport expects (ConnectionRpcFailure). */
function failure(code: string, message: string): { ok: false; error: { code: string; message: string; details: object } } {
return { ok: false, error: { code, message, details: {} } }
}
/**
* Arm the detail endpoint whenever the connection and sessions services are
* both composed (see the module header for the load-order contract). The
* registration rides the injected fiber: either service unloading withdraws
* the channel and closes the gate.
*/
export function watchDetailChannel(ctx: Context, bounds: FoldBounds): DetailChannelGate {
const gate = { live: false }
ctx.inject(['connection', 'sessions'], (c) => {
const connection = c.get('connection') as ConnectionHostFace | undefined
const sessions = c.get('sessions') as SessionsHostFace | undefined
// Bind at extraction (an unbound hand-off loses `this` on the real faces).
const handle = typeof connection?.rpc?.handle === 'function'
? connection.rpc.handle.bind(connection.rpc)
: undefined
const getSession = typeof sessions?.get === 'function' ? sessions.get.bind(sessions) : undefined
if (handle === undefined || getSession === undefined) return
const projections = ctx.sessionProjections
const handler = (endpoint: string, payload: unknown): unknown => {
if (endpoint !== DETAIL_ENDPOINT) {
return failure('dsh-context/unknown-endpoint', `unknown endpoint: ${endpoint}`)
}
const sessionId = payload !== null && typeof payload === 'object'
? (payload as { sessionId?: unknown }).sessionId
: undefined
if (typeof sessionId !== 'string' || sessionId === '') {
return failure('dsh-context/bad-request', 'missing sessionId')
}
try {
const session = getSession(sessionId)
// Only live (attached) sessions carry fold state; a viewed session is
// always attached, so a miss here means the session left the live set —
// typed null, not an error: the client keeps its last detail.
if (session === undefined || session === null) return { ok: true, value: null }
// `stateOf` materializes the cell at the session cursor (no-op when
// the drive is current) and returns the LIVE state — never mutate it.
const state = projections.stateOf(session as never, 'contextTimeline')
// The unit is absent only in the baseline-gated composition, which never
// installs this channel — a miss is defensive.
if (state === undefined) return { ok: true, value: null }
return { ok: true, value: buildTimelineDetail(state, bounds) }
} catch (err) {
return failure('gateway/internal', err instanceof Error ? err.message : String(err))
}
}
try {
c.effect(() => {
const unregister = handle(DETAIL_CHANNEL, handler)
return () => {
unregister()
}
}, 'dsh-context: detail channel')
} catch {
// A hostile or rejecting registry must not take the plugin down — the
// gate stays closed and the wire value stays inline.
return
}
gate.live = true
return () => {
gate.live = false
}
})
return gate
}
+106 -14
View File
@@ -19,7 +19,7 @@
* the request/event records are the raw material of `buildTimelineView`.
*/
import type { Category, ContextEventRecord, CostFamilyUsage, RequestRecord, SessionCostUsage, Snapshot, SurfaceNode, TimingTotals, ToolTimingTotals } from '../shared/types'
import type { Category, ContextEventRecord, ContextTimelineDetail, CostFamilyUsage, RequestRecord, SessionCostUsage, Snapshot, SurfaceNode, TimingTotals, ToolTimingTotals } from '../shared/types'
import { estimateSystemTokens } from '../shared/estimate'
import type { FoldBounds } from './config'
import {
@@ -100,6 +100,15 @@ export interface TimelineState {
*/
cost?: SessionCostUsage
archiveFloor?: number
/**
* The detail collections' revision marker (see ContextTimelineDetail):
* bumped by every fold that mutates the request records, context events,
* live surface, or the removed-node archive — the slim wire head carries
* it so an open tab knows its fetched detail went stale. Absent until the
* first detail fold (undefined reads as 0; never materialize an
* `undefined`-valued property — the plain-JSON precondition above).
*/
detailRev?: number
/**
* Whole-session timing totals (see TimingTotals) — running sums over the
* COMPLETE session log, like `cost`. Absent until the first step or tool
@@ -234,6 +243,17 @@ function categoryOf(type: string, message: { source?: MessageSource } | undefine
return 'user'
}
/**
* Mark the detail collections dirty (TimelineState.detailRev). Every caller
* is a fold branch that just mutated the requests/events/surface/archive;
* branches that touch only the working slots (stepStart, callNames, the
* shadow claim) or the envelope scalars do NOT bump — the served detail is
* unchanged, and an open tab has nothing to refetch.
*/
function bumpDetailRev(st: TimelineState): void {
st.detailRev = (st.detailRev ?? 0) + 1
}
/**
* Archive removed surface nodes as stamped COPIES — the objects leaving
* `st.surface` are shared with the persisted previous state, so `gone` must
@@ -657,6 +677,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
// too. Firing only on a real change keeps the list equal to the record.
if ((data?.reason === 'change' || data?.reason === 'resume') && s.model && s.lastModel && s.model !== s.lastModel) {
s.events.push({ seq: event.seq, time: event.time, kind: 'model', from: s.lastModel, to: s.model })
bumpDetailRev(s)
}
if (s.model) s.lastModel = s.model
break
@@ -717,6 +738,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
// `event.data` for user/message (no `data.message` indirection).
const msg = deriveEventMessage(event as never) as MessageLike | null
const s = ensure()
bumpDetailRev(s)
const node = applySurface(s, event, event.type, data, msg)
const source = msg?.source
if (isInjection(source)) {
@@ -744,6 +766,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
// the envelope would miss all content).
const toolMsg = deriveEventMessage(event as never) as MessageLike | null
const s = ensure()
bumpDetailRev(s)
const node = applySurface(s, event, event.type, data, toolMsg)
// A skill load via the `skill` tool returns the loaded skill's
// instructions as a tool result — content the harness injected into the
@@ -770,6 +793,7 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
// before this response joins the surface.
const usage = data?.usage as UsageLike | null | undefined
const s = ensure()
bumpDetailRev(s)
const total = s.systemTokens + s.toolsTokens + s.sums.user + s.sums.inject + s.sums.assistant + s.sums.tool
const record: RequestRecord = {
time: event.time, seq: event.seq,
@@ -841,12 +865,14 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
if (data && typeof data.active === 'boolean') {
const s = ensure()
s.events.push({ seq: event.seq, time: event.time, kind: 'mode', name: data.active ? 'plan.on' : 'plan.off' })
bumpDetailRev(s)
}
break
}
case 'compaction/summary':
case 'compaction/prune': {
const s = ensure()
bumpDetailRev(s)
// Arm the shadow-price claim: the replacement that follows this
// event synchronously shadows exactly these node seqs.
if (data && Array.isArray(data.shadowedSeqs)) {
@@ -882,22 +908,21 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
}
/**
* Serve the projection's wire view: bound the surface nodes to the newest tail and attach each event to the request around it; stamp
* COPIES
* — the persisted state objects are never mutated.
* The envelope scalars both wire generations share: current composition, the
* live-surface counters, and the copied cost/timing totals. Served value
* fields are COPIES — the served value must never alias persisted state.
* Optional scalars use conditional spread: an unknown value must not
* materialize an `undefined`-valued property (the lossless-JSON pipeline —
* a single such property can fail the whole push, the failure mode behind
* issue #29).
*/
export function buildTimelineView(state: TimelineState, bounds: FoldBounds): Snapshot {
function headFieldsOf(state: TimelineState): Snapshot {
const surfaceTotal = state.sums.user + state.sums.inject + state.sums.assistant + state.sums.tool
// NOTE: provider-anchored occupancy (the official chat ring) is NOT folded
// here since 0.11 — the Client reads token-meter's own `contextPressure`
// projection key for it (token-meter owns estimation and replay). This
// value keeps only the heuristic composition; `current.total` includes the
// envelope (system + tools) and the live surface.
// Optional scalars use conditional spread: an unknown value must not
// materialize an `undefined`-valued property on the served view. The wire
// value travels the harness's lossless-JSON pipeline — a single
// `undefined`-valued property can fail the whole push (the failure mode
// behind issue #29), so absence beats a present-but-undefined key.
const result: Snapshot = {
ok: true,
...(state.model !== undefined ? { model: state.model } : {}),
@@ -918,13 +943,13 @@ export function buildTimelineView(state: TimelineState, bounds: FoldBounds): Sna
// count. Calls still in flight (no result yet) and results compacted or
// pruned out of the surface are both excluded.
toolCalls: state.surface.reduce((n, node) => node.cat === 'tool' ? n + 1 : n, 0),
requests: state.requests.map(r => ({ ...r })),
events: state.events.map(e => ({ ...e })),
requests: [],
events: [],
nodes: [],
droppedNodes: 0,
archive: state.archived.map(n => ({ ...n })),
archive: [],
}
// The cost totals ride the wire as COPIES (same rule as requests/events:
// The cost totals ride the wire as COPIES (same rule as the collections:
// the served value must never alias persisted state).
if (state.cost !== undefined) {
const copyFam = (f: CostFamilyUsage | undefined): CostFamilyUsage | undefined => {
@@ -947,6 +972,24 @@ export function buildTimelineView(state: TimelineState, bounds: FoldBounds): Sna
for (const k in state.timing.tools) tools[k] = { ...state.timing.tools[k] }
result.timing = { ...state.timing, tools }
}
return result
}
/**
* The heavy collections: copies of the retained request records and context
* events (each event attached to the requests around it — the chart's ✂
* anchoring), the bounded served surface window, and the removed-node
* archive. Shared verbatim by the inline wire view (channel-less hosts) and
* the on-demand detail payload (host/detail.ts).
*/
function detailCollectionsOf(state: TimelineState, bounds: FoldBounds): Omit<ContextTimelineDetail, 'rev'> {
const result: Omit<ContextTimelineDetail, 'rev'> = {
requests: state.requests.map(r => ({ ...r })),
events: state.events.map(e => ({ ...e })),
nodes: [],
droppedNodes: 0,
archive: state.archived.map(n => ({ ...n })),
}
// The served slice: the newest `maxNodes` tail PLUS every live inject node
// older than the tail. Injections (AGENTS.md, session-start context, …)
// land on the surface FIRST, so in a long session the plain tail window
@@ -995,3 +1038,52 @@ export function buildTimelineView(state: TimelineState, bounds: FoldBounds): Sna
}
return result
}
/**
* The split generation's SLIM wire head: the envelope scalars plus the
* precomputed count figures, the newest request's billing summary (the
* headline's derived anchor), and the detail revision marker. Small enough
* to ride every delivery channel whole (~1KB) — the heavy collections moved
* to the on-demand detail channel (host/detail.ts).
*/
export function buildTimelineHead(state: TimelineState): Snapshot {
const result = headFieldsOf(state)
// The stats board's count figures, over the RETAINED records (the same set
// the detail serves): distinct turn values and per-kind event tallies.
const turns = new Set<number>()
for (const r of state.requests) turns.add(r.turn ?? 0)
let injects = 0
let compactions = 0
let prunes = 0
for (const e of state.events) {
if (e.kind === 'inject') injects++
else if (e.kind === 'compaction') compactions++
else if (e.kind === 'prune') prunes++
}
result.counts = { turns: turns.size, steps: state.requests.length, injects, compactions, prunes }
const last = state.requests.at(-1)
if (last !== undefined) {
result.last = { seq: last.seq, total: last.total, ...(typeof last.prompt === 'number' ? { prompt: last.prompt } : {}) }
}
result.detailRev = state.detailRev ?? 0
return result
}
/**
* The on-demand detail payload (host/detail.ts serves it off the live fold
* state): the heavy collections plus the revision marker the head carries.
*/
export function buildTimelineDetail(state: TimelineState, bounds: FoldBounds): ContextTimelineDetail {
return { rev: state.detailRev ?? 0, ...detailCollectionsOf(state, bounds) }
}
/**
* Serve the INLINE projection wire view (channel-less hosts): the head
* scalars with the detail collections in place — the shape every delivery
* channel carried before the split generation. Bound the surface nodes to
* the newest tail and attach each event to the request around it; stamp
* COPIES — the persisted state objects are never mutated.
*/
export function buildTimelineView(state: TimelineState, bounds: FoldBounds): Snapshot {
return { ...headFieldsOf(state), ...detailCollectionsOf(state, bounds) }
}
+16 -7
View File
@@ -2,11 +2,14 @@
* dsh-context — Host half (installed package entry).
*
* A plain Cordis plugin module (ESM) loaded by the harness as the
* `dsh-context` loader row. Since v0.9 the Host half is a single *projection
* unit* (`timeline.ts`): registered on `ctx.sessionProjections`, it folds a
* `dsh-context` loader row. Since v0.9 the Host half is built on *projection
* units* (`timeline.ts`): registered on `ctx.sessionProjections`, they fold a
* session's durable event log into the per-request context-composition
* timeline and lets the harness stream the finished value to the browser
* through its push pipeline. There is no custom RPC channel anymore.
* timeline and let the harness stream the finished values to the browser
* through its push pipeline. The one exception is the on-demand detail
* channel (detail.ts): a single generic Connection RPC endpoint serving the
* heavy collections per VIEWING client, so the wire value every channel
* carries whole stays a slim head.
*
* Required service: the session-projection registry (the framework drives
* the unit over `session/event` and persists its state via the projection
@@ -19,7 +22,8 @@
import type { Context } from '@deepseek-ai/cordis'
import { createToolAttribution } from './attribution'
import { Config } from './config'
import { Config, resolveBounds } from './config'
import { watchDetailChannel } from './detail'
import { createFallbackHeadersDefinition, createFallbackTimelineDefinition } from './fallback'
import { createContextHeadersDefinition } from './headers'
import { installSettings } from './settings'
@@ -58,7 +62,12 @@ export function apply(ctx: Context, config: Config): void {
// third-party / agent-scoped / dynamic tools on top. Strictly additive — an
// unsupported cordis or a missed read degrades to the static chain.
const attribution = createToolAttribution(ctx)
ctx.sessionProjections.register(createContextTimelineDefinition(config))
// The split wire generation (detail.ts): the detail channel arms whenever
// the connection/sessions services compose (load order never assumed), and
// the unit's view reads the gate per serve — slim while the channel is
// live, inline otherwise.
const gate = watchDetailChannel(ctx, resolveBounds(config))
ctx.sessionProjections.register(createContextTimelineDefinition(config, () => gate.live))
ctx.sessionProjections.register(createContextHeadersDefinition(name => attribution.ownerOf(name)))
installSettings(ctx)
}
@@ -66,6 +75,6 @@ export function apply(ctx: Context, config: Config): void {
// ---- public type surface (stable for downstream consumers) -------------------
export type { Category, ContextEventRecord, RequestRecord, Snapshot, ContextTimeline, SurfaceNode } from '../shared/types'
export type { ContextHeaders, HeaderRecord, HeaderTool } from '../shared/types'
export type { ContextHeaders, HeaderRecord, HeaderTool, ContextTimelineDetail, TimelineCounts, TimelineLast } from '../shared/types'
export type { TimelineState } from './fold'
export type { HeadersState } from './headers'
+51 -9
View File
@@ -23,7 +23,7 @@ import type { Config } from './config'
import { resolveBounds } from './config'
import type { ProjectionDefinition } from './compat'
import type { ContextTimeline } from '../shared/types'
import { applyTimeline, buildTimelineView, createTimelineState } from './fold'
import { applyTimeline, buildTimelineHead, buildTimelineView, createTimelineState } from './fold'
import type { TimelineState } from './fold'
/** Validate the wire payload before it leaves the host (strict: no drift). */
@@ -121,7 +121,30 @@ const unsupportedSchema = z.object({
minimum: z.string(),
}).strict()
/** Exported for the fallback unit (fallback.ts): one wire contract, one schema. */
/** The stats board's precomputed count figures (the split head — see Snapshot.counts). */
const countsSchema = z.object({
turns: z.number().int().nonnegative(),
steps: z.number().int().nonnegative(),
injects: z.number().int().nonnegative(),
compactions: z.number().int().nonnegative(),
prunes: z.number().int().nonnegative(),
}).strict()
/** The newest retained request's billing summary (the split head's headline anchor). */
const lastSchema = z.object({
seq: z.number(),
total: z.number().int().nonnegative(),
prompt: z.number().int().nonnegative().optional(),
}).strict()
/**
* One wire contract for both generations: the SPLIT head (envelope scalars +
* counts/last/detailRev; the heavy collections stay absent — they ride the
* on-demand detail channel, host/detail.ts) and the INLINE value
* (channel-less hosts and the fallback unit carry the collections in place).
* The collections are therefore optional on the schema; the split marker is
* `detailRev` (present ⟺ split).
*/
export const contextTimelineSchema = z.object({
ok: z.literal(true),
unsupported: unsupportedSchema.optional(),
@@ -131,13 +154,16 @@ export const contextTimelineSchema = z.object({
current: currentSchema,
images: z.number().int().nonnegative().optional(),
toolCalls: z.number().int().nonnegative().optional(),
requests: z.array(requestRecordSchema),
events: z.array(contextEventSchema),
counts: countsSchema.optional(),
last: lastSchema.optional(),
detailRev: z.number().int().nonnegative().optional(),
requests: z.array(requestRecordSchema).optional(),
events: z.array(contextEventSchema).optional(),
cost: z.object({ flash: costFamilySchema.optional(), pro: costFamilySchema.optional() }).strict().optional(),
timing: timingTotalsSchema.optional(),
nodes: z.array(surfaceNodeSchema),
droppedNodes: z.number().int().nonnegative(),
archive: z.array(surfaceNodeSchema),
nodes: z.array(surfaceNodeSchema).optional(),
droppedNodes: z.number().int().nonnegative().optional(),
archive: z.array(surfaceNodeSchema).optional(),
surfaceFloor: z.number().int().nonnegative().optional(),
archiveFloor: z.number().int().nonnegative().optional(),
}).strict() as unknown as z.ZodType<ContextTimeline>
@@ -172,6 +198,7 @@ const timelineStateSchema = z.object({
callNames: z.record(z.string(), z.object({ name: z.string(), start: z.number() }).strict()),
pendingShadowedSeqs: z.array(z.number()).optional(),
pendingShadowEventSeq: z.number().optional(),
detailRev: z.number().int().nonnegative().optional(),
}) as unknown as z.ZodType<TimelineState>
/**
@@ -192,10 +219,25 @@ const timelineStateSchema = z.object({
* `wire` block the registry treats the unit as host-only and never delivers
* `contextTimeline` to the browser (the Context tab would stay on its
* loading screen forever).
*
* `slim` selects the wire generation PER SERVE (a liveness probe, not a
* fixed flag): while the on-demand detail channel is live (host/detail.ts),
* the wire value is the SLIM head (buildTimelineHead) — the heavy
* collections no longer ride every session.list row, control baseline,
* follow snapshot, and push frame. Before the channel arms (the connection
* service may activate after this plugin) or on a deployment whose
* connection/sessions services never compose, the unit serves the INLINE
* value so the tab keeps working end to end. Both generations validate
* against the same schema (the collections are optional on it), and both
* fold the SAME state — the split is view-only, so no `stateVersion` bump
* and no cached-row invalidation comes with it (the `detailRev` state field
* is additive-optional: older rows restore without it and read as revision
* 0).
*/
export function createContextTimelineDefinition(config: Config): ProjectionDefinition<'contextTimeline', TimelineState> {
export function createContextTimelineDefinition(config: Config, slim: () => boolean): ProjectionDefinition<'contextTimeline', TimelineState> {
const bounds = resolveBounds(config)
const view = (state: TimelineState): ContextTimeline => buildTimelineView(state, bounds)
const view = (state: TimelineState): ContextTimeline =>
slim() ? buildTimelineHead(state) : buildTimelineView(state, bounds)
const definition: ProjectionDefinition<'contextTimeline', TimelineState> = {
key: 'contextTimeline',
stateSchema: timelineStateSchema,
+72 -4
View File
@@ -20,10 +20,14 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
// dsh 0.1.1 — the definition's K constraint moved to the state map).
interface SessionProjectionMap {
/**
* The plugin's whole-value context timeline: current composition,
* per-request history, context events, and the model-visible surface.
* The Host folds it from the session log; clients receive the finished
* value (key absence = the plugin's host half is not composed).
* The plugin's context timeline: current composition, counters, and the
* headline anchor. Since the split generation the wire value is the SLIM
* head (every delivery channel — session.list rows, control baselines,
* push frames — carries it whole); the per-request history, context
* events, and the surface/archive collections ride the on-demand detail
* channel ({@link ContextTimelineDetail}, host/detail.ts). Channel-less
* hosts keep the inline generation (the collections stay in the value).
* Key absence = the plugin's host half is not composed.
*/
contextTimeline: ContextTimeline
/**
@@ -44,6 +48,33 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
export type Category = 'user' | 'inject' | 'assistant' | 'tool'
/**
* The stats board's count figures, precomputed host-side over the RETAINED
* request/event records (the same set the detail payload serves). Carried by
* the split-generation wire head so the board — and the Agent card's
* per-session request tally — never need the collections themselves.
* `steps` doubles as the retained request-record count.
*/
export interface TimelineCounts {
turns: number
steps: number
injects: number
compactions: number
prunes: number
}
/**
* The newest retained request record's billing summary — the headline's
* derived-occupancy anchor (`prompt + surface movement since`), carried by
* the split-generation wire head so the headline never needs the request
* records themselves.
*/
export interface TimelineLast {
seq: number
total: number
prompt?: number
}
/**
* The per-user display-preference vocabulary of the `dsh-context` settings
* namespace — the ONE declaration both halves share: the Host registers the
@@ -106,6 +137,23 @@ export interface Snapshot {
* hosts; clients treat absence as zero.
*/
toolCalls?: number
/**
* Split-generation head fields — present exactly when the host serves the
* SLIM head (the heavy collections moved to the on-demand detail channel,
* host/detail.ts) and absent on the inline generation (older or
* channel-less hosts serve the collections in place). `detailRev` is the
* detail's revision marker: it bumps whenever the detail collections
* change, so an open tab refetches on the push alone.
*/
counts?: TimelineCounts
last?: TimelineLast
detailRev?: number
/**
* The per-request history / context-event collections. On the split
* generation these stay ABSENT from the wire value (every session.list row,
* control baseline, and push frame would carry them whole otherwise); the
* client fills them from the detail channel ({@link ContextTimelineDetail}).
*/
requests: RequestRecord[]
events: ContextEventRecord[]
/**
@@ -148,6 +196,26 @@ export interface Snapshot {
archiveFloor?: number
}
/**
* The on-demand DETAIL payload of the split `contextTimeline` generation —
* the heavy collections (per-request records, context events, the served
* surface window, and the removed-node archive) that the slim wire head no
* longer carries through every delivery channel. The host serves it off the
* live fold state at the `/dsh-context` `detail` endpoint (host/detail.ts);
* `rev` mirrors the head's `detailRev` at build time and acts as the
* client's latest-wins cursor.
*/
export interface ContextTimelineDetail {
rev: number
requests: RequestRecord[]
events: ContextEventRecord[]
nodes: SurfaceNode[]
droppedNodes: number
archive: SurfaceNode[]
surfaceFloor?: number
archiveFloor?: number
}
/**
* The `contextTimeline` projection's whole value — the same snapshot the Client has always rendered. `ok` is always `true` here (a
* delivered projection is by definition available); kept for wire compatibility with the snapshot shape.
+26
View File
@@ -26,6 +26,24 @@ export interface ClientSeam {
markdownChrome: string
/** The platform module table the shell seeds (client-bundle requires must resolve). */
platformModules: readonly string[]
/**
* The faces the split timeline's on-demand detail channel rides
* (host/detail.ts + client/timelineSource.ts): the host's generic
* Connection RPC registry, the browser caller, and the projection
* registry's `stateOf` read. Each probe is a needle in the named source
* file of the baseline tag.
*/
detailChannel: {
/** `HostConnectionRpc` in the connection package's shared rpc source. */
hostRpcFile: string
hostRpcNeedle: string
/** The browser caller's `call(channel, endpoint, payload…)` in the connection client source. */
clientRpcFile: string
clientRpcNeedle: string
/** The registry's `stateOf` unit-state read (the detail endpoint's data source). */
registryFile: string
registryNeedle: string
}
}
export interface Baseline {
@@ -60,6 +78,14 @@ export const BASELINES: readonly Baseline[] = [
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-ui-primitives',
],
detailChannel: {
hostRpcFile: 'packages/client/connection/src/rpc.ts',
hostRpcNeedle: 'HostConnectionRpc',
clientRpcFile: 'packages/client/connection/src/client/rpc.ts',
clientRpcNeedle: 'call(channel, endpoint, payload',
registryFile: 'packages/session/session-projection/src/index.ts',
registryNeedle: 'stateOf<',
},
},
},
]
+13
View File
@@ -130,6 +130,19 @@ describe('agentStatsOf', () => {
assert.deepEqual(stats.identity, { mode: 'continuable', label: 'helper' })
})
test('split-generation rows: the precomputed tally serves without the request records', () => {
// The slim wire head carries no `requests` collection — the Agent card
// reads the host-precomputed tally off `counts.steps` instead.
const slim = {
...timeline(500, 0),
counts: { turns: 2, steps: 7, injects: 1, compactions: 0, prunes: 0 },
detailRev: 7,
}
const stats = agentStatsOf({ contextTimeline: slim })
assert.equal(stats.requests, 7)
assert.ok(stats.head !== null, 'the head still derives from the slim row')
})
test('pressure-only rows still yield an occupancy head without slices', () => {
const projected = agentStatsOf({ contextPressure: { projectedTokens: 250, contextWindow: 1000 } })
assert.deepEqual(projected.head, { tokens: 250, window: 1000, pct: 25, parts: [] })
+37
View File
@@ -9,6 +9,7 @@ import { describe, test } from 'vitest'
import { h } from '../../../src/client/react'
import { makeContextBrowser, type ContextBrowserProps } from '../../../src/client/components/browser'
import { makeStackedBar } from '../../../src/client/components/stackedBar'
import { DICT_EN } from '../../../src/client/i18n'
import { UNKNOWN_TOOL_SOURCE, type ContextHeaders, type ContextTimeline, type HeaderEpochContent, type RequestRecord, type SurfaceNode } from '../../../src/shared/types'
import { headersOf, type ConversationNodeLike, type ImageLoader } from '../../../src/client/services'
import { click, flush, hover, makeKit, mount, query, queryAll, text, unhover, type Mounted } from '../helpers/kit'
@@ -1468,3 +1469,39 @@ describe('ContextBrowser focus bridges', () => {
await m.unmount()
})
})
describe('ContextBrowser — the split generation detail states', () => {
test('a pending detail read names the state on the note strip', async () => {
const m = await mount(h(Browser, { data: tl({}), headers: null, detailState: 'loading' }))
assert.ok(text(m.container).includes(DICT_EN['detail.loading']))
// The picker still offers the live surface (the sections read zero until the detail lands).
assert.equal(queryAll(m.container, '.lc-br-pick option').length, 1)
await m.unmount()
})
test('a failed detail read arms the retry button on the strip', async () => {
let retries = 0
const m = await mount(h(Browser, {
data: tl({}),
headers: null,
detailState: 'failed',
onDetailRetry: () => { retries++ },
}))
await click(query(m.container, '.lc-br-retry'))
assert.equal(retries, 1)
await m.unmount()
})
test('a landed (or inline) detail renders no strip', async () => {
for (const state of ['ready', 'legacy'] as const) {
const m = await mount(h(Browser, { data: tl({}), headers: null, detailState: state }))
assert.ok(!text(m.container).includes(DICT_EN['detail.loading']))
assert.ok(!text(m.container).includes(DICT_EN['detail.loadFailed']))
await m.unmount()
}
// And the prop-less caller (older hosts) renders nothing either.
const m = await mount(h(Browser, { data: tl({}), headers: null }))
assert.ok(queryAll(m.container, '.lc-br-retry').length === 0)
await m.unmount()
})
})
@@ -406,3 +406,56 @@ describe('ContextModal', () => {
await m.unmount()
})
})
describe('ContextModal — the split generation', () => {
/** Poll until the predicate holds (the detail store's debounce rides real timers). */
async function until(fn: () => boolean, message: string): Promise<void> {
for (let i = 0; i < 400; i++) {
if (fn()) return
await new Promise(resolve => setTimeout(resolve, 5))
}
assert.fail(message)
}
test('the modal shares the detail channel: the browser lands the collections on open', async () => {
const ctx = new TestClientCtx({
services: {
sessions: new TestSessions(),
connection: {
rpc: {
call: async () => ({
ok: true,
value: {
rev: 1,
requests: [{ seq: 1, turn: 1, step: 1, time: 1, system: 1, tools: 2, user: 3, inject: 4, assistant: 5, tool: 6, total: 21 }],
events: [],
nodes: [{ seq: 1, cat: 'assistant', tokens: 5, text: 'modal reply' }],
droppedNodes: 0,
archive: [],
},
}),
},
},
},
})
const ContextModal = makeContextModal(asClientCtx(ctx), kit)
const head = timeline({
counts: { turns: 1, steps: 1, injects: 0, compactions: 0, prunes: 0 },
last: { seq: 1, total: 21, prompt: 20 },
detailRev: 1,
})
const m = await mount(h(ContextModal, {
sessionId: 'sm-slim',
useContextModal: OPEN,
useProjection: (key: string) => (key === 'contextTimeline' ? head : undefined),
}))
// The composition card paints off the head while the browser's detail note shows.
assert.ok(text(m.container).includes(DICT_EN['overview.title']))
assert.ok(text(m.container).includes(DICT_EN['detail.loading']))
// The detail lands: the browser serves the step picker + the sections.
await until(() => !text(m.container).includes(DICT_EN['detail.loading']), 'the modal detail never landed')
assert.equal(queryAll(m.container, '.lc-br-pick option').length, 2, 'live + the one served step')
assert.ok(text(m.container).includes('1 Item'), 'the assistant section counts the served node')
await m.unmount()
})
})
+105
View File
@@ -943,3 +943,108 @@ describe('ContextView — chat→Context jump', () => {
await m2.unmount()
})
})
describe('ContextView — the split generation (slim head + detail channel)', () => {
/** A slim wire head: the pushed value with the collections empty and the counters/markers on. */
function slimHead(over: Record<string, unknown> = {}): ContextTimeline {
return timeline({
model: 'deepseek-v4-flash',
provider: 'deepseek',
contextWindow: 128000,
toolCalls: 3,
images: 1,
counts: { turns: 1, steps: 3, injects: 1, compactions: 1, prunes: 0 },
last: { seq: 6, total: 420, prompt: 410 },
detailRev: 6,
...over,
})
}
/** The matching detail payload (richTimeline's collections). */
function slimDetail(rev = 6): Record<string, unknown> {
const rich = richTimeline()
return {
rev,
requests: rich.requests,
events: rich.events,
nodes: rich.nodes,
droppedNodes: rich.droppedNodes,
archive: rich.archive,
}
}
/** A ctx whose connection.rpc.call serves (or fails) the detail endpoint. */
function slimCtx(serve: () => Promise<unknown>): TestClientCtx {
return new TestClientCtx({
services: {
connection: { rpc: { call: (_channel: string, _endpoint: string, _payload: unknown) => serve() } },
},
})
}
async function until(fn: () => boolean, message: string): Promise<void> {
for (let i = 0; i < 400; i++) {
if (fn()) return
await new Promise(resolve => setTimeout(resolve, 5))
}
assert.fail(message)
}
test('the head paints the counters immediately; the detail collections land through the channel', async () => {
const ctx = slimCtx(async () => ({ ok: true, value: slimDetail() }))
const View = makeView(ctx)
const m = await mount(h(View, { sessionId: 'sv-slim', useProjection: projectionsFor(slimHead()) }))
// First paint: the counters are real (no detail needed), the detail cards name the pending read.
const values = queryAll(m.container, '.lc-stat-value').map(el => text(el))
assert.deepEqual(values.slice(0, 2), ['1', '3'], 'turns/steps from the head counters')
assert.ok(text(m.container).includes(DICT_EN['detail.loading']))
assert.equal(queryAll(m.container, '.lc-bar').length, 0, 'the chart waits for the detail')
assert.equal(queryAll(m.container, '.lc-br-pick option').length, 1, 'the picker holds only the live row')
// The detail lands: every card renders its real content, the notes clear.
await until(() => queryAll(m.container, '.lc-bar').length === 3, 'the detail never landed')
assert.ok(!text(m.container).includes(DICT_EN['detail.loading']))
assert.ok(text(m.container).includes('heads-up'), 'the events list serves the detail')
assert.equal(queryAll(m.container, '.lc-br-pick option').length, 4, 'live + three steps')
assert.ok(text(m.container).includes('reply three'), 'the brief rows serve the detail')
assert.ok(text(m.container).includes(DICT_EN['files.empty']), 'no file ops in this detail')
await m.unmount()
})
test('a failed detail read arms the retry notes; one click refires and the cards land', async () => {
let online = false
const ctx = slimCtx(async () => {
if (!online) throw new Error('offline')
return { ok: true, value: slimDetail() }
})
const View = makeView(ctx)
const m = await mount(h(View, { sessionId: 'sv-slim-fail', useProjection: projectionsFor(slimHead()) }))
await until(() => text(m.container).includes(DICT_EN['detail.loadFailed']), 'the failure never surfaced')
assert.equal(queryAll(m.container, '.lc-bar').length, 0)
online = true
await click(query(m.container, '.lc-br-retry'))
await until(() => queryAll(m.container, '.lc-bar').length === 3, 'the retry never recovered the cards')
assert.ok(!text(m.container).includes(DICT_EN['detail.loadFailed']))
assert.ok(text(m.container).includes('heads-up'))
await m.unmount()
})
test('the jump relay survives the split: held while the detail reads, resolves when it lands', async () => {
const ctx = slimCtx(async () => ({ ok: true, value: slimDetail() }))
const View = makeView(ctx)
// The jump is armed BEFORE the view mounts (the chat action relay) — the
// detail read is still pending, so the pin must wait for it (not consume
// and clamp against an empty record list).
requestContextFocus('sv-slim-jump', 4)
const m = await mount(h(View, { sessionId: 'sv-slim-jump', useProjection: projectionsFor(slimHead()) }))
assert.equal(queryAll(m.container, '.lc-bar-selected').length, 0, 'nothing pins before the detail')
await until(
() => queryAll(m.container, '.lc-bar[data-seq="4"]')[0]?.className.includes('lc-bar-selected') === true,
'the jump never resolved',
)
assert.equal(takeContextFocus('sv-slim-jump'), null)
await m.unmount()
})
})
@@ -0,0 +1,48 @@
// DetailNote (src/client/components/detailNote.tsx): the split generation's
// detail-pending note — loading strip, the failed state's retry button, and
// the inert plain-text failure when no retry is wired.
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { h } from '../../../src/client/react'
import { makeDetailNote } from '../../../src/client/components/detailNote'
import { DICT_EN, DICT_ZH } from '../../../src/client/i18n'
import { click, makeKit, mount, query, text } from '../helpers/kit'
const DetailNote = makeDetailNote(makeKit())
const DetailNoteZh = makeDetailNote(makeKit('zh'))
describe('DetailNote', () => {
test('the loading strip names the pending read', async () => {
const m = await mount(h(DetailNote, { state: 'loading' as const }))
assert.equal(text(m.container), DICT_EN['detail.loading'])
assert.equal(query(m.container, 'div').className, 'lc-empty', 'the default well class')
await m.unmount()
})
test('the failed strip arms the retry button; the caller class applies', async () => {
let retries = 0
const m = await mount(h(DetailNote, {
state: 'failed' as const,
onRetry: () => { retries++ },
className: 'lc-br-note',
}))
assert.equal(query(m.container, 'div').className, 'lc-br-note')
await click(query(m.container, '.lc-br-retry'))
assert.equal(retries, 1)
await m.unmount()
})
test('a failed note without a retry callback renders the inert text', async () => {
const m = await mount(h(DetailNote, { state: 'failed' as const }))
assert.equal(text(m.container), DICT_EN['detail.loadFailed'])
assert.equal(m.container.querySelector('button'), null)
await m.unmount()
})
test('the zh locale localizes both states', async () => {
const m = await mount(h(DetailNoteZh, { state: 'loading' as const }))
assert.equal(text(m.container), DICT_ZH['detail.loading'])
await m.unmount()
})
})
+25 -1
View File
@@ -9,7 +9,8 @@ import { h } from '../../../src/client/react'
import { makeEventList, makeEventText } from '../../../src/client/components/events'
import { fmtTime } from '../../../src/client/format'
import type { ContextEventRecord } from '../../../src/shared/types'
import { flush, makeKit, mount, query, queryAll, text } from '../helpers/kit'
import { DICT_EN } from '../../../src/client/i18n'
import { click, flush, makeKit, mount, query, queryAll, text } from '../helpers/kit'
const kit = makeKit()
const { eventLabel, eventAt } = makeEventText(kit.t)
@@ -197,3 +198,26 @@ describe('EventList', () => {
await m.unmount()
})
})
describe('EventList — the split generation detail states', () => {
test('a pending detail read shows the loading note instead of the empty claim', async () => {
const m = await mount(h(EventList, { events: [], state: 'loading', onRetry: () => {} }))
assert.ok(text(m.container).includes(DICT_EN['detail.loading']))
assert.ok(!text(m.container).includes('No context events yet'))
await m.unmount()
})
test('a failed detail read arms the retry button; a failed read without one falls back to the empty claim', async () => {
let retries = 0
const m = await mount(h(EventList, { events: [], state: 'failed', onRetry: () => { retries++ } }))
const retry = query(m.container, '.lc-br-retry')
assert.ok(text(m.container).includes(DICT_EN['detail.loadFailed']))
await click(retry)
assert.equal(retries, 1)
const inert = await mount(h(EventList, { events: [], state: 'failed' }))
assert.ok(text(inert.container).includes('No context events yet'), 'no retry wired — the plain empty state')
await inert.unmount()
await m.unmount()
})
})
+31
View File
@@ -523,3 +523,34 @@ describe('FileCard — workspace display and system open', () => {
await m2.unmount()
})
})
describe('FileCard — the split generation detail states', () => {
test('a pending detail read shows the loading note instead of the empty claim', async () => {
const m = await mount(h(FileCard, {
activity: richActivity({ entries: [] }),
scope: 'live',
state: 'loading',
onRetry: () => {},
}))
assert.ok(text(m.container).includes('Loading history'))
assert.ok(!text(m.container).includes('No file reads'))
await m.unmount()
})
test('a failed detail read arms the retry button; without one the empty claim stays', async () => {
let retries = 0
const m = await mount(h(FileCard, {
activity: richActivity({ entries: [] }),
scope: 'live',
state: 'failed',
onRetry: () => { retries++ },
}))
await click(query(m.container, '.lc-br-retry'))
assert.equal(retries, 1)
const inert = await mount(h(FileCard, { activity: richActivity({ entries: [] }), scope: 'live', state: 'failed' }))
assert.ok(text(inert.container).includes('No file reads'), 'no retry wired — the plain empty state')
await inert.unmount()
await m.unmount()
})
})
+36 -9
View File
@@ -1,11 +1,14 @@
// StatsContext (src/client/components/statsContext.tsx) rendered with real
// React: the eight-cell 2×4 grid — session shape, the priced cost cell with
// its rate tooltip, and the context-event tally — in both locales.
// its rate tooltip, and the context-event tally — in both locales. The count
// figures arrive precomputed (the split generation's wire head carries them);
// `countsOfRecords` is the inline generation's derivation, pinned here to the
// same totals.
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { h } from '../../../src/client/react'
import { makeStatsContext } from '../../../src/client/components/statsContext'
import { countsOfRecords, makeStatsContext } from '../../../src/client/components/statsContext'
import type { ContextEventRecord, RequestRecord, SessionCostUsage } from '../../../src/shared/types'
import { makeKit, mount, query, queryAll, text } from '../helpers/kit'
@@ -35,12 +38,26 @@ function cells(container: HTMLElement): { labels: string[]; values: string[] } {
}
}
describe('countsOfRecords (the inline generation derivation)', () => {
test('tallies distinct turns, records, and the three priced event kinds', () => {
// Two steps in turn 1, one in turn 2, one without a turn (folds as turn 0).
const counts = countsOfRecords(
[req(1), req(1), req(2), req()],
[ev('inject'), ev('inject'), ev('inject'), ev('compaction'), ev('compaction'), ev('prune'), ev('model'), ev('mode')],
)
// model/mode events do not appear (only the three priced kinds do).
assert.deepEqual(counts, { turns: 3, steps: 4, injects: 3, compactions: 2, prunes: 1 })
})
test('empty collections tally zero', () => {
assert.deepEqual(countsOfRecords([], []), { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 })
})
})
describe('StatsContext', () => {
test('folds the eight-cell grid: shape stats, cost, and the event tally', async () => {
const m = await mount(h(StatsContext, {
// Two steps in turn 1, one in turn 2, one without a turn (folds as turn 0).
requests: [req(1), req(1), req(2), req()],
events: [ev('inject'), ev('inject'), ev('inject'), ev('compaction'), ev('compaction'), ev('prune'), ev('model'), ev('mode')],
counts: { turns: 3, steps: 4, injects: 3, compactions: 2, prunes: 1 },
toolCalls: 3,
images: 2,
cost: COST,
@@ -53,19 +70,25 @@ describe('StatsContext', () => {
'Turns', 'Steps', 'Tool Calls', 'Images',
'Cost?', 'Injections', 'Compactions', 'Prunes',
])
// model/mode events do not appear (only their three priced kinds do).
assert.deepEqual(values, ['3', '4', '3', '2', '$0.44', '3', '2', '1'])
await m.unmount()
})
test('absent counters and cost degrade to zeros and the dash', async () => {
const m = await mount(h(StatsContext, { requests: [], events: [], locale: 'en' }))
const m = await mount(h(StatsContext, {
counts: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 },
locale: 'en',
}))
assert.deepEqual(cells(m.container).values, ['0', '0', '0', '0', '—', '0', '0', '0'])
await m.unmount()
})
test('only the cost cell is tipped; the bubble lists both families with peak/off rates', async () => {
const m = await mount(h(StatsContext, { requests: [], events: [], cost: COST, locale: 'en' }))
const m = await mount(h(StatsContext, {
counts: { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 },
cost: COST,
locale: 'en',
}))
assert.equal(queryAll(m.container, '.lc-stat-tip').length, 1)
assert.equal(queryAll(m.container, '.lc-stat-q').length, 1)
const tip = text(query(m.container, '.lc-stat-tip'))
@@ -78,7 +101,11 @@ describe('StatsContext', () => {
})
test('the zh locale localizes labels and prices the cost in CNY', async () => {
const m = await mount(h(StatsContextZh, { requests: [req(1)], events: [ev('compaction')], cost: COST, locale: 'zh' }))
const m = await mount(h(StatsContextZh, {
counts: { turns: 1, steps: 1, injects: 0, compactions: 1, prunes: 0 },
cost: COST,
locale: 'zh',
}))
assert.ok(text(m.container).includes('上下文统计'))
const { labels, values } = cells(m.container)
assert.deepEqual(labels, ['轮次', '步数', '工具调用', '图片', '预估费用?', '注入', '压缩', '剪枝'])
+11
View File
@@ -66,6 +66,17 @@ describe('headlineOf occupancy', () => {
const h = headlineOf(timeline())
assert.equal(h.tokens, 100)
})
test('the split head anchors the derivation from `last` (the records ride the detail channel)', () => {
// lastReq.prompt + surface movement since: 150 + (100 - 80).
const h = headlineOf(timeline({ last: { seq: 9, total: 80, prompt: 150 } }), null)
assert.equal(h.tokens, 170)
})
test('a `last` without a numeric prompt yields no derived anchor', () => {
const h = headlineOf(timeline({ last: { seq: 9, total: 80 } }), null)
assert.equal(h.tokens, 100)
})
})
describe('headlineOf window and pct', () => {
+57
View File
@@ -223,6 +223,63 @@ describe('timelineOf', () => {
assert.ok(!('unsupported' in out), JSON.stringify(bad))
}
})
test('the split-generation slim head: counters/anchor/revision survive, the absent collections zero out', () => {
const head = {
ok: true,
model: 'm',
current,
counts: { turns: 3, steps: 12, injects: 2, compactions: 1, prunes: 0 },
last: { seq: 9, total: 100, prompt: 90 },
detailRev: 12,
}
const out = timelineOf(head)
assert.ok(out !== null)
assert.deepEqual(out.counts, { turns: 3, steps: 12, injects: 2, compactions: 1, prunes: 0 })
assert.deepEqual(out.last, { seq: 9, total: 100, prompt: 90 })
assert.equal(out.detailRev, 12)
assert.deepEqual(out.requests, [])
assert.deepEqual(out.nodes, [])
assert.deepEqual(out.archive, [])
})
test('the slim head fields re-prove: partial counts zero per field, shapeless last/detailRev drop', () => {
const out = timelineOf({ current, counts: { turns: 2, steps: 'many' }, last: { seq: 'x' }, detailRev: 'r' })
assert.ok(out !== null)
assert.deepEqual(out.counts, { turns: 2, steps: 0, injects: 0, compactions: 0, prunes: 0 })
assert.ok(!('last' in out), 'a wrong-typed seq drops the anchor whole')
assert.ok(!('detailRev' in out), 'a wrong-typed revision drops the marker (reads as the inline generation)')
const noCounts = timelineOf({ current, counts: 'junk', last: null, detailRev: NaN })
assert.ok(noCounts !== null)
assert.ok(!('counts' in noCounts))
assert.ok(!('last' in noCounts))
assert.ok(!('detailRev' in noCounts))
// A wrong-typed total drops the anchor too; a prompt-less anchor keeps just seq/total.
const badTotal = timelineOf({ current, last: { seq: 1, total: 'x' } })
assert.ok(badTotal !== null && !('last' in badTotal))
const noPrompt = timelineOf({ current, last: { seq: 1, total: 7 } })
assert.ok(noPrompt !== null)
assert.deepEqual(noPrompt.last, { seq: 1, total: 7 })
})
test('a slim head carrying the (empty) collections passes through by reference, markers intact', () => {
// The host's head builder emits the four collections as empty lists, so
// the split generation rides the cheap pass-through path like any
// well-formed value — the markers/counts survive untouched.
const head = {
ok: true,
current,
counts: { turns: 1, steps: 1, injects: 0, compactions: 0, prunes: 0 },
last: { seq: 1, total: 7 },
detailRev: 1,
requests: [],
events: [],
nodes: [],
archive: [],
droppedNodes: 0,
}
assert.equal(timelineOf(head), head)
})
})
describe('unsupportedOf', () => {
+498
View File
@@ -0,0 +1,498 @@
// The timeline source (src/client/timelineSource.ts): the detail payload
// narrow, the Connection-RPC fetcher, the per-session detail store machine
// (debounce / single-flight / latest-wins / refold / retry / backoff), and
// the useTimelineSource hook merging both wire generations into the value
// the cards render.
import assert from 'node:assert/strict'
import { describe, test, vi, afterEach } from 'vitest'
import { act } from 'react'
import type { ReactElement } from 'react'
import { h } from '../../src/client/react'
import type { ClientCtx } from '../../src/client/services'
import {
DetailStore,
detailOf,
detailStoreOf,
makeDetailFetcher,
resetTimelineDetailStores,
useTimelineSource,
} from '../../src/client/timelineSource'
import type { ContextTimeline, ContextTimelineDetail } from '../../src/shared/types'
import { TestClientCtx, asClientCtx } from './helpers/harness'
import { mount, text } from './helpers/kit'
afterEach(() => {
resetTimelineDetailStores()
vi.restoreAllMocks()
vi.useRealTimers()
})
/** A render-safe detail payload (the shape the host endpoint serves). */
function detail(rev: number, over: Record<string, unknown> = {}): ContextTimelineDetail {
return {
rev,
requests: [{ seq: rev, time: 0, system: 1, tools: 2, user: 3, inject: 0, assistant: 4, tool: 5, total: 15 }],
events: [{ seq: rev, time: 0, kind: 'inject', form: 'context' }],
nodes: [{ seq: rev, cat: 'user', tokens: 3 }],
droppedNodes: 0,
archive: [],
...over,
}
}
/** A slim wire head (the split generation's pushed value). */
function slimHead(rev: number, over: Record<string, unknown> = {}): Record<string, unknown> {
return {
ok: true,
model: 'm',
current: { system: 1, tools: 2, user: 3, inject: 0, assistant: 4, tool: 5, total: 15 },
counts: { turns: 1, steps: 1, injects: 1, compactions: 0, prunes: 0 },
last: { seq: 9, total: 15, prompt: 14 },
detailRev: rev,
...over,
}
}
/** A ctx whose connection face serves the given call behavior. */
function ctxWithCall(call: unknown): ClientCtx {
const ctx = new TestClientCtx()
if (call !== undefined) ctx.setService('connection', { rpc: { call } })
return asClientCtx(ctx)
}
/** Poll until the predicate holds (real timers ride the store's debounce). */
async function until(fn: () => boolean, message: string): Promise<void> {
for (let i = 0; i < 400; i++) {
if (fn()) return
await new Promise(resolve => setTimeout(resolve, 5))
}
assert.fail(message)
}
describe('detailOf', () => {
test('rejects non-records and revision-less/negative/NaN payloads', () => {
assert.equal(detailOf(null), null)
assert.equal(detailOf(42), null)
assert.equal(detailOf({}), null)
assert.equal(detailOf({ rev: '1' }), null)
assert.equal(detailOf({ rev: -1 }), null)
assert.equal(detailOf({ rev: NaN }), null)
})
test('narrows a served payload: collections re-proved per item, floors conditional', () => {
const d = detail(3, {
requests: [{ seq: 3 }, null, 'junk'],
nodes: [null, { seq: 2 }],
droppedNodes: 'many',
surfaceFloor: 7,
archiveFloor: 'no',
})
const narrowed = detailOf(d)
assert.ok(narrowed !== null)
assert.equal(narrowed.rev, 3)
assert.deepEqual(narrowed.requests, [{ seq: 3 }])
assert.deepEqual(narrowed.nodes, [{ seq: 2 }])
assert.equal(narrowed.droppedNodes, 0, 'a wrong-typed scalar zeroes')
assert.equal(narrowed.surfaceFloor, 7)
assert.equal(narrowed.archiveFloor, undefined, 'a wrong-typed floor stays absent')
// A numeric archiveFloor rides through.
assert.equal(detailOf(detail(1, { archiveFloor: 9 }))?.archiveFloor, 9)
})
})
describe('makeDetailFetcher', () => {
test('no session id, no connection face, or a hostile read yields no fetcher', () => {
assert.equal(makeDetailFetcher(ctxWithCall(async () => ({ ok: true, value: null })), ''), undefined)
assert.equal(makeDetailFetcher(asClientCtx(new TestClientCtx()), 's1'), undefined, 'no connection service')
assert.equal(makeDetailFetcher(ctxWithCall(undefined), 's1'), undefined)
assert.equal(makeDetailFetcher(ctxWithCall('not-a-function'), 's1'), undefined)
const hostile = { get: () => { throw new Error('hostile') } }
assert.equal(makeDetailFetcher(hostile as unknown as ClientCtx, 's1'), undefined)
})
test('the envelope contract: not-ok rejects, null value means absent, malformed value rejects', async () => {
const failed = makeDetailFetcher(ctxWithCall(async () => ({ ok: false, error: { code: 'x', message: 'm', details: {} } })), 's1')!
await assert.rejects(() => failed(), /detail rpc failed/)
const absent = makeDetailFetcher(ctxWithCall(async () => ({ ok: true, value: null })), 's1')!
assert.equal(await absent(), null)
const malformed = makeDetailFetcher(ctxWithCall(async () => ({ ok: true, value: { no: 'rev' } })), 's1')!
await assert.rejects(() => malformed(), /detail rpc malformed/)
const transport = makeDetailFetcher(ctxWithCall(async () => { throw new Error('offline') }), 's1')!
await assert.rejects(() => transport(), /offline/)
})
test('a good read resolves the narrowed detail and rides the declared channel pair', async () => {
const seen: string[] = []
const fetcher = makeDetailFetcher(ctxWithCall(async (channel: string, endpoint: string, payload: unknown) => {
seen.push(channel, endpoint)
assert.deepEqual(payload, { sessionId: 's1' })
return { ok: true, value: detail(4) }
}), 's1')!
const d = await fetcher()
assert.equal(d?.rev, 4)
assert.deepEqual(seen, ['/dsh-context', 'detail'])
})
})
describe('DetailStore', () => {
/** A fetcher with a call log and a programmable queue of outcomes. */
function scriptedFetcher(outcomes: (ContextTimelineDetail | null | Error)[]): { fetcher: () => Promise<ContextTimelineDetail | null>; calls: number[] } {
const calls: number[] = []
return {
calls,
fetcher: async () => {
calls.push(Date.now())
const next = outcomes.length > 0 ? outcomes.shift() : null
if (next instanceof Error) throw next
return next ?? null
},
}
}
async function settle(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 5))
}
test('request schedules the trailing-edge read; the snapshot transitions pending → landed', async () => {
const { fetcher, calls } = scriptedFetcher([detail(1)])
const store = new DetailStore(fetcher, 0)
const seen: string[] = []
store.subscribe(() => seen.push('change'))
assert.equal(store.getSnapshot().pending, false)
store.request(1)
assert.equal(store.getSnapshot().pending, true, 'the debounce window is armed')
await settle()
assert.equal(store.getSnapshot().detail?.rev, 1)
assert.equal(store.getSnapshot().pending, false)
assert.equal(store.getSnapshot().failed, false)
assert.equal(calls.length, 1)
assert.ok(seen.length > 0, 'listeners fired')
// The same revision asks for nothing new.
store.request(1)
await settle()
assert.equal(calls.length, 1, 'a covered revision does not refetch')
})
test('unsubscribe stops the notifications', async () => {
const { fetcher } = scriptedFetcher([detail(1)])
const store = new DetailStore(fetcher, 0)
const seen: string[] = []
const off = store.subscribe(() => seen.push('change'))
off()
store.request(1)
await settle()
assert.equal(seen.length, 0)
})
test('a rev bump during the in-flight read re-reads after settle (trailing edge)', async () => {
let gate!: () => void
const first = new Promise<ContextTimelineDetail | null>(resolve => { gate = () => resolve(detail(1)) })
let call = 0
const store = new DetailStore(() => {
call++
return call === 1 ? first : Promise.resolve(detail(2))
}, 0)
store.request(1)
await vi.waitFor(() => { assert.equal(call, 1) })
store.request(2)
gate()
await until(() => store.getSnapshot().detail?.rev === 2, 'the trailing read never landed')
assert.equal(call, 2)
})
test('latest-wins: a stale response (behind the served rev, off-target) drops; the trail re-reads', async () => {
const { fetcher, calls } = scriptedFetcher([detail(3), detail(2), detail(4)])
const store = new DetailStore(fetcher, 0)
store.request(3)
await settle()
assert.equal(store.getSnapshot().detail?.rev, 3)
store.request(4)
await until(() => store.getSnapshot().detail?.rev === 4, 'the trail never converged')
assert.equal(calls.length, 3, 'the stale rev-2 answer burned one read before the rev-4 landing')
})
test('a rev DECREASE means the host refolded: the ledger resets and refetches', async () => {
const { fetcher } = scriptedFetcher([detail(5), detail(2)])
const store = new DetailStore(fetcher, 0)
store.request(5)
await settle()
assert.equal(store.getSnapshot().detail?.rev, 5)
store.request(2)
await until(() => store.getSnapshot().detail?.rev === 2, 'the refold reset never refetched')
})
test('a transport failure with no detail arms the failed state; the backoff trailing recovers on its own', async () => {
const { fetcher, calls } = scriptedFetcher([new Error('offline'), detail(1)])
const store = new DetailStore(fetcher, 20)
store.request(1)
await until(() => store.getSnapshot().failed === true, 'the failure never surfaced')
assert.equal(store.getSnapshot().detail, null)
// No manual retry: the trailing edge re-reads after the backoff window.
await until(() => store.getSnapshot().detail?.rev === 1, 'the trailing recovery never landed')
assert.equal(store.getSnapshot().failed, false)
assert.equal(calls.length, 2)
})
test('a transport failure WITH detail keeps the served value (no flicker to failed), then recovers', async () => {
const { fetcher, calls } = scriptedFetcher([detail(1), new Error('offline'), detail(2)])
const store = new DetailStore(fetcher, 0)
store.request(1)
await until(() => store.getSnapshot().detail?.rev === 1, 'the first read never landed')
store.request(2)
// Mid-way: the failed read settles with the last good detail still serving
// (>=: the zero-debounce trail can land the recovery between two polls).
await until(() => calls.length >= 2, 'the second read never fired')
await until(() => store.getSnapshot().detail?.rev === 2, 'the trailing recovery never landed')
assert.equal(store.getSnapshot().failed, false, 'the failed read never flipped the cards to failed')
})
test('an absent answer stops the trailing; a fresh rev asks again', async () => {
const { fetcher, calls } = scriptedFetcher([null, detail(2)])
const store = new DetailStore(fetcher, 0)
store.request(1)
await settle()
assert.equal(store.getSnapshot().failed, true, 'absent with nothing to show arms the note')
await settle()
assert.equal(calls.length, 1, 'absence stops the self-trailing')
store.request(2)
await until(() => store.getSnapshot().detail?.rev === 2, 'the fresh rev never refetched')
assert.equal(store.getSnapshot().failed, false)
})
test('retry with no armed timer refires directly (the stopped-trailing shape)', async () => {
// Absence stops the trailing timer; the manual retry must not wait on one.
const { fetcher, calls } = scriptedFetcher([null, detail(1)])
const store = new DetailStore(fetcher, 0)
store.request(1)
await until(() => store.getSnapshot().failed === true, 'the absent read never settled')
assert.equal(calls.length, 1)
store.retry()
await until(() => store.getSnapshot().detail?.rev === 1, 'the timer-less retry never fired')
assert.equal(calls.length, 2)
})
test('retry during an in-flight read does not stack a second call', async () => {
let release!: (d: ContextTimelineDetail | null) => void
let started = false
const store = new DetailStore(() => {
started = true
return new Promise<ContextTimelineDetail | null>(resolve => { release = resolve })
}, 0)
store.request(1)
await until(() => started, 'the read never started')
store.retry()
release(detail(1))
await until(() => store.getSnapshot().detail?.rev === 1, 'the in-flight read never landed')
assert.equal(store.getSnapshot().failed, false)
})
test('absence with a served detail keeps it and stays silent', async () => {
const { fetcher } = scriptedFetcher([detail(1), null])
const store = new DetailStore(fetcher, 0)
store.request(1)
await settle()
store.request(2)
await until(() => store.getSnapshot().pending === false, 'the absent read never settled')
assert.equal(store.getSnapshot().detail?.rev, 1)
assert.equal(store.getSnapshot().failed, false)
})
test('without a fetcher the store types the failure (the exotic no-connection path)', async () => {
const store = new DetailStore(undefined, 0)
store.request(1)
await settle()
assert.equal(store.getSnapshot().failed, true)
assert.equal(store.getSnapshot().detail, null)
})
test('retry re-arms immediately and resets the backoff', async () => {
const { fetcher, calls } = scriptedFetcher([new Error('offline'), detail(1)])
const store = new DetailStore(fetcher, 1000)
store.request(1)
await vi.waitFor(() => { assert.equal(calls.length, 1) }, { timeout: 3000 })
await until(() => store.getSnapshot().failed === true, 'the failure never settled')
store.retry()
await until(() => store.getSnapshot().detail?.rev === 1, 'the retry never refetched')
assert.equal(calls.length, 2)
// A retry with a served detail is a no-op.
store.retry()
await settle()
assert.equal(calls.length, 2)
})
test('consecutive failures back the debounce off exponentially', async () => {
vi.useFakeTimers()
const { fetcher, calls } = scriptedFetcher([new Error('a'), new Error('b'), detail(1)])
const store = new DetailStore(fetcher, 10)
store.request(1)
await vi.advanceTimersByTimeAsync(10)
assert.equal(calls.length, 1, 'the first read fires after the base window')
assert.equal(store.getSnapshot().failed, true)
// The failed read's trailing edge waits out the DOUBLED window.
await vi.advanceTimersByTimeAsync(19)
assert.equal(calls.length, 1, 'the trailing read waits out the doubled window')
await vi.advanceTimersByTimeAsync(1)
assert.equal(calls.length, 2)
// Second failure: the window doubles again (40).
await vi.advanceTimersByTimeAsync(39)
assert.equal(calls.length, 2)
await vi.advanceTimersByTimeAsync(1)
assert.equal(calls.length, 3, 'the recovered read lands')
})
test('a rev bump arriving while a read is in flight waits out the flight, then trails', async () => {
// The in-flight guard: the bumped rev's timer fires DURING the first read
// and must not stack a second call; the settle re-arms it.
let release!: (d: ContextTimelineDetail | null) => void
let call = 0
const store = new DetailStore(() => {
call++
if (call === 1) return new Promise<ContextTimelineDetail | null>(resolve => { release = resolve })
return Promise.resolve(detail(2))
}, 0)
store.request(1)
await vi.waitFor(() => { assert.equal(call, 1) })
store.request(2)
await settle()
assert.equal(call, 1, 'no stacked read while the first is in flight')
release(detail(1))
await until(() => store.getSnapshot().detail?.rev === 2, 'the trailing read never landed')
assert.equal(call, 2)
})
})
describe('detailStoreOf', () => {
test('one store per session, shared by every consumer; reset drops the cache', () => {
const ctx = ctxWithCall(async () => ({ ok: true, value: detail(1) }))
const a = detailStoreOf(ctx, 's1')
assert.equal(a, detailStoreOf(ctxWithCall(async () => ({ ok: true, value: null })), 's1'), 'the session key wins, the first fetcher keeps serving')
assert.notEqual(a, detailStoreOf(ctx, 's2'))
resetTimelineDetailStores()
assert.notEqual(detailStoreOf(ctx, 's1'), a, 'a reset store is a fresh instance')
})
})
/** A probe component rendering the source's observable surface as text. */
function SourceProbe(props: { ctx: ClientCtx; sessionId: string; value: unknown }): ReactElement {
const source = useTimelineSource(props.ctx, {
sessionId: props.sessionId,
useProjection: (key: string) => (key === 'contextTimeline' ? props.value : undefined),
})
const data = source.data
return h('div', null,
h('span', { 'data-k': 'state' }, source.detailState),
h('span', { 'data-k': 'steps' }, data === null ? 'null' : String(data.requests.length)),
h('span', { 'data-k': 'turns' }, String(data?.counts?.turns ?? 'x')),
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': 'model' }, data?.model ?? 'x'),
h('button', { 'data-k': 'retry', onClick: source.retryDetail }, 'retry'))
}
function probeRead(container: HTMLElement, key: string): string {
return text(container.querySelector(`[data-k="${key}"]`) as HTMLElement)
}
describe('useTimelineSource', () => {
test('the inline generation passes through untouched and never fetches', async () => {
let calls = 0
const ctx = ctxWithCall(async () => {
calls++
return { ok: true, value: detail(1) }
})
const inline: ContextTimeline = {
ok: true,
model: 'inline-m',
current: { system: 1, tools: 2, user: 3, inject: 0, assistant: 4, tool: 5, total: 15 },
requests: [{ seq: 1, time: 0, system: 1, tools: 2, user: 3, inject: 0, assistant: 4, tool: 5, total: 15 }],
events: [],
nodes: [],
droppedNodes: 0,
archive: [],
}
const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: inline }))
await new Promise(resolve => setTimeout(resolve, 350))
assert.equal(probeRead(m.container, 'state'), 'legacy')
assert.equal(probeRead(m.container, 'steps'), '1')
assert.equal(probeRead(m.container, 'model'), 'inline-m')
assert.equal(calls, 0, 'the inline generation never opens the detail channel')
// The legacy retry is the inert no-op (the cards show no note).
await act(async () => {
;(m.container.querySelector('[data-k="retry"]') as HTMLElement).click()
})
await m.unmount()
})
test('no projection value keeps the loading surface', async () => {
const m = await mount(h(SourceProbe, { ctx: ctxWithCall(undefined), sessionId: 's1', value: undefined }))
assert.equal(probeRead(m.container, 'state'), 'loading')
assert.equal(probeRead(m.container, 'steps'), 'null')
await m.unmount()
})
test('the split generation: counters render off the head immediately, the detail lands through the channel', async () => {
let calls = 0
const ctx = ctxWithCall(async () => {
calls++
return { ok: true, value: detail(3, { surfaceFloor: 2, archiveFloor: 5 }) }
})
const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: slimHead(3) }))
// The first paint: the head's counters with the detail still pending.
assert.equal(probeRead(m.container, 'state'), 'loading')
assert.equal(probeRead(m.container, 'turns'), '1', 'the head counters render before the detail lands')
assert.equal(probeRead(m.container, 'steps'), '0', 'the collections wait for the channel')
assert.equal(probeRead(m.container, 'floors'), 'x/x', 'no floors before the detail')
await until(() => probeRead(m.container, 'state') === 'ready', 'the detail never landed')
assert.equal(probeRead(m.container, 'steps'), '1', 'the merged requests serve')
assert.equal(probeRead(m.container, 'rev'), '3')
assert.equal(probeRead(m.container, 'floors'), '2/5', 'the detail floors merge')
assert.ok(calls >= 1)
await m.unmount()
})
test('a rev bump on the pushed head refetches the detail', async () => {
let served = 3
const ctx = ctxWithCall(async () => ({ ok: true, value: detail(served) }))
const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: slimHead(3) }))
await until(() => probeRead(m.container, 'state') === 'ready', 'the first read never landed')
assert.equal(detailStoreOf(ctx, 's1').getSnapshot().detail?.rev, 3)
served = 4
await m.update(h(SourceProbe, { ctx, sessionId: 's1', value: slimHead(4) }))
// The probe's rev cell reads the HEAD's marker (4 as pushed); the store's
// served detail proves the refetch.
assert.equal(probeRead(m.container, 'rev'), '4')
await until(() => detailStoreOf(ctx, 's1').getSnapshot().detail?.rev === 4, 'the refetch never landed')
await m.unmount()
})
test('the failed read arms the retry affordance, which refires the read', async () => {
let online = false
let calls = 0
const ctx = ctxWithCall(async () => {
calls++
if (!online) throw new Error('offline')
return { ok: true, value: detail(1) }
})
const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: slimHead(1) }))
await until(() => probeRead(m.container, 'state') === 'failed', 'the failure never surfaced')
online = true
await act(async () => {
;(m.container.querySelector('[data-k="retry"]') as HTMLElement).click()
})
await until(() => probeRead(m.container, 'state') === 'ready', 'the retry never recovered')
assert.ok(calls >= 2)
await m.unmount()
})
test('an absent answer (the session left the live set) surfaces the failed note', async () => {
const ctx = ctxWithCall(async () => ({ ok: true, value: null }))
const m = await mount(h(SourceProbe, { ctx, sessionId: 's1', value: slimHead(1) }))
await until(() => probeRead(m.container, 'state') === 'failed', 'absence never surfaced')
await m.unmount()
})
})
+7
View File
@@ -117,6 +117,13 @@ describe.skipIf(reasons.length > 0)('compat matrix — real dsh sources per base
assert.equal(staging.dshHasString(baseline.tag, "'remote.session'", 'packages/api/*/src/**'), true)
})
test('detail channel: the Connection RPC faces and the registry stateOf exist', () => {
const dc = baseline.client.detailChannel
assert.equal(staging.dshHasString(baseline.tag, dc.hostRpcNeedle, dc.hostRpcFile), true, 'host RPC registry face')
assert.equal(staging.dshHasString(baseline.tag, dc.clientRpcNeedle, dc.clientRpcFile), true, 'browser RPC caller face')
assert.equal(staging.dshHasString(baseline.tag, dc.registryNeedle, dc.registryFile), true, 'registry stateOf face')
})
test('client: MarkdownText chrome prop', () => {
assert.equal(staging.dshHasString(baseline.tag, baseline.client.markdownChrome, 'packages/client/ui-primitives/src/markdown/MarkdownText.tsx'), true)
})
+40 -6
View File
@@ -66,7 +66,7 @@ function fullLog(): TimelineEvent[] {
function bootSession(baselineIndex: number, log = fullLog()): { driver: RegistryDriver; session: SessionLike; log: TimelineEvent[] } {
const baseline = BASELINES[baselineIndex]
const driver = new RegistryDriver(baseline)
driver.register(createContextTimelineDefinition({}))
driver.register(createContextTimelineDefinition({}, () => false))
driver.register(createContextHeadersDefinition())
const session: SessionLike = { seq: 0, header: { id: 's-matrix', cwd: '/tmp' }, inheritedEventCount: 0, events: [] }
driver.sessionCreated(session)
@@ -92,8 +92,42 @@ for (const [index, baseline] of BASELINES.entries()) {
assert.equal(headers.headers[0].tools[0].name, 'bash')
})
test('the split generation (slim head) rides the same registry contract', () => {
// The slim head is the wire value when the detail channel is live
// (host/detail.ts): the registry drives, caches, restores, and gates it
// identically — only the served value differs.
const driver = new RegistryDriver(baseline)
driver.register(createContextTimelineDefinition({}, () => true))
driver.register(createContextHeadersDefinition())
const session: SessionLike = { seq: 0, header: { id: 's-slim', cwd: '/tmp' }, inheritedEventCount: 0, events: [] }
driver.sessionCreated(session)
// One log instance (the envelope builders' clock is monotonic — a second
// call would mint fresh times and the restore comparison would drift).
const log = fullLog()
for (const event of log) {
session.events[event.seq] = event
session.seq = event.seq + 1
driver.driveEvent(session, event)
}
const timeline = driver.snapshot(session).values.contextTimeline as {
detailRev?: number
counts?: { steps: number }
nodes: unknown[]
requests: unknown[]
}
assert.equal(typeof timeline.detailRev, 'number', 'the split marker rides the push feed')
assert.equal(timeline.counts?.steps, 2, 'the counters travel on the head')
assert.equal(timeline.nodes.length, 0, 'the collections stay off the wire')
assert.equal(timeline.requests.length, 0)
// The cache contract is unchanged: plain-JSON rows, and a restore that
// refolds to the live cut.
assert.ok(driver.checkpointJson(session) !== undefined)
const cold = driver.restore({}, log, 0, session.header)
assert.deepEqual(cold.values, driver.snapshot(session).values)
})
test('init tolerates the registry call shape: the header and inherited-count arguments go unobserved', () => {
const timelineDef = createContextTimelineDefinition({})
const timelineDef = createContextTimelineDefinition({}, () => false)
const headersDef = createContextHeadersDefinition()
const probe = { id: 's-init', cwd: '/tmp' }
for (const def of [timelineDef, headersDef]) {
@@ -103,7 +137,7 @@ for (const [index, baseline] of BASELINES.entries()) {
}
// And through the driver, whose init arity follows the registry's.
const driver = new RegistryDriver(baseline)
driver.register(createContextTimelineDefinition({}))
driver.register(createContextTimelineDefinition({}, () => false))
const session: SessionLike = { seq: 0, header: probe, inheritedEventCount: 7, events: [] }
driver.sessionCreated(session)
assert.equal(driver.snapshot(session).values.contextTimeline !== undefined, true)
@@ -210,7 +244,7 @@ for (const [index, baseline] of BASELINES.entries()) {
const { driver: current, session } = bootSession(index, log)
const rows = current.checkpoint(session)
const rolledBack = new RegistryDriver(baseline)
rolledBack.register({ ...createContextTimelineDefinition({}), stateVersion: 12 })
rolledBack.register({ ...createContextTimelineDefinition({}, () => false), stateVersion: 12 })
rolledBack.register(createContextHeadersDefinition())
assert.equal(rolledBack.viewCheckpoint(rows).contextTimeline, undefined)
const restored = rolledBack.restore(rows, log, 0, session.header)
@@ -251,12 +285,12 @@ for (const [index, baseline] of BASELINES.entries()) {
test('registry registration rules: bad stateVersion and shared-key version conflicts are refused', () => {
const driver = new RegistryDriver(baseline)
const def = createContextTimelineDefinition({})
const def = createContextTimelineDefinition({}, () => false)
assert.throws(() => driver.register({ ...def, key: 'x' as never, stateVersion: -1 }), RegistryViolationError)
assert.throws(() => driver.register({ ...def, key: 'y' as never, stateVersion: 1.5 }), RegistryViolationError)
driver.register(def)
// Same key at the SAME stateVersion shares the registration (preset ref-counting).
assert.doesNotThrow(() => driver.register(createContextTimelineDefinition({})))
assert.doesNotThrow(() => driver.register(createContextTimelineDefinition({}, () => false)))
assert.throws(() => driver.register({ ...def, stateVersion: 14 }), RegistryViolationError)
})
})
+238
View File
@@ -0,0 +1,238 @@
// The on-demand detail channel (src/host/detail.ts): the nested-inject
// gating on the connection/sessions faces (load-order independent), the
// gate's live flip on unload, and the endpoint's typed outcomes — the detail
// payload off the live fold state, the typed `null` for a gone session or an
// absent unit, and the failure envelope for bad payloads and hostile reads
// (the channel must never throw into the transport).
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import type { Context } from '@deepseek-ai/cordis'
import { watchDetailChannel } from '../../src/host/detail'
import { resolveBounds } from '../../src/host/config'
import type { TimelineState } from '../../src/host/fold'
import { assistantMessage, header, userMessage } from './helpers/events'
import { driveTimeline } from './helpers/projection'
const BOUNDS = resolveBounds({})
type Handler = (endpoint: string, payload: unknown) => unknown
interface CtxSpec {
connection?: unknown
sessions?: unknown
stateOf?: (session: unknown, key: string) => unknown
}
/**
* A minimal host ctx double with cordis inject semantics: the callback runs
* once its dependency list completes (here: immediately when the services
* map has them, never otherwise), and its returned disposer is collected.
*/
function ctxOf(spec: CtxSpec): { ctx: Context; captured: { channel?: string; handler?: Handler }; disposers: (() => void)[] } {
const captured: { channel?: string; handler?: Handler } = {}
const disposers: (() => void)[] = []
const services = new Map<string, unknown>()
if ('connection' in spec) services.set('connection', spec.connection)
if ('sessions' in spec) services.set('sessions', spec.sessions)
const ctx = {
get: (name: string) => services.get(name),
sessionProjections: { stateOf: spec.stateOf ?? (() => undefined) },
effect(fn: () => unknown, _label?: string) {
const d = fn()
if (typeof d === 'function') disposers.push(d as () => void)
return () => {}
},
inject(deps: string[], cb: (c: unknown) => unknown) {
if (!deps.every(d => services.has(d))) return
const d = cb(ctx)
if (typeof d === 'function') disposers.push(d as () => void)
},
}
// Wire the connection face so handle() captures its registration.
if (spec.connection !== undefined && spec.connection !== null) {
const conn = spec.connection as { rpc?: { handle?: unknown } }
if (typeof conn.rpc?.handle === 'function') {
conn.rpc.handle = (channel: string, handler: Handler) => {
captured.channel = channel
captured.handler = handler
return () => {}
}
}
}
return { ctx: ctx as unknown as Context, captured, disposers }
}
/** A live sessions face whose get() serves the `s1` id only. */
function sessionsWith(session: unknown): { get(id: string): unknown } {
return { get: (id: string) => (id === 's1' ? session : undefined) }
}
function okSession(): object {
return { id: 's1' }
}
describe('watchDetailChannel gating', () => {
test('the gate stays closed without the connection or sessions service', () => {
// Neither service.
assert.equal(watchDetailChannel(ctxOf({}).ctx, BOUNDS).live, false)
// Sessions only.
assert.equal(watchDetailChannel(ctxOf({ sessions: sessionsWith(okSession()) }).ctx, BOUNDS).live, false)
// Connection only.
const connOnly = ctxOf({ connection: { rpc: { handle: () => () => {} } } })
assert.equal(watchDetailChannel(connOnly.ctx, BOUNDS).live, false)
// A connection without the rpc.handle face, or a sessions without get.
assert.equal(watchDetailChannel(ctxOf({ connection: { rpc: {} }, sessions: sessionsWith(okSession()) }).ctx, BOUNDS).live, false)
assert.equal(watchDetailChannel(ctxOf({
connection: { rpc: { handle: () => () => {} } },
sessions: { get: 42 },
}).ctx, BOUNDS).live, false)
})
test('the gate opens on registration and closes on unload', () => {
const { ctx, captured, disposers } = ctxOf({
connection: { rpc: { handle: () => () => {} } },
sessions: sessionsWith(okSession()),
})
const gate = watchDetailChannel(ctx, BOUNDS)
assert.equal(gate.live, true)
assert.equal(captured.channel, '/dsh-context')
assert.equal(typeof captured.handler, 'function')
// Unload: the inject fiber's disposer closes the gate (the wire flips back to inline).
for (const d of disposers) d()
assert.equal(gate.live, false)
})
test('a rejecting registry keeps the gate closed without throwing', () => {
const ctx = {
get: (name: string) =>
name === 'connection'
? {
rpc: {
handle: () => {
throw new Error('registration rejected')
},
},
}
: name === 'sessions'
? sessionsWith(okSession())
: undefined,
sessionProjections: { stateOf: () => undefined },
effect(fn: () => unknown) {
fn()
return () => {}
},
inject(deps: string[], cb: (c: unknown) => unknown) {
if (deps.every(d => (d === 'connection' || d === 'sessions'))) cb(ctx)
},
}
assert.equal(watchDetailChannel(ctx as unknown as Context, BOUNDS).live, false)
})
})
describe('the detail endpoint', () => {
function liveCtx(state: TimelineState | undefined): { ctx: Context; captured: { handler?: Handler } } {
return ctxOf({
connection: { rpc: { handle: () => () => {} } },
sessions: sessionsWith(okSession()),
stateOf: () => state,
})
}
test('unknown endpoints and bad payloads fail with typed envelopes', async () => {
const { ctx, captured } = liveCtx(undefined)
watchDetailChannel(ctx, BOUNDS)
const handler = captured.handler!
assert.deepEqual(await handler('nope', { sessionId: 's1' }), {
ok: false,
error: { code: 'dsh-context/unknown-endpoint', message: 'unknown endpoint: nope', details: {} },
})
for (const payload of [null, 42, {}, { sessionId: 42 }, { sessionId: '' }]) {
const result = await handler('detail', payload) as { ok: boolean; error?: { code: string } }
assert.equal(result.ok, false, `payload ${JSON.stringify(payload)} rejected`)
assert.equal(result.error?.code, 'dsh-context/bad-request')
}
})
test('a session outside the live set resolves to the typed null', async () => {
const { state } = driveTimeline([userMessage(1, [{ type: 'text', text: 'hi' }], { kind: 'user' })])
const { ctx, captured } = liveCtx(state)
watchDetailChannel(ctx, BOUNDS)
assert.deepEqual(await captured.handler!('detail', { sessionId: 'gone' }), { ok: true, value: null })
})
test('an absent unit state resolves to the typed null', async () => {
const { ctx, captured } = liveCtx(undefined)
watchDetailChannel(ctx, BOUNDS)
assert.deepEqual(await captured.handler!('detail', { sessionId: 's1' }), { ok: true, value: null })
})
test('serves the fold state\'s detail payload with its revision', async () => {
const { state } = driveTimeline([
header(1, { model: 'deepseek-v4-flash', provider: 'deepseek' }),
userMessage(2, [{ type: 'text', text: 'hi' }], { kind: 'user' }),
assistantMessage(3, { turn: 1, step: 0, usage: { inputTokens: 10, outputTokens: 5 } }),
])
const { ctx, captured } = liveCtx(state)
watchDetailChannel(ctx, BOUNDS)
const result = await captured.handler!('detail', { sessionId: 's1' }) as { ok: true; value: { rev: number; requests: unknown[]; nodes: unknown[] } }
assert.equal(result.ok, true)
assert.equal(result.value.rev, 2, 'two detail-mutating folds')
assert.equal(result.value.requests.length, 1)
assert.equal(result.value.nodes.length, 2)
})
test('hostile reads degrade to the failure envelope, never a throw', async () => {
// sessions.get throwing.
const throwingGet = ctxOf({
connection: { rpc: { handle: () => () => {} } },
sessions: {
get: () => {
throw new Error('hostile sessions')
},
},
})
watchDetailChannel(throwingGet.ctx, BOUNDS)
const r1 = await throwingGet.captured.handler!('detail', { sessionId: 's1' }) as { ok: boolean; error: { code: string; message: string } }
assert.equal(r1.ok, false)
assert.equal(r1.error.code, 'gateway/internal')
assert.equal(r1.error.message, 'hostile sessions')
// stateOf throwing.
const throwingState = ctxOf({
connection: { rpc: { handle: () => () => {} } },
sessions: sessionsWith(okSession()),
stateOf: () => {
throw new Error('hostile registry')
},
})
watchDetailChannel(throwingState.ctx, BOUNDS)
const r2 = await throwingState.captured.handler!('detail', { sessionId: 's1' }) as { ok: boolean; error: { message: string } }
assert.equal(r2.ok, false)
assert.equal(r2.error.message, 'hostile registry')
// A state whose collections are junk (the detail builder throws).
const junkState = ctxOf({
connection: { rpc: { handle: () => () => {} } },
sessions: sessionsWith(okSession()),
stateOf: () => ({ requests: null }),
})
watchDetailChannel(junkState.ctx, BOUNDS)
const r3 = await junkState.captured.handler!('detail', { sessionId: 's1' }) as { ok: boolean }
assert.equal(r3.ok, false)
// A non-Error rejection stringifies into the envelope.
const stringThrow = ctxOf({
connection: { rpc: { handle: () => () => {} } },
sessions: {
get: () => {
throw 'string boom'
},
},
})
watchDetailChannel(stringThrow.ctx, BOUNDS)
const r4 = await stringThrow.captured.handler!('detail', { sessionId: 's1' }) as { ok: boolean; error: { message: string } }
assert.equal(r4.ok, false)
assert.equal(r4.error.message, 'string boom')
})
})
+208
View File
@@ -0,0 +1,208 @@
// The split wire generation (src/host/fold.ts buildTimelineHead /
// buildTimelineDetail + the detailRev ledger): the slim head carries the
// counters and the headline anchor while the detail payload serves the heavy
// collections, and the revision bumps exactly on detail-mutating folds.
// Also pins the equivalence: inline view = head + detail, so the two
// generations can never drift apart.
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { buildTimelineDetail, buildTimelineHead } from '../../src/host/fold'
import type { TimelineEvent } from '../../src/host/fold'
import { resolveBounds } from '../../src/host/config'
import {
assistantChunk,
assistantMessage,
compaction,
header,
planMode,
requestContext,
stepEnd,
stepStart,
toolCall,
toolResult,
userMessage,
} from './helpers/events'
import { assertPlainJson, driveTimeline, timelineDef } from './helpers/projection'
/** A session log touching every envelope family the fold serves (mirrors the compat driver's canonical log). */
function canonicalLog(): TimelineEvent[] {
return [
header(1, {
system: 'You are an agent.',
tools: [{ name: 'bash', description: 'run a command' }],
model: 'deepseek-v4-flash',
provider: 'deepseek',
}),
requestContext(2, { contextWindow: 128000 }),
stepStart(3),
userMessage(4, [{ type: 'text', text: 'hello there' }], { kind: 'user' }),
assistantMessage(5, { turn: 1, step: 0, usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 3 } }),
toolCall(6, { callId: 'c1', name: 'bash' }),
toolResult(7, { callId: 'c1', content: [{ type: 'text', text: 'ok' }] }),
stepEnd(8),
assistantMessage(9, { turn: 1, step: 1, usage: { inputTokens: 20, outputTokens: 8 } }),
compaction(10, 'summary', { shadowedTokenCount: 12, shadowedSeqs: [4] }),
planMode(11, { active: true }),
]
}
describe('the detailRev ledger', () => {
test('bumps exactly on the detail-mutating folds of the canonical log', () => {
// Mutations: userMessage(4), assistantMessage(5), toolResult(7),
// assistantMessage(9), compaction(10), planMode(11) — six in total.
const { state } = driveTimeline(canonicalLog())
assert.equal(state.detailRev, 6)
})
test('the working-slot and envelope folds do NOT bump (nothing to refetch)', () => {
const { def, state } = driveTimeline(canonicalLog())
// step/start (arms the step slot), tool/call (arms the call ledger), the
// first-token chunk stamp, request/context (capacity metadata), and a
// header with an UNCHANGED model all leave the detail collections as-is.
const follow: TimelineEvent[] = [
requestContext(12, { contextWindow: 256000 }),
header(13, { model: 'deepseek-v4-flash', provider: 'deepseek', reason: 'change' }),
stepStart(14),
toolCall(15, { callId: 'c2', name: 'bash' }),
assistantChunk(16, { type: 'text-delta', text: 'tok' }),
]
let st = state
for (const ev of follow) st = def.apply(st, ev)
assert.equal(st.detailRev, 6, 'no detail mutation, no bump')
// The chunk stamp and the step lifecycle DID fold (state changed) — only the rev stayed.
assert.notEqual(st, state)
})
test('a real model switch bumps (the events collection gains a row)', () => {
const { def, state } = driveTimeline(canonicalLog())
const switched = def.apply(state, header(12, { model: 'deepseek-v4-pro', provider: 'deepseek', reason: 'change' }))
assert.equal(switched.detailRev, 7)
})
test('the rev is plain JSON and survives the state gate', () => {
const { states } = driveTimeline(canonicalLog())
for (const state of states) assertPlainJson(state)
})
})
describe('buildTimelineHead', () => {
test('serves the counters, the headline anchor, and the revision — with empty collections', () => {
const { state } = driveTimeline(canonicalLog())
const head = buildTimelineHead(state)
assert.equal(head.ok, true)
assert.equal(head.model, 'deepseek-v4-flash')
assert.equal(head.provider, 'deepseek')
assert.equal(head.contextWindow, 128000)
assert.ok(head.current.total > 0)
// The retained records tally: one turn, two steps, the compaction event.
assert.deepEqual(head.counts, { turns: 1, steps: 2, injects: 0, compactions: 1, prunes: 0 })
// 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)
// The collections stay empty on the head — the detail channel serves them.
assert.deepEqual(head.requests, [])
assert.deepEqual(head.events, [])
assert.deepEqual(head.nodes, [])
assert.equal(head.droppedNodes, 0)
assert.deepEqual(head.archive, [])
assertPlainJson(head)
})
test('the counters tally injects/prunes, and a turn-less record folds as turn 0', () => {
const { state } = driveTimeline([
userMessage(1, [{ type: 'text', text: 'ctx' }], { kind: 'plugin', form: 'context', plugin: 'dsh-test' }),
compaction(2, 'prune', { shadowedTokenCount: 3 }),
assistantMessage(3, {}), // no turn: the replay shape folds as turn 0
])
const head = buildTimelineHead(state)
assert.deepEqual(head.counts, { turns: 1, steps: 1, injects: 1, compactions: 0, prunes: 1 })
})
test('a fresh state serves zeroed counters and no last/detailRev payload keys beyond the marker', () => {
const head = buildTimelineHead(timelineDef().init())
assert.deepEqual(head.counts, { turns: 0, steps: 0, injects: 0, compactions: 0, prunes: 0 })
assert.equal(head.last, undefined, 'no request yet — the anchor stays absent')
assert.equal(head.detailRev, 0)
assert.equal(head.current.total, 0)
})
test('the newest request without a usage sample anchors without prompt', () => {
const { state } = driveTimeline([
userMessage(1, [{ type: 'text', text: 'hi' }], { kind: 'user' }),
assistantMessage(2, { turn: 1, step: 1 }),
])
const head = buildTimelineHead(state)
assert.deepEqual(head.last, { seq: 2, total: state.requests[0].total })
assert.ok(head.last !== undefined && !('prompt' in head.last), 'no undefined-valued prompt key')
})
})
describe('buildTimelineDetail', () => {
test('serves the same collections the inline view serves, plus the revision', () => {
const { state, view } = driveTimeline(canonicalLog())
const detail = buildTimelineDetail(state, resolveBounds({}))
assert.equal(detail.rev, 6)
assert.deepEqual(detail.requests, view.requests)
assert.deepEqual(detail.events, view.events)
assert.deepEqual(detail.nodes, view.nodes)
assert.equal(detail.droppedNodes, view.droppedNodes)
assert.deepEqual(detail.archive, view.archive)
assert.equal(detail.surfaceFloor, view.surfaceFloor)
assert.equal(detail.archiveFloor, view.archiveFloor)
assertPlainJson(detail)
// The detail never aliases the persisted state.
assert.notEqual(detail.requests[0], state.requests[0])
assert.notEqual(detail.archive, state.archived)
})
test('the served window and floors follow the retention bounds (same rule as the inline view)', () => {
const events: TimelineEvent[] = [
userMessage(1, [{ type: 'text', text: 'injected' }], { kind: 'plugin', form: 'context', plugin: 'dsh-test' }),
userMessage(2, [{ type: 'text', text: 'x1' }]),
userMessage(3, [{ type: 'text', text: 'x2' }]),
userMessage(4, [{ type: 'text', text: 'x3' }]),
userMessage(5, [{ type: 'text', text: 'x4' }]),
]
const { state } = driveTimeline(events)
const detail = buildTimelineDetail(state, resolveBounds({ maxNodes: 2 }))
assert.deepEqual(detail.nodes.map(n => n.seq), [1, 4, 5], 'the out-of-window inject is pinned ahead of the tail')
assert.equal(detail.droppedNodes, 2)
assert.equal(detail.surfaceFloor, 3)
assert.equal(detail.rev, 5)
})
})
describe('the split wire generation (definition slim flag)', () => {
test('the slim view validates against the wire schema and carries the marker', () => {
const slim = timelineDef({}, true)
const { state } = driveTimeline(canonicalLog())
const view = slim.wire.view(state)
assert.equal(slim.wire.viewSchema.safeParse(view).success, true, 'the slim head validates')
assert.equal(typeof view.detailRev, 'number', 'the split marker is present')
assert.ok(view.counts !== undefined)
assert.deepEqual(view.nodes, [], 'collections stay off the wire value')
})
test('the inline view carries the collections and NO split markers', () => {
const inline = timelineDef({}, false)
const { state } = driveTimeline(canonicalLog())
const view = inline.wire.view(state)
assert.equal(inline.wire.viewSchema.safeParse(view).success, true, 'the inline value validates on the same schema')
assert.equal(view.detailRev, undefined)
assert.equal(view.counts, undefined)
assert.ok(view.nodes.length > 0)
assert.ok(view.requests.length > 0)
})
test('equivalence: the inline view is exactly the head plus the detail (minus the markers)', () => {
const { state } = driveTimeline(canonicalLog())
const head = buildTimelineHead(state)
const detail = buildTimelineDetail(state, resolveBounds({}))
const inline = timelineDef({}, false).wire.view(state)
const { counts: _counts, last: _last, detailRev: _rev, ...headScalars } = head
const { rev: _detailRev, ...collections } = detail
assert.deepEqual(inline, { ...headScalars, ...collections })
})
})
+3 -3
View File
@@ -28,7 +28,7 @@ export interface TimelineDefLike {
apply(state: TimelineState, event: TimelineEvent): TimelineState
wire: {
/** The wire gate every delivery channel parses the served value with. */
viewSchema: { parse(value: unknown): unknown }
viewSchema: { parse(value: unknown): unknown; safeParse(value: unknown): { success: boolean } }
view(state: TimelineState): ContextTimeline
}
}
@@ -40,8 +40,8 @@ export interface HeadersDefLike {
wire: { view(state: HeadersState): ContextHeaders }
}
export function timelineDef(config?: Config): TimelineDefLike {
return createContextTimelineDefinition(config ?? {}) as unknown as TimelineDefLike
export function timelineDef(config?: Config, slim = false): TimelineDefLike {
return createContextTimelineDefinition(config ?? {}, () => slim) as unknown as TimelineDefLike
}
export function headersDef(): HeadersDefLike {
+47 -1
View File
@@ -113,7 +113,7 @@ describe('dsh-context host plugin', () => {
assert.ok(timeline.length > 0, 'contextTimeline changes notified')
const last = timeline.at(-1)?.value
assert.equal(
createContextTimelineDefinition({}).wire.viewSchema.safeParse(last).success,
createContextTimelineDefinition({}, () => false).wire.viewSchema.safeParse(last).success,
true,
'the notified value is the validated wire view',
)
@@ -184,6 +184,52 @@ describe('dsh-context host plugin', () => {
assert.equal(snapshot.values.contextHeaders, undefined)
})
test('the split generation with the detail channel live: slim wire head + the endpoint serves the collections', async () => {
const ctx = new Context()
let handler: ((endpoint: string, payload: unknown) => Promise<unknown>) | undefined
ctx.provide('connection', {
rpc: {
handle: (_channel: string, h: never) => {
handler = h
return () => {}
},
},
})
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(plugin, noConfig)
const session = ctx.sessions.create()
appendRealEnvelopes(session)
const timeline = ctx.sessionProjections.snapshot(session).values.contextTimeline
assert.ok(timeline !== undefined)
assert.equal(typeof timeline.detailRev, 'number', 'the split marker rides the slim head')
assert.deepEqual(timeline.counts, { turns: 1, steps: 1, injects: 0, compactions: 0, prunes: 0 })
assert.equal(timeline.nodes.length, 0, 'the collections stay off the wire value')
assert.equal(timeline.requests.length, 0)
assert.ok(handler !== undefined, 'the detail channel registered')
const result = await handler('detail', { sessionId: session.header.id }) as {
ok: boolean
value: { rev: number; nodes: unknown[]; requests: unknown[] } | null
}
assert.equal(result.ok, true)
assert.ok(result.value !== null, 'a viewed session is live — its detail serves')
assert.equal(result.value.rev, timeline.detailRev, 'the payload mirrors the head revision')
assert.equal(result.value.nodes.length, 2, 'user + assistant surface nodes')
assert.equal(result.value.requests.length, 1)
})
test('without the connection service the wire value stays inline', async () => {
const { ctx } = await boot()
const session = ctx.sessions.create()
appendRealEnvelopes(session)
const timeline = ctx.sessionProjections.snapshot(session).values.contextTimeline
assert.ok(timeline !== undefined)
assert.equal(timeline.detailRev, undefined, 'no channel, no split marker')
assert.equal(timeline.nodes.length, 2, 'the collections ride the wire value as before')
})
test('stays pending without the registry, starts when it arrives', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+3 -3
View File
@@ -47,7 +47,7 @@ function realLog(): TimelineEvent[] {
describe('createContextTimelineDefinition', () => {
test('carries the supported projection contract on one unit', () => {
const def = createContextTimelineDefinition({})
const def = createContextTimelineDefinition({}, () => false)
assert.equal(def.key, 'contextTimeline')
assert.equal(def.stateVersion, 13)
assert.equal(typeof def.init, 'function')
@@ -59,7 +59,7 @@ describe('createContextTimelineDefinition', () => {
})
test('the wire schema accepts a real folded view; the state schema accepts every intermediate state', () => {
const def = createContextTimelineDefinition({})
const def = createContextTimelineDefinition({}, () => false)
const drive = driveTimeline(realLog())
const parsed = def.wire.viewSchema.safeParse(drive.view)
assert.equal(parsed.success, true, 'a real folded view validates')
@@ -81,7 +81,7 @@ describe('createContextTimelineDefinition', () => {
})
test('the wire schema rejects drift', () => {
const def = createContextTimelineDefinition({})
const def = createContextTimelineDefinition({}, () => false)
const drive = driveTimeline(realLog())
const extra = structuredClone(drive.view) as unknown as Record<string, unknown>