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:
1 parent
0a028ace6f
commit
925c9ccca6
10 files changed
+310
-16
No files matched your search
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
@@ -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) ----
|
||||
|
||||
@@ -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
@@ -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)')
|
||||
Reference in new issue
Block a user