diff --git a/src/client/components/contextModal.tsx b/src/client/components/contextModal.tsx index d6fb3f2..d5b1f62 100644 --- a/src/client/components/contextModal.tsx +++ b/src/client/components/contextModal.tsx @@ -16,9 +16,10 @@ import { contextPressureOf, headersOf, timelineOf } from '../services' import type { ViewKit } from '../viewkit' import { makeContextBrowser } from './browser' import { makeCurrentComposition } from './currentComposition' +import { makeErrorBoundary } from './errorBoundary' import { makeLegend, makeStackedBar } from './stackedBar' -import { React } from '../react' +import { React, h } from '../react' export interface ContextModalProps extends SessionStandardProps { /** Bound selector hook over the per-session open flag (hooks compartment). */ @@ -36,8 +37,13 @@ export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextM // while the browser shows the live step, and the tool chips jumping it). const CurrentComposition = makeCurrentComposition(kit, StackedBar, Legend) const ContextBrowser = makeContextBrowser(kit, StackedBar) + const ErrorBoundary = makeErrorBoundary(t) - return function ContextModal(props: ContextModalProps): ReactNS.ReactElement | null { + // The body holds all hooks and data reads inside the error boundary (same + // arrangement as the Context tab): a corrupt projection value or framework + // surprise degrades to a styled error card inside the dialog instead of + // breaking the conversation input overlay around it. + 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 = typeof props.useProjection === 'function' @@ -133,4 +139,8 @@ export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextM ) } + + return function ContextModal(props: ContextModalProps): ReactNS.ReactElement | null { + return h(ErrorBoundary, null, h(ContextModalBody, props)) + } } diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx index fb9b936..8044f72 100644 --- a/src/client/components/contextView.tsx +++ b/src/client/components/contextView.tsx @@ -26,7 +26,8 @@ import { makeStatsBoard } from './statsBoard' import { makeLegend, makeStackedBar } from './stackedBar' import { aggregateByTurn, attachMarkers, makeTrendChart } from './trendChart' -import { React } from '../react' +import { React, h } from '../react' +import { makeErrorBoundary } from './errorBoundary' // The context page scrolls inside the conversation's shared page scroller // (`[data-conversation-scroll]`) — the same container the chat auto-scrolls @@ -55,8 +56,15 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi const StatsBoard = makeStatsBoard(kit) const PluginInfo = makePluginInfo(kit) const ContextBrowser = makeContextBrowser(kit, StackedBar) + const ErrorBoundary = makeErrorBoundary(t) - return function ContextView(props: ContextViewProps): ReactNS.ReactElement { + // The data-driven body lives inside the error boundary: any render error + // (a corrupt projection value that slips past the timelineOf shape guard, + // a framework surprise) degrades to a styled error card inside the tab + // instead of white-screening the whole conversation view. The boundary is + // a separate component with NO hooks of its own, so the body's hook order + // and the loading/data control flow stay exactly as they were. + function ContextViewBody(props: ContextViewProps): ReactNS.ReactElement { const sessionId = props.sessionId // The finished value the harness pushes for this session: the // `contextTimeline` projection key (capability-absent until a value @@ -340,4 +348,10 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi ) } + + return function ContextView(props: ContextViewProps): ReactNS.ReactElement { + // The whole body — hooks, projection reads, derivation, and all child + // cards — renders under the boundary (see ContextViewBody above). + return h(ErrorBoundary, null, h(ContextViewBody, props)) + } } diff --git a/src/client/components/errorBoundary.tsx b/src/client/components/errorBoundary.tsx new file mode 100644 index 0000000..6049244 --- /dev/null +++ b/src/client/components/errorBoundary.tsx @@ -0,0 +1,52 @@ +/** + * ErrorBoundary — the tab's no-white-screen guarantee. + * + * A render error anywhere in the subtree (a foreign or corrupt projection + * value that slips past the shape guards in services.ts, a framework + * surprise, a future bug) would otherwise propagate into the harness's slot + * renderer and unmount the whole conversation view. This boundary catches + * render errors of its subtree and degrades to a styled error card with the + * offending message — the surrounding harness UI (chat, tabs, composer) + * keeps working as if nothing happened. + * + * A class component: the only React primitive that can catch render errors + * of a subtree (there is no hook-based boundary in React 18). The Retry + * button resets the boundary; if the underlying condition persists the + * boundary catches again, once a healthy value arrives the tab resumes. + */ + +import type * as ReactNS from 'react' +import type { Translate } from '../i18n' + +import { React } from '../react' + +export function makeErrorBoundary(t: Translate): ReactNS.ComponentType<{ children?: ReactNS.ReactNode }> { + return class ErrorBoundary extends React.Component<{ children?: ReactNS.ReactNode }, { error: Error | null }> { + constructor(props: { children?: ReactNS.ReactNode }) { + super(props) + this.state = { error: null } + } + + static getDerivedStateFromError(error: unknown): { error: Error | null } { + return { error: error instanceof Error ? error : new Error(String(error)) } + } + + render(): ReactNS.ReactNode { + const error = this.state.error + if (error === null) return this.props.children + return ( +
+
+ {t('error')} + {error.message} + +
+
+ ) + } + } +} \ No newline at end of file diff --git a/src/client/components/statsBoard.tsx b/src/client/components/statsBoard.tsx index 541d35d..1a332db 100644 --- a/src/client/components/statsBoard.tsx +++ b/src/client/components/statsBoard.tsx @@ -19,6 +19,7 @@ import type * as ReactNS from 'react' import type { ContextEventRecord, RequestRecord, SessionCostUsage, TokenUsage } from '../../shared/types' import { estimateSessionCost, formatCost, formatPriceRate, sessionPrices } from '../cost' import type { CostCurrency } from '../cost' +import { numOf } from '../services' import type { ViewKit } from '../viewkit' import { React } from '../react' @@ -31,12 +32,16 @@ import { React } from '../react' * round). Null when no input was billed. A tiny epsilon keeps a double stored * a hair below a cent boundary (e.g. 80.00 as 79.9999999999…) from losing its * last digit — with integer token counts no genuine value ever sits that close - * to a boundary, so the epsilon can only absorb float noise. + * to a boundary, so the epsilon can only absorb float noise. Bucket reads go + * through numOf so a malformed payload degrades to a dash instead of NaN text. */ function cacheHitPercent(usage: TokenUsage): string | null { - const billed = usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens + const uncached = numOf(usage.uncachedInputTokens) + const reads = numOf(usage.cacheReadTokens) + const writes = numOf(usage.cacheWriteTokens) + const billed = uncached + reads + writes if (billed === 0) return null - const hundredths = Math.trunc((usage.cacheReadTokens / billed) * 10000 + 1e-9) + const hundredths = Math.trunc((reads / billed) * 10000 + 1e-9) return `${Math.floor(hundredths / 100)}.${String(hundredths % 100).padStart(2, '0')}` } diff --git a/src/client/cost.ts b/src/client/cost.ts index e828f29..b441fbb 100644 --- a/src/client/cost.ts +++ b/src/client/cost.ts @@ -12,6 +12,7 @@ */ import type { CostBucketTotals, SessionCostUsage } from '../shared/types' +import { numOf } from './services' /** Per-1M-token rates: cache-hit input, cache-miss input, output. */ export interface PriceTriple { hit: number; miss: number; out: number } @@ -46,7 +47,7 @@ export function estimateSessionCost(usage: SessionCostUsage | null | undefined, const b: CostBucketTotals | undefined = fam[period] if (b === undefined) continue const p: PriceTriple = PRICES[currency][family][period] - total += (b.cacheRead * p.hit + (b.uncached + b.cacheWrite) * p.miss + b.output * p.out) / 1e6 + total += (numOf(b.cacheRead) * p.hit + (numOf(b.uncached) + numOf(b.cacheWrite)) * p.miss + numOf(b.output) * p.out) / 1e6 any = true } } diff --git a/src/client/format.ts b/src/client/format.ts index 89005d5..39d35cb 100644 --- a/src/client/format.ts +++ b/src/client/format.ts @@ -13,5 +13,9 @@ export function fmt(n: number | null | undefined): string { export function fmtTime(t: number): string { // en-GB with a 24-hour clock renders the same local HH:MM:SS the manual // zero-pad produced, without the hand-rolled p() helper. - return new Date(t).toLocaleTimeString('en-GB', { hour12: false }) + // An invalid timestamp (garbage from a corrupt payload) must show a dash, + // never throw — `toLocaleTimeString` raises RangeError on Invalid Date. + const d = new Date(t) + if (isNaN(d.getTime())) return '—' + return d.toLocaleTimeString('en-GB', { hour12: false }) } diff --git a/src/client/i18n.ts b/src/client/i18n.ts index 41df2d8..796a7c4 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -54,6 +54,7 @@ export const DICT_ZH: Record = { 'nodes.empty': '当前没有模型可见的消息', 'loading': '正在读取会话日志…', 'error': '上下文数据读取失败:', + 'error.retry': '重试', 'footer': '估算口径:与 dsh 内置 tokenMeter 相同的固定密度启发式(约 4 字符 ≈ 1 token);「实际」为供应商上报用量。', 'tip.step': '第 {t} 轮 · 第{s}步', 'tip.turn': '第 {t} 轮 · 共 {n} 步', @@ -149,6 +150,7 @@ export const DICT_EN: Record = { 'nodes.empty': 'No model-visible messages right now', 'loading': 'Reading the session log…', 'error': 'Failed to read context data: ', + 'error.retry': 'Retry', 'footer': 'Estimate: same fixed-density heuristic as dsh’s built-in tokenMeter (~4 chars ≈ 1 token); “actual” is provider-reported usage.', 'tip.step': 'Turn {t} · Step {s}', 'tip.turn': 'Turn {t} · {n} steps', diff --git a/src/client/services.ts b/src/client/services.ts index 1a7a9c8..ef8f67f 100644 --- a/src/client/services.ts +++ b/src/client/services.ts @@ -11,7 +11,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import type { ContextHeaders, ContextPressure, ContextTimeline, TokenUsage } from '../shared/types' +import type { ContextHeaders, ContextPressure, ContextTimeline, SessionCostUsage, TokenUsage } from '../shared/types' export interface LocaleService { register(ns: string, dicts: Record>): () => void @@ -98,8 +98,84 @@ function asRecord(value: unknown): T | null { return value as T } -/** Narrow a delivered projection value to the context timeline. */ -export const timelineOf = (value: unknown): ContextTimeline | null => asRecord(value) +/** + * Safe finite-number read: a missing/non-numeric/NaN field degrades to 0 + * instead of leaking into the UI as NaN percentages or broken arithmetic. + */ +export function numOf(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +/** The usable (non-null object) entries of a delivered list; [] when the field is not a list. */ +function objectsOf(value: unknown): T[] { + if (!Array.isArray(value)) return [] + return value.filter((v): v is T => v !== null && typeof v === 'object') +} + +/** + * Narrow a delivered projection value to a RENDER-SAFE context timeline — + * the client's no-white-screen guarantee against backend/parse failures. + * + * A value that is not a record at all (capability absent, nothing delivered + * yet) stays `null` and callers show the loading screen. A record that fails + * the wire shape (corrupt checkpoint restore, a failed/older host payload, + * plugin drift) is SANITIZED instead of rejected: every collection becomes + * an array, non-object entries are dropped, `current` becomes a numeric + * breakdown, and wrong-typed scalars are dropped or zeroed — so the whole + * tab still renders with every usable piece of data instead of throwing + * during render and unmounting the conversation view. + */ +export function timelineOf(value: unknown): ContextTimeline | null { + const data = asRecord(value) + if (data === null) return null + const current = data.current + // The wire shape check for the cheap pass-through path: `current` must be + // a full numeric breakdown (the host always sends all seven fields), and + // every collection must be a real list. Anything else takes the slow path + // and is rebuilt into the safe shape below. + const numericBreakdown = current !== null && typeof current === 'object' + && ['system', 'tools', 'user', 'inject', 'assistant', 'tool', 'total'] + .every(k => typeof (current as Record)[k] === 'number') + if (numericBreakdown + && Array.isArray(data.requests) + && Array.isArray(data.events) + && Array.isArray(data.nodes) + && Array.isArray(data.archive) + && Array.isArray(data.toolList)) { + // Well-formed: pass the delivered value through untouched (cheap, and + // reference-stable so plain re-renders stay zero-copy). + return data + } + // Malformed: rebuild with safe defaults, keeping every usable piece. + const safeCurrent: Record = current !== null && typeof current === 'object' ? current : {} + const cost = typeof data.cost === 'object' && data.cost !== null && !Array.isArray(data.cost) + ? data.cost as SessionCostUsage + : undefined + return { + ok: true, + ...(typeof data.model === 'string' ? { model: data.model } : {}), + ...(typeof data.provider === 'string' ? { provider: data.provider } : {}), + ...(typeof data.contextWindow === 'number' ? { contextWindow: data.contextWindow } : {}), + current: { + system: numOf(safeCurrent.system), + tools: numOf(safeCurrent.tools), + user: numOf(safeCurrent.user), + inject: numOf(safeCurrent.inject), + assistant: numOf(safeCurrent.assistant), + tool: numOf(safeCurrent.tool), + total: numOf(safeCurrent.total), + }, + toolList: objectsOf(data.toolList), + requests: objectsOf(data.requests), + events: objectsOf(data.events), + nodes: objectsOf(data.nodes), + droppedNodes: numOf(data.droppedNodes), + archive: objectsOf(data.archive), + ...(cost !== undefined ? { cost } : {}), + ...(typeof data.surfaceFloor === 'number' ? { surfaceFloor: data.surfaceFloor } : {}), + ...(typeof data.archiveFloor === 'number' ? { archiveFloor: data.archiveFloor } : {}), + } +} /** * Narrow a delivered projection value to the official token-meter @@ -123,10 +199,22 @@ export const tokenUsageOf = (value: unknown): TokenUsage | null => asRecord(value) - return headers !== null && Array.isArray(headers.headers) ? headers : null + if (headers === null || !Array.isArray(headers.headers)) return null + for (const h of headers.headers) { + if (h === null || typeof h !== 'object') return null + const entry = h as { tools?: unknown; system?: unknown } + if (!Array.isArray(entry.tools)) return null + if (entry.system !== undefined && typeof entry.system !== 'string') return null + } + return headers } // ---- /context command faces (framework `inputTriggers` service) ---- diff --git a/src/client/styles.ts b/src/client/styles.ts index 9614da3..082d7b0 100644 --- a/src/client/styles.ts +++ b/src/client/styles.ts @@ -221,6 +221,13 @@ export const STYLES = [ '.lc-node-tokens { color: var(--dsw-alias-label-secondary); }', '.lc-nodes-more { color: var(--dsw-alias-label-secondary); padding: 3px 0; }', '.lc-empty { color: var(--dsw-alias-label-secondary); padding: 18px 0; text-align: center; }', + // Error-boundary fallback card: the tab degrades to this instead of + // white-screening; the message runs in mono on a padded chip, and Retry + // re-mounts the subtree once a healthy value arrives. + '.lc-error { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 40px 16px; }', + '.lc-error-msg { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--dsw-alias-state-error-primary); background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 4px 8px; max-width: 100%; overflow-wrap: anywhere; }', + '.lc-error-retry { border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font: inherit; font-size: 12px; padding: 4px 14px; border-radius: 6px; cursor: pointer; }', + '.lc-error-retry:hover { border-color: var(--dsw-alias-label-primary); }', '.lc-foot { color: var(--dsw-alias-label-secondary); font-size: 12px; margin-top: 4px; }', // ---- /context modal (centered dialog; escapes the composer anchor via fixed positioning) ---- '.lc-modal-backdrop { position: fixed; inset: 0; z-index: 200; background: rgba(0, 0, 0, 0.45); display: flex; align-items: center; justify-content: center; }', diff --git a/tests/client.test.mjs b/tests/client.test.mjs index 3747d31..1098aa3 100644 --- a/tests/client.test.mjs +++ b/tests/client.test.mjs @@ -31,6 +31,12 @@ const fakeReact = { createElement: (...args) => ({ kind: 'element', args }), useState: (init) => [init, () => {}], useEffect: () => {}, + // The ErrorBoundary extends React.Component; a minimal base keeps the + // class construction working (props assigned, state merge via setState). + Component: class Component { + constructor(props) { this.props = props } + setState(partial) { this.state = { ...this.state, ...partial } } + }, } let handoff = null @@ -225,6 +231,10 @@ const statefulReact = { currentHooks[i] = { effect: fn } return currentHooks[i] }, + Component: class Component { + constructor(props) { this.props = props } + setState(partial) { this.state = { ...this.state, ...partial } } + }, } const requireStateful = (spec) => { assert.ok(spec === 'react' || spec === '@deepseek-ai/dsh-client-ui-primitives', @@ -234,7 +244,7 @@ const requireStateful = (spec) => { const m2 = { exports: {} } const pluginExports2 = factory(requireStateful, m2, globalThis.window, fakeDoc) -const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'x', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'overview.used': 'of context used', 'overview.free': 'Free window', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}', 'stats.recycleSub': '{c} compactions · {p} prunes', 'tip.step': 'Turn {t} · Step {s}', 'tip.turn': 'Turn {t} · {n} steps', 'tip.total': 'total ≈ {n}', 'tip.actual': ' (actual {n})', 'trend.title': 'History', 'trend.empty': 'empty trend', 'cmd.close': 'Close', 'cat.user': 'User', 'browser.live': 'Live (next request)', 'browser.liveNow': 'Live · next request', 'browser.items': '{n} items', 'browser.noContent': 'outside the loaded window', 'browser.loading': 'loading older history', 'browser.preview': 'preview', 'browser.noHeader': 'older plugin build', +const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'ERR:', 'error.retry': 'retry', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'overview.used': 'of context used', 'overview.free': 'Free window', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}', 'stats.recycleSub': '{c} compactions · {p} prunes', 'tip.step': 'Turn {t} · Step {s}', 'tip.turn': 'Turn {t} · {n} steps', 'tip.total': 'total ≈ {n}', 'tip.actual': ' (actual {n})', 'trend.title': 'History', 'trend.empty': 'empty trend', 'cmd.close': 'Close', 'cat.user': 'User', 'browser.live': 'Live (next request)', 'browser.liveNow': 'Live · next request', 'browser.items': '{n} items', 'browser.noContent': 'outside the loaded window', 'browser.loading': 'loading older history', 'browser.preview': 'preview', 'browser.noHeader': 'older plugin build', 'browser.deltaHint': 'vs previous turn', 'overview.compactReserve': 'compact reserve {pct}%', 'tool.desc': 'Description', 'tool.params': 'Parameters', 'tool.paramsEmpty': '(no parameters)', 'tool.jsonToggle': 'View Raw JSON', 'tool.jsonHide': 'Collapse', 'rich.raw': 'Raw', 'rich.md': 'Markdown', 'rich.toMd': 'View as Markdown', 'rich.toRaw': 'View Raw Text' } let viewComponent = null let modalComponent = null @@ -310,11 +320,37 @@ const renderView = () => evaluate(viewComponent({ /** Invoke function-typed elements so hooks run and the tree materializes. * Hooks are keyed by the component's fiber path (e.g. root/ContextView#0/ - * StackedBar#0), so distinct instances of the same component keep state. */ + * StackedBar#0), so distinct instances of the same component keep state. + * Class components (the ErrorBoundary) are simulated with a React-like + * lifecycle: an instance is created from the prototype with props + empty + * state, render errors are caught through the static getDerivedStateFromError + * (the boundary protocol) and the fallback re-renders, and instances are + * registered so tests can inspect the caught error. */ +const classInstances = new Map() function evaluate(node, path = '', fnIdx = 0) { if (node === null || typeof node !== 'object') return node if (node.kind === 'element') { const [type, props, ...children] = node.args + if (typeof type === 'function' && type.prototype !== undefined && typeof type.prototype.render === 'function') { + const key = path + '/' + (type.name || 'anon') + '#' + fnIdx + // The fake createElement keeps children in args, not props; real React + // puts them on props.children (single child as-is, several as an + // array), which the class instance's render() reads. Constructing with + // `new` runs the real constructor, so props AND the initial state are + // exactly what React would set. + const childrenOf = children.length === 0 ? {} : children.length === 1 ? { children: children[0] } : { children } + const inst = new type(Object.assign({}, props || {}, childrenOf)) + classInstances.set(key, inst) + try { + return evaluate(inst.render(), key) + } catch (err) { + if (typeof type.getDerivedStateFromError === 'function') { + inst.state = type.getDerivedStateFromError(err) + return evaluate(inst.render(), key) + } + throw err + } + } if (typeof type === 'function') { const key = path + '/' + (type.name || 'anon') + '#' + fnIdx currentHooks = hookStates.get(key) @@ -377,7 +413,9 @@ const snapshot = { } // Deliver the timeline like a session/projection frame: swap the holder the // useProjection stub reads, then re-render the view. -const ctxKey = [...hookStates.keys()].find(k => k.includes('ContextView')) +// The ContextView fiber: the boundary wrapper owns an EMPTY hook slot array, +// so the data-driven body's fiber is the one carrying the view hooks. +const ctxKey = [...hookStates.keys()].find(k => k.includes('ContextView') && hookStates.get(k).length > 0) assert.ok(ctxKey, 'ContextView fiber registered') dataValue = snapshot const tree = renderView() @@ -1463,3 +1501,76 @@ modalOpen = false assert.equal(renderModal(), null, 'modal closes again') console.log('✔ modal render test passed (open/close, current overview, embedded context browser, localized chrome, deferred token consume on close)') + +// ---- backend-parse-failure resilience: a corrupt/foreign `contextTimeline` +// value must never white-screen the tab. The projection is sanitized into a +// render-safe shape — the tab shows the WHOLE UI with every usable piece — +// and any residual render error is caught by the error boundary (a styled +// error card, not an unmounted conversation view). ---- + +// A non-record value (capability absent, nothing delivered yet): loading. +dataValue = 'not-even-an-object' +tr = renderView() +assert.equal(textOf(byClass(tr, 'lc-empty')[0]), '…', 'a non-record value keeps the loading screen') + +// A garbage payload: wrong-typed scalars drop, non-list collections degrade +// to [], non-object entries are dropped, and the breakdown zeroes out. +dataValue = { + ok: false, + model: 42, + current: null, + requests: ['junk', { seq: 1, turn: 1, time: 10, system: 1, tools: 2, user: 3, inject: 0, assistant: 4, tool: 0, total: 10 }], + events: [null, { kind: 'inject', seq: 1, time: 5 }], + nodes: [null, { seq: 1, cat: 'user', tokens: 10, text: 'talk', time: NaN }], + archive: { bogus: true }, + toolList: [null, { name: 'bash', tokens: 5 }], + droppedNodes: -3, + cost: ['not-a-record'], + surfaceFloor: 'x', +} +tr = renderView() // must not throw +assert.equal(byClass(tr, 'lc-bar').length, 1, 'the usable request entry still renders a trend bar') +assert.equal(byClass(tr, 'lc-stat-value').length, 7, 'stats board still renders') +const statValsM = byClass(tr, 'lc-stat-value').map(n => n.args[2]) +assert.equal(statValsM[0], '1', 'turns count survives (non-array requests degraded, entries filtered)') +assert.equal(statValsM[1], '1', 'steps count survives') +assert.equal(statValsM[5], '—', 'cache hit degrades to a dash') +assert.equal(statValsM[6], '—', 'cost degrades to a dash (non-record cost dropped)') +assert.equal(byClass(tr, 'lc-event').length, 1, 'the usable event entry still shows') +assert.equal(byClass(tr, 'lc-node').length, 1, 'the usable node entry still shows') +assert.equal(textOf(byClass(tr, 'lc-node-time')[0]), '—', 'an invalid timestamp shows a dash instead of throwing') +assert.equal(byClass(tr, 'lc-tool-chip').length, 1, 'the usable tool entry still shows') +assert.equal(byClass(tr, 'lc-br-cat-row').length, 6, 'the context browser still renders all six sections') + +// The /context modal degrades identically: sanitized empty data renders the +// dialog shell with full sections instead of throwing. +const brokenModalTree = evaluate(modalComponent({ + sessionId: 's1', + useProjection: (key) => (key === 'contextTimeline' + ? { current: 'x', requests: 5, nodes: null, toolList: 7, events: {}, archive: 'z' } + : undefined), + useContextModal: (sel) => sel(true), +})) +assert.equal(byClass(brokenModalTree, 'lc-modal-card').length, 1, 'the modal shell stays intact') +assert.equal(byClass(brokenModalTree, 'lc-br-cat-row').length, 6, 'the modal degrades to full sections with sanitized data') + +// A projection reader that THROWS (a broker/framework failure) lands in the +// error boundary's styled card — not a white screen — with the boundary +// protocol recording the offending error. +const boomTree = evaluate(viewComponent({ + sessionId: 's1', + useProjection: () => { throw new Error('boom-parse') }, +})) +assert.equal(byClass(boomTree, 'lc-error').length, 1, 'a throwing reader degrades to the error card') +assert.match(textOf(byClass(boomTree, 'lc-error')[0]), /ERR:.*boom-parse/, 'the card carries the offending message') +assert.equal(byClass(boomTree, 'lc-error-retry').length, 1, 'the card offers Retry') +const boundaryKey = [...classInstances.keys()].find(k => k.includes('ErrorBoundary')) +assert.ok(boundaryKey, 'boundary instance registered') +assert.match(classInstances.get(boundaryKey).state.error.message, /boom-parse/, 'the boundary protocol recorded the error') + +// restore state for any later tests +dataValue = snapshot +tr = renderView() +assert.equal(byClass(tr, 'lc-bar').length, 4, 'snapshot restored after the resilience tests') + +console.log('✔ backend-parse-failure resilience test passed (sanitized render of corrupt payloads, degraded modal, error boundary card)')