diff --git a/src/client/agentTree.ts b/src/client/agentTree.ts index c6085c0..4982f83 100644 --- a/src/client/agentTree.ts +++ b/src/client/agentTree.ts @@ -198,7 +198,10 @@ export function agentStatsOf(values: Record | 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), diff --git a/src/client/components/browser.tsx b/src/client/components/browser.tsx index 6f2d067..e047b94 100644 --- a/src/client/components/browser.tsx +++ b/src/client/components/browser.tsx @@ -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 ?
{t('browser.approx')}
: null} + {props.detailState === 'loading' + ? + : null} + {props.detailState === 'failed' && props.onDetailRetry !== undefined + ? + : null}
{CATS.map((c) => { diff --git a/src/client/components/contextModal.tsx b/src/client/components/contextModal.tsx index 3b656fa..95356fc 100644 --- a/src/client/components/contextModal.tsx +++ b/src/client/components/contextModal.tsx @@ -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} />
)} diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx index 7cb068a..73f7b65 100644 --- a/src/client/components/contextView.tsx +++ b/src/client/components/contextView.tsx @@ -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(
- @@ -366,7 +379,11 @@ export function makeContextView(
{displayRequests.length === 0 - ?
{t('trend.empty')}
+ // Split generation, first detail read pending or failed: say + // so instead of claiming the session has no history. + ? (detailReady + ?
{t('trend.empty')}
+ : ) : (
@@ -443,9 +462,10 @@ export function makeContextView( ))} - + - + 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
{t('detail.loading')}
+ return ( +
+ {props.onRetry !== undefined + ? + : t('detail.loadFailed')} +
+ ) + } +} diff --git a/src/client/components/events.tsx b/src/client/components/events.tsx index 075496c..bdd3ced 100644 --- a/src/client/components/events.tsx +++ b/src/client/components/events.tsx @@ -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 = { 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 + if (props.state === 'failed' && props.onRetry !== undefined) { + return + } return
{t('events.empty')}
} const sorted = props.events.slice().reverse() diff --git a/src/client/components/fileCard.tsx b/src/client/components/fileCard.tsx index d9b0bff..ed99e7a 100644 --- a/src/client/components/fileCard.tsx +++ b/src/client/components/fileCard.tsx @@ -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 { 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 {t('files.title')} {props.scope} - {activity.entries.length === 0 ? ( + {activity.entries.length === 0 && props.state === 'loading' ? ( + + ) : activity.entries.length === 0 && props.state === 'failed' && props.onRetry !== undefined ? ( + + ) : activity.entries.length === 0 ? (
{t('files.empty')}
) : (
diff --git a/src/client/components/statsContext.tsx b/src/client/components/statsContext.tsx index 962e338..c018f7c 100644 --- a/src/client/components/statsContext.tsx +++ b/src/client/components/statsContext.tsx @@ -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() + 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() - 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: { {t('stats.hint')}
- {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)}
) diff --git a/src/client/headline.ts b/src/client/headline.ts index 9c7f31b..db0448a 100644 --- a/src/client/headline.ts +++ b/src/client/headline.ts @@ -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' diff --git a/src/client/i18n.ts b/src/client/i18n.ts index 8010bae..470cb6a 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -137,6 +137,8 @@ export const DICT_ZH: Record = { '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 = { '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', diff --git a/src/client/index.ts b/src/client/index.ts index 3416aef..d08d307 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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 diff --git a/src/client/services.ts b/src/client/services.ts index fe6a755..2d1faa9 100644 --- a/src/client/services.ts +++ b/src/client/services.ts @@ -211,7 +211,8 @@ export function numOf(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) ? value : 0 } -function objectsOf(value: unknown): T[] { +/** Shared per-item collection guard: drop non-object entries, keep the rest. */ +export function objectsOf(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 } : {}), + } } /** diff --git a/src/client/timelineSource.ts b/src/client/timelineSource.ts new file mode 100644 index 0000000..7788285 --- /dev/null +++ b/src/client/timelineSource.ts @@ -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) | undefined { + if (sessionId === '') return undefined + let call: ((channel: string, endpoint: string, payload: unknown) => Promise) | 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).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 | null = null + private failures = 0 + private readonly listeners = new Set<() => void>() + private snap: DetailSnap = EMPTY_SNAP + + constructor( + private readonly fetcher: (() => Promise) | 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 { + // 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() + +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(() => { + 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]) +} diff --git a/src/host/detail.ts b/src/host/detail.ts new file mode 100644 index 0000000..3580e9f --- /dev/null +++ b/src/host/detail.ts @@ -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 +} diff --git a/src/host/fold.ts b/src/host/fold.ts index dc31597..86a8f34 100644 --- a/src/host/fold.ts +++ b/src/host/fold.ts @@ -19,7 +19,7 @@ * the request/event records are the raw material of `buildTimelineView`. */ -import type { Category, ContextEventRecord, 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 { + const result: Omit = { + 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() + 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) } +} diff --git a/src/host/index.ts b/src/host/index.ts index f86446b..7b652c8 100644 --- a/src/host/index.ts +++ b/src/host/index.ts @@ -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' diff --git a/src/host/timeline.ts b/src/host/timeline.ts index 3ffd5f4..da586fc 100644 --- a/src/host/timeline.ts +++ b/src/host/timeline.ts @@ -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 @@ -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 /** @@ -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, diff --git a/src/shared/types.ts b/src/shared/types.ts index 0ac558d..2e60ffd 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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. diff --git a/tests/baselines.ts b/tests/baselines.ts index f9e461f..340597d 100644 --- a/tests/baselines.ts +++ b/tests/baselines.ts @@ -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<', + }, }, }, ] diff --git a/tests/client/agentTree.spec.ts b/tests/client/agentTree.spec.ts index ab57bd7..b171290 100644 --- a/tests/client/agentTree.spec.ts +++ b/tests/client/agentTree.spec.ts @@ -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: [] }) diff --git a/tests/client/components/browser.spec.ts b/tests/client/components/browser.spec.ts index eb8e68b..7aad1d8 100644 --- a/tests/client/components/browser.spec.ts +++ b/tests/client/components/browser.spec.ts @@ -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() + }) +}) diff --git a/tests/client/components/contextModal.spec.ts b/tests/client/components/contextModal.spec.ts index d292375..1f2a72c 100644 --- a/tests/client/components/contextModal.spec.ts +++ b/tests/client/components/contextModal.spec.ts @@ -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 { + 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() + }) +}) diff --git a/tests/client/components/contextView.spec.ts b/tests/client/components/contextView.spec.ts index 8ce68e5..8dc69ae 100644 --- a/tests/client/components/contextView.spec.ts +++ b/tests/client/components/contextView.spec.ts @@ -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 = {}): 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 { + 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): TestClientCtx { + return new TestClientCtx({ + services: { + connection: { rpc: { call: (_channel: string, _endpoint: string, _payload: unknown) => serve() } }, + }, + }) + } + + async function until(fn: () => boolean, message: string): Promise { + 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() + }) +}) diff --git a/tests/client/components/detailNote.spec.ts b/tests/client/components/detailNote.spec.ts new file mode 100644 index 0000000..49a2366 --- /dev/null +++ b/tests/client/components/detailNote.spec.ts @@ -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() + }) +}) diff --git a/tests/client/components/events.spec.ts b/tests/client/components/events.spec.ts index 51940f2..6870be6 100644 --- a/tests/client/components/events.spec.ts +++ b/tests/client/components/events.spec.ts @@ -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() + }) +}) diff --git a/tests/client/components/fileCard.spec.ts b/tests/client/components/fileCard.spec.ts index a5108fd..e4a7684 100644 --- a/tests/client/components/fileCard.spec.ts +++ b/tests/client/components/fileCard.spec.ts @@ -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() + }) +}) diff --git a/tests/client/components/statsContext.spec.ts b/tests/client/components/statsContext.spec.ts index ad6359a..5069b32 100644 --- a/tests/client/components/statsContext.spec.ts +++ b/tests/client/components/statsContext.spec.ts @@ -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, ['轮次', '步数', '工具调用', '图片', '预估费用?', '注入', '压缩', '剪枝']) diff --git a/tests/client/headline.spec.ts b/tests/client/headline.spec.ts index 5293700..52b930b 100644 --- a/tests/client/headline.spec.ts +++ b/tests/client/headline.spec.ts @@ -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', () => { diff --git a/tests/client/services.spec.ts b/tests/client/services.spec.ts index ea83e6a..e5c2f84 100644 --- a/tests/client/services.spec.ts +++ b/tests/client/services.spec.ts @@ -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', () => { diff --git a/tests/client/timelineSource.spec.ts b/tests/client/timelineSource.spec.ts new file mode 100644 index 0000000..7a0a22c --- /dev/null +++ b/tests/client/timelineSource.spec.ts @@ -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 = {}): 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 = {}): Record { + 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 { + 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; 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 { + 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(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(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(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() + }) +}) + diff --git a/tests/compat/matrix.spec.ts b/tests/compat/matrix.spec.ts index e3488b4..8cd727c 100644 --- a/tests/compat/matrix.spec.ts +++ b/tests/compat/matrix.spec.ts @@ -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) }) diff --git a/tests/host/compat/registry.spec.ts b/tests/host/compat/registry.spec.ts index f418ebd..e0beac7 100644 --- a/tests/host/compat/registry.spec.ts +++ b/tests/host/compat/registry.spec.ts @@ -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) }) }) diff --git a/tests/host/detail.spec.ts b/tests/host/detail.spec.ts new file mode 100644 index 0000000..65008ab --- /dev/null +++ b/tests/host/detail.spec.ts @@ -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() + 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') + }) +}) diff --git a/tests/host/fold-split.spec.ts b/tests/host/fold-split.spec.ts new file mode 100644 index 0000000..6c6d6eb --- /dev/null +++ b/tests/host/fold-split.spec.ts @@ -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 }) + }) +}) diff --git a/tests/host/helpers/projection.ts b/tests/host/helpers/projection.ts index 39aa610..64c93bb 100644 --- a/tests/host/helpers/projection.ts +++ b/tests/host/helpers/projection.ts @@ -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 { diff --git a/tests/host/index.spec.ts b/tests/host/index.spec.ts index 93a4f14..3a6691e 100644 --- a/tests/host/index.spec.ts +++ b/tests/host/index.spec.ts @@ -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) | 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) diff --git a/tests/host/timeline.spec.ts b/tests/host/timeline.spec.ts index d274414..402220c 100644 --- a/tests/host/timeline.spec.ts +++ b/tests/host/timeline.spec.ts @@ -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