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