fix(client): no white screen on malformed backend timeline payload

Sanitize the contextTimeline projection into a render-safe shape at the
services boundary (collections -> arrays, non-object entries dropped,
current -> numeric breakdown, wrong-typed scalars dropped) so a corrupt
or failed host payload degrades to empty sections instead of throwing
during render, keeping every usable piece of data visible.

Wrap the Context tab and /context dialog bodies in a React error
boundary (styled error card + Retry) as the backstop for any residual
render error; harden fmtTime against invalid timestamps, headersOf
against malformed epochs, and the cache-hit/cost math against NaN.

Add client regression tests: corrupt payloads render with usable data,
the modal degrades, and a throwing projection reader lands in the error
card.
This commit is contained in:
bowenliang123 committed 2026-08-21 23:12:51 +08:00
1 parent 0a028ace6f
commit 925c9ccca6
10 files changed
+310 -16

No files matched your search

+12 -2
View File
@@ -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
</div>
)
}
return function ContextModal(props: ContextModalProps): ReactNS.ReactElement | null {
return h(ErrorBoundary, null, h(ContextModalBody, props))
}
}
+16 -2
View File
@@ -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
</div>
)
}
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))
}
}
+52
View File
@@ -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 (
<div className="lc-root">
<div className="lc-empty lc-error">
<span>{t('error')}</span>
<code className="lc-error-msg">{error.message}</code>
<button
type="button"
className="lc-error-retry"
onClick={() => { this.setState({ error: null }) }}
>{t('error.retry')}</button>
</div>
</div>
)
}
}
}
+8 -3
View File
@@ -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')}`
}
+2 -1
View File
@@ -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
}
}
+5 -1
View File
@@ -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 })
}
+2
View File
@@ -54,6 +54,7 @@ export const DICT_ZH: Record<string, string> = {
'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<string, string> = {
'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',
+92 -4
View File
@@ -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<string, Record<string, string>>): () => void
@@ -98,8 +98,84 @@ function asRecord<T>(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<ContextTimeline>(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<T>(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<ContextTimeline>(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<string, unknown>)[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<string, unknown> = 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<Toke
* (request-header content epochs). Absent key = an older Host half without
* the companion unit — the Context browser degrades its system/tools
* sections to tokens-only with a note.
*
* Entry-level shape is checked too: a malformed epoch (corrupt payload with
* a missing tools list or wrong-typed system prompt) would crash the
* browser's tools/sections reads, so the WHOLE projection degrades to null
* and the card falls back to its tokens-only note.
*/
export function headersOf(value: unknown): ContextHeaders | null {
const headers = asRecord<ContextHeaders>(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) ----
+7
View File
@@ -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; }',
+114 -3
View File
@@ -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)')