diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/VendoredToolUi.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/VendoredToolUi.tsx index 0fafa6e5..eca181eb 100644 --- a/backend/apps/outputs/webapp_template/frontend/src/toolui/VendoredToolUi.tsx +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/VendoredToolUi.tsx @@ -1,6 +1,7 @@ import React, { Suspense, useEffect, useMemo, useRef, useState } from 'react'; import { useThemeMode } from '@/shared/styles/ThemeContext'; import { TOOL_UI_REGISTRY } from './registry'; +import { parseLeniently, type Gate } from './parseLeniently'; interface GuardProps { name: string; quiet?: boolean; children: React.ReactNode } @@ -41,104 +42,6 @@ interface VendoredToolUiProps { const warnedShapes = new Set(); -type Gate = - | { state: 'pending' } - | { state: 'ok'; parsed: Record } - | { state: 'bad'; problem: string }; - -/** Models pad payloads with invented keys; strip ONLY unrecognized-key issues and retry once, so - sloppiness self-heals while genuinely wrong shapes still fall back loudly. */ -function slugFor(label: unknown, i: number): string { - const t = typeof label === 'string' ? label.trim().toLowerCase().replace(/\s+/g, '-').slice(0, 40) : ''; - return t || `item-${i + 1}`; -} - -// Mechanical repairs for the mistakes agents actually make (numeric ids, ranked priorities, nested -// row objects, bare action objects). Only ever applied when the strict parse FAILED, and the result -// is re-validated, so a repair can flip fail->pass but never corrupt a valid payload. -function repairCommonAgentShapes(props: Record): Record { - let out: Record; - try { - out = JSON.parse(JSON.stringify(props ?? {}, (_k, v) => (v === undefined ? null : v))); - } catch { - return props; - } - const fixIdLabel = (arr: unknown): unknown => { - if (!Array.isArray(arr)) return arr; - return arr.map((o, i) => { - if (typeof o === 'string') return { id: slugFor(o, i), label: o }; - if (o && typeof o === 'object' && !Array.isArray(o)) { - const obj = { ...(o as Record) }; - // Agents reach for value/name/key and title/text as synonyms; honor them before inventing a slug. - if (obj.id == null || obj.id === '') obj.id = obj.value ?? obj.key ?? obj.name ?? null; - if (obj.id == null || obj.id === '') obj.id = slugFor(obj.label ?? obj.title ?? obj.text, i); - else if (typeof obj.id !== 'string') obj.id = String(obj.id); - if (typeof obj.label !== 'string' || !obj.label) obj.label = String(obj.label ?? obj.title ?? obj.text ?? obj.name ?? obj.id); - return obj; - } - return o; - }); - }; - if ('options' in out) out.options = fixIdLabel(out.options); - if ('actions' in out) { - if (out.actions && !Array.isArray(out.actions) && typeof out.actions === 'object' && 'label' in (out.actions as object)) out.actions = [out.actions]; - out.actions = fixIdLabel(out.actions); - } - const PRIORITY_SYNONYMS: Record = { '1': 'primary', '2': 'secondary', '3': 'tertiary', high: 'primary', medium: 'secondary', low: 'tertiary', primary: 'primary', secondary: 'secondary', tertiary: 'tertiary' }; - if (Array.isArray(out.columns)) { - out.columns = out.columns.map((c) => { - if (c && typeof c === 'object' && 'priority' in (c as object)) { - const mapped = PRIORITY_SYNONYMS[String((c as Record).priority).toLowerCase()]; - const copy = { ...(c as Record) }; - if (mapped) copy.priority = mapped; else delete copy.priority; - return copy; - } - return c; - }); - } - if (Array.isArray(out.data)) { - // Row arrays (instead of keyed objects) zip against the column keys, in order. - const colKeys = Array.isArray(out.columns) - ? (out.columns as Array>).map((c, i) => String((c && typeof c === 'object' ? (c.key ?? c.id ?? c.label) : c) ?? `col${i + 1}`)) - : null; - out.data = out.data.map((row) => { - if (Array.isArray(row) && colKeys && colKeys.length > 0) { - return Object.fromEntries(row.map((v, i) => [colKeys[i] ?? `col${i + 1}`, v])); - } - if (!row || typeof row !== 'object' || Array.isArray(row)) return row; - return Object.fromEntries(Object.entries(row as Record).map(([k, v]) => { - if (v !== null && typeof v === 'object' && !Array.isArray(v)) return [k, JSON.stringify(v)]; - if (Array.isArray(v)) return [k, v.map((x) => (x !== null && typeof x === 'object' ? JSON.stringify(x) : x))]; - return [k, v]; - })); - }); - } - return out; -} - -function parseLeniently(schema: { safeParse: (v: unknown) => any }, props: Record): Gate { - let result = schema.safeParse(props); - let base: Record = props; - if (!result.success) { - const issues: Array<{ code: string; keys?: string[]; path: Array; message: string }> = result.error.issues; - if (issues.every((i) => i.code === 'unrecognized_keys')) { - const cleaned: Record = { ...props }; - for (const issue of issues) { - for (const key of issue.keys || []) delete cleaned[key]; - } - base = cleaned; - result = schema.safeParse(cleaned); - } - } - if (!result.success) { - const repaired = schema.safeParse(repairCommonAgentShapes(base)); - if (repaired.success) return { state: 'ok', parsed: repaired.data as Record }; - } - if (result.success) return { state: 'ok', parsed: result.data as Record }; - const issues = result.error.issues.slice(0, 2).map((i: { path: Array; message: string }) => `${i.path.join('.')}: ${i.message}`).join('; '); - return { state: 'bad', problem: issues }; -} - // Rough resting height per component family so the loading skeleton reserves believable space // (Lobe/Open WebUI pattern: a breathing block where the card will land, not a tiny sliver). function skeletonHeightFor(name: string): number { diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/parseLeniently.test.ts b/backend/apps/outputs/webapp_template/frontend/src/toolui/parseLeniently.test.ts new file mode 100644 index 00000000..f6ea8bb4 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/parseLeniently.test.ts @@ -0,0 +1,86 @@ +// Run: npx tsx --test frontend/src/toolui/parseLeniently.test.ts +// Every case here is a payload shape a real agent emitted (Eric's 2026-08-09 console capture): +// widgets users should see were quietly falling back to classic rendering. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseLeniently } from './parseLeniently.ts'; +import { SerializableStatsDisplaySchema } from './components/stats-display/schema.ts'; +import { SerializableProgressTrackerSchema } from './components/progress-tracker/schema.ts'; +import { SerializableDataTableSchema } from './components/data-table/schema.ts'; + +test('stats-display: missing key and stringy diff.value repair to a valid payload', () => { + const gate = parseLeniently(SerializableStatsDisplaySchema, { + id: 'revenue-stats', + stats: [ + { label: 'Revenue', value: 1204, diff: { value: '+12%' } }, + { label: 'Churn', value: '2.1%', diff: '-0.4' }, + ], + }); + assert.equal(gate.state, 'ok'); + const stats = (gate as { parsed: { stats: Array> } }).parsed.stats; + assert.equal(stats[0].key, 'revenue'); + assert.deepEqual(stats[0].diff, { value: 12 }); + assert.deepEqual(stats[1].diff, { value: -0.4 }); +}); + +test('progress-tracker: missing step ids, duplicate labels, underscore statuses all repair', () => { + const gate = parseLeniently(SerializableProgressTrackerSchema, { + id: 'deploy-progress', + steps: [ + { label: 'Build', status: 'completed' }, + { label: 'Test', status: 'in_progress' }, + { label: 'Test', status: 'pending' }, + ], + }); + assert.equal(gate.state, 'ok'); + const steps = (gate as { parsed: { steps: Array> } }).parsed.steps; + assert.deepEqual(steps.map((s) => s.status), ['completed', 'in-progress', 'pending']); + assert.equal(new Set(steps.map((s) => s.id)).size, 3); +}); + +test('extra invented top-level keys AND repairable steps in the same payload still pass', () => { + const gate = parseLeniently(SerializableProgressTrackerSchema, { + id: 'combo', + caption: 'invented key the schema rejects', + steps: [{ label: 'Only step', status: 'done' }], + }); + assert.equal(gate.state, 'ok'); +}); + +test('data-table: a non-ISO receipt.at coerces to ISO; an unparseable one costs only the receipt', () => { + const base = { + id: 'orders', + columns: [{ key: 'name', label: 'Name' }], + data: [{ name: 'Widget' }], + }; + const coerced = parseLeniently(SerializableDataTableSchema, { + ...base, + receipt: { outcome: 'confirmed', summary: 'ok', at: '2026-08-08 14:30' }, + }); + assert.equal(coerced.state, 'ok'); + const rec = (coerced as { parsed: { receipt: { at: string; outcome: string } } }).parsed.receipt; + assert.ok(rec.at.endsWith('Z') && !Number.isNaN(Date.parse(rec.at))); + assert.equal(rec.outcome, 'success'); + const dropped = parseLeniently(SerializableDataTableSchema, { + ...base, + receipt: { outcome: 'confirmed', summary: 'ok', at: 'yesterday-ish' }, + }); + assert.equal(dropped.state, 'ok'); + assert.equal((dropped as { parsed: Record }).parsed.receipt, undefined); +}); + +test('a valid payload passes through byte-identical, no repair applied', () => { + const payload = { + id: 'clean', + stats: [{ key: 'a', label: 'A', value: 1, diff: { value: 2 } }], + }; + const gate = parseLeniently(SerializableStatsDisplaySchema, payload); + assert.equal(gate.state, 'ok'); + assert.deepEqual((gate as { parsed: Record }).parsed.stats, payload.stats); +}); + +test('a genuinely wrong shape still fails loudly with a readable problem', () => { + const gate = parseLeniently(SerializableStatsDisplaySchema, { id: 'nope', stats: [] }); + assert.equal(gate.state, 'bad'); + assert.ok((gate as { problem: string }).problem.length > 0); +}); diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/parseLeniently.ts b/backend/apps/outputs/webapp_template/frontend/src/toolui/parseLeniently.ts new file mode 100644 index 00000000..6ba8d1ad --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/parseLeniently.ts @@ -0,0 +1,175 @@ +// Lenient wire-payload validation for vendored tool-ui components: strict zod parse first, then +// mechanical repairs for the mistakes agents actually make, re-validated so a repair can flip +// fail->pass but never corrupt a valid payload. + +export type Gate = + | { state: 'pending' } + | { state: 'ok'; parsed: Record } + | { state: 'bad'; problem: string }; + +function slugFor(label: unknown, i: number): string { + const t = typeof label === 'string' ? label.trim().toLowerCase().replace(/\s+/g, '-').slice(0, 40) : ''; + return t || `item-${i + 1}`; +} + +// Agents write "+12%", "1,204", "$40" where schemas want a number; strip the dressing and parse. +function numFrom(v: unknown): number | null { + if (typeof v === 'number' && Number.isFinite(v)) return v; + if (typeof v === 'string') { + const n = parseFloat(v.replace(/[+,%$\s]/g, '')); + return Number.isFinite(n) ? n : null; + } + return null; +} + +// Mechanical repairs for the mistakes agents actually make (numeric ids, ranked priorities, nested +// row objects, bare action objects). Only ever applied when the strict parse FAILED, and the result +// is re-validated, so a repair can flip fail->pass but never corrupt a valid payload. +function repairCommonAgentShapes(props: Record): Record { + let out: Record; + try { + out = JSON.parse(JSON.stringify(props ?? {}, (_k, v) => (v === undefined ? null : v))); + } catch { + return props; + } + const fixIdLabel = (arr: unknown): unknown => { + if (!Array.isArray(arr)) return arr; + return arr.map((o, i) => { + if (typeof o === 'string') return { id: slugFor(o, i), label: o }; + if (o && typeof o === 'object' && !Array.isArray(o)) { + const obj = { ...(o as Record) }; + // Agents reach for value/name/key and title/text as synonyms; honor them before inventing a slug. + if (obj.id == null || obj.id === '') obj.id = obj.value ?? obj.key ?? obj.name ?? null; + if (obj.id == null || obj.id === '') obj.id = slugFor(obj.label ?? obj.title ?? obj.text, i); + else if (typeof obj.id !== 'string') obj.id = String(obj.id); + if (typeof obj.label !== 'string' || !obj.label) obj.label = String(obj.label ?? obj.title ?? obj.text ?? obj.name ?? obj.id); + return obj; + } + return o; + }); + }; + const dedupeIds = (arr: unknown): unknown => { + if (!Array.isArray(arr)) return arr; + const seen = new Map(); + return arr.map((o) => { + if (!o || typeof o !== 'object' || Array.isArray(o)) return o; + const obj = o as Record; + if (typeof obj.id !== 'string') return o; + const count = (seen.get(obj.id) ?? 0) + 1; + seen.set(obj.id, count); + return count === 1 ? o : { ...obj, id: `${obj.id}-${count}` }; + }); + }; + const STATUS_SYNONYMS: Record = { in_progress: 'in-progress', active: 'in-progress', running: 'in-progress', done: 'completed', complete: 'completed', error: 'failed', todo: 'pending', waiting: 'pending' }; + const fixStepStatus = (arr: unknown): unknown => { + if (!Array.isArray(arr)) return arr; + return arr.map((o) => { + if (!o || typeof o !== 'object' || Array.isArray(o)) return o; + const obj = o as Record; + const mapped = STATUS_SYNONYMS[String(obj.status ?? '').toLowerCase()]; + return mapped ? { ...obj, status: mapped } : o; + }); + }; + if ('options' in out) out.options = fixIdLabel(out.options); + if ('steps' in out) out.steps = fixStepStatus(dedupeIds(fixIdLabel(out.steps))); + if ('todos' in out) out.todos = dedupeIds(fixIdLabel(out.todos)); + if (Array.isArray(out.stats)) { + out.stats = out.stats.map((s, i) => { + if (!s || typeof s !== 'object' || Array.isArray(s)) return s; + const st = { ...(s as Record) }; + if (typeof st.key !== 'string' || !st.key) st.key = typeof st.id === 'string' && st.id ? st.id : slugFor(st.label ?? st.name ?? st.title, i); + if (typeof st.label !== 'string' || !st.label) st.label = String(st.name ?? st.title ?? st.key); + if (st.diff != null && typeof st.diff !== 'object') st.diff = { value: st.diff }; + if (st.diff && typeof st.diff === 'object' && !Array.isArray(st.diff)) { + const d = { ...(st.diff as Record) }; + const n = numFrom(d.value); + if (n === null) delete st.diff; + else { d.value = n; st.diff = d; } + } + return st; + }); + } + const RECEIPT_OUTCOMES: Record = { success: 'success', confirmed: 'success', ok: 'success', done: 'success', complete: 'success', completed: 'success', sent: 'success', partial: 'partial', failed: 'failed', error: 'failed', failure: 'failed', cancelled: 'cancelled', canceled: 'cancelled' }; + const receipt = out.receipt as Record | null | undefined; + if (receipt && typeof receipt === 'object' && !Array.isArray(receipt)) { + const fixed = { ...receipt }; + const outcome = RECEIPT_OUTCOMES[String(fixed.outcome ?? '').toLowerCase()]; + if (outcome) fixed.outcome = outcome; + const t = typeof fixed.at === 'string' ? Date.parse(fixed.at) : NaN; + if (!Number.isNaN(t)) fixed.at = new Date(t).toISOString(); + // A receipt that still can't validate costs the optional chip, never the whole widget. + if (!outcome || Number.isNaN(t)) delete out.receipt; + else out.receipt = fixed; + } + if ('actions' in out) { + if (out.actions && !Array.isArray(out.actions) && typeof out.actions === 'object' && 'label' in (out.actions as object)) out.actions = [out.actions]; + out.actions = fixIdLabel(out.actions); + } + const PRIORITY_SYNONYMS: Record = { '1': 'primary', '2': 'secondary', '3': 'tertiary', high: 'primary', medium: 'secondary', low: 'tertiary', primary: 'primary', secondary: 'secondary', tertiary: 'tertiary' }; + if (Array.isArray(out.columns)) { + out.columns = out.columns.map((c) => { + if (c && typeof c === 'object' && 'priority' in (c as object)) { + const mapped = PRIORITY_SYNONYMS[String((c as Record).priority).toLowerCase()]; + const copy = { ...(c as Record) }; + if (mapped) copy.priority = mapped; else delete copy.priority; + return copy; + } + return c; + }); + } + if (Array.isArray(out.data)) { + // Row arrays (instead of keyed objects) zip against the column keys, in order. + const colKeys = Array.isArray(out.columns) + ? (out.columns as Array>).map((c, i) => String((c && typeof c === 'object' ? (c.key ?? c.id ?? c.label) : c) ?? `col${i + 1}`)) + : null; + out.data = out.data.map((row) => { + if (Array.isArray(row) && colKeys && colKeys.length > 0) { + return Object.fromEntries(row.map((v, i) => [colKeys[i] ?? `col${i + 1}`, v])); + } + if (!row || typeof row !== 'object' || Array.isArray(row)) return row; + return Object.fromEntries(Object.entries(row as Record).map(([k, v]) => { + if (v !== null && typeof v === 'object' && !Array.isArray(v)) return [k, JSON.stringify(v)]; + if (Array.isArray(v)) return [k, v.map((x) => (x !== null && typeof x === 'object' ? JSON.stringify(x) : x))]; + return [k, v]; + })); + }); + } + return out; +} + +// Drop top-level keys the schema rejects (models pad payloads with invented ones) and reparse. +// Runs even when OTHER issues coexist, so a payload with extra keys AND a repairable shape can +// still be saved by the repair pass instead of failing on the first problem it happens to hit. +function stripUnrecognizedKeys(props: Record, issues: Array<{ code: string; keys?: string[] }>): Record | null { + const unrecognized = issues.filter((i) => i.code === 'unrecognized_keys'); + if (unrecognized.length === 0) return null; + const cleaned: Record = { ...props }; + for (const issue of unrecognized) { + for (const key of issue.keys || []) delete cleaned[key]; + } + return cleaned; +} + +export function parseLeniently(schema: { safeParse: (v: unknown) => any }, props: Record): Gate { + let result = schema.safeParse(props); + let base: Record = props; + if (!result.success) { + const cleaned = stripUnrecognizedKeys(props, result.error.issues); + if (cleaned) { + base = cleaned; + result = schema.safeParse(cleaned); + } + } + if (!result.success) { + const repairedBase = repairCommonAgentShapes(base); + let repaired = schema.safeParse(repairedBase); + if (!repaired.success) { + const cleaned = stripUnrecognizedKeys(repairedBase, repaired.error.issues); + if (cleaned) repaired = schema.safeParse(cleaned); + } + if (repaired.success) return { state: 'ok', parsed: repaired.data as Record }; + } + if (result.success) return { state: 'ok', parsed: result.data as Record }; + const issues = result.error.issues.slice(0, 2).map((i: { path: Array; message: string }) => `${i.path.join('.')}: ${i.message}`).join('; '); + return { state: 'bad', problem: issues }; +} diff --git a/frontend/src/toolui/VendoredToolUi.tsx b/frontend/src/toolui/VendoredToolUi.tsx index 0fafa6e5..eca181eb 100644 --- a/frontend/src/toolui/VendoredToolUi.tsx +++ b/frontend/src/toolui/VendoredToolUi.tsx @@ -1,6 +1,7 @@ import React, { Suspense, useEffect, useMemo, useRef, useState } from 'react'; import { useThemeMode } from '@/shared/styles/ThemeContext'; import { TOOL_UI_REGISTRY } from './registry'; +import { parseLeniently, type Gate } from './parseLeniently'; interface GuardProps { name: string; quiet?: boolean; children: React.ReactNode } @@ -41,104 +42,6 @@ interface VendoredToolUiProps { const warnedShapes = new Set(); -type Gate = - | { state: 'pending' } - | { state: 'ok'; parsed: Record } - | { state: 'bad'; problem: string }; - -/** Models pad payloads with invented keys; strip ONLY unrecognized-key issues and retry once, so - sloppiness self-heals while genuinely wrong shapes still fall back loudly. */ -function slugFor(label: unknown, i: number): string { - const t = typeof label === 'string' ? label.trim().toLowerCase().replace(/\s+/g, '-').slice(0, 40) : ''; - return t || `item-${i + 1}`; -} - -// Mechanical repairs for the mistakes agents actually make (numeric ids, ranked priorities, nested -// row objects, bare action objects). Only ever applied when the strict parse FAILED, and the result -// is re-validated, so a repair can flip fail->pass but never corrupt a valid payload. -function repairCommonAgentShapes(props: Record): Record { - let out: Record; - try { - out = JSON.parse(JSON.stringify(props ?? {}, (_k, v) => (v === undefined ? null : v))); - } catch { - return props; - } - const fixIdLabel = (arr: unknown): unknown => { - if (!Array.isArray(arr)) return arr; - return arr.map((o, i) => { - if (typeof o === 'string') return { id: slugFor(o, i), label: o }; - if (o && typeof o === 'object' && !Array.isArray(o)) { - const obj = { ...(o as Record) }; - // Agents reach for value/name/key and title/text as synonyms; honor them before inventing a slug. - if (obj.id == null || obj.id === '') obj.id = obj.value ?? obj.key ?? obj.name ?? null; - if (obj.id == null || obj.id === '') obj.id = slugFor(obj.label ?? obj.title ?? obj.text, i); - else if (typeof obj.id !== 'string') obj.id = String(obj.id); - if (typeof obj.label !== 'string' || !obj.label) obj.label = String(obj.label ?? obj.title ?? obj.text ?? obj.name ?? obj.id); - return obj; - } - return o; - }); - }; - if ('options' in out) out.options = fixIdLabel(out.options); - if ('actions' in out) { - if (out.actions && !Array.isArray(out.actions) && typeof out.actions === 'object' && 'label' in (out.actions as object)) out.actions = [out.actions]; - out.actions = fixIdLabel(out.actions); - } - const PRIORITY_SYNONYMS: Record = { '1': 'primary', '2': 'secondary', '3': 'tertiary', high: 'primary', medium: 'secondary', low: 'tertiary', primary: 'primary', secondary: 'secondary', tertiary: 'tertiary' }; - if (Array.isArray(out.columns)) { - out.columns = out.columns.map((c) => { - if (c && typeof c === 'object' && 'priority' in (c as object)) { - const mapped = PRIORITY_SYNONYMS[String((c as Record).priority).toLowerCase()]; - const copy = { ...(c as Record) }; - if (mapped) copy.priority = mapped; else delete copy.priority; - return copy; - } - return c; - }); - } - if (Array.isArray(out.data)) { - // Row arrays (instead of keyed objects) zip against the column keys, in order. - const colKeys = Array.isArray(out.columns) - ? (out.columns as Array>).map((c, i) => String((c && typeof c === 'object' ? (c.key ?? c.id ?? c.label) : c) ?? `col${i + 1}`)) - : null; - out.data = out.data.map((row) => { - if (Array.isArray(row) && colKeys && colKeys.length > 0) { - return Object.fromEntries(row.map((v, i) => [colKeys[i] ?? `col${i + 1}`, v])); - } - if (!row || typeof row !== 'object' || Array.isArray(row)) return row; - return Object.fromEntries(Object.entries(row as Record).map(([k, v]) => { - if (v !== null && typeof v === 'object' && !Array.isArray(v)) return [k, JSON.stringify(v)]; - if (Array.isArray(v)) return [k, v.map((x) => (x !== null && typeof x === 'object' ? JSON.stringify(x) : x))]; - return [k, v]; - })); - }); - } - return out; -} - -function parseLeniently(schema: { safeParse: (v: unknown) => any }, props: Record): Gate { - let result = schema.safeParse(props); - let base: Record = props; - if (!result.success) { - const issues: Array<{ code: string; keys?: string[]; path: Array; message: string }> = result.error.issues; - if (issues.every((i) => i.code === 'unrecognized_keys')) { - const cleaned: Record = { ...props }; - for (const issue of issues) { - for (const key of issue.keys || []) delete cleaned[key]; - } - base = cleaned; - result = schema.safeParse(cleaned); - } - } - if (!result.success) { - const repaired = schema.safeParse(repairCommonAgentShapes(base)); - if (repaired.success) return { state: 'ok', parsed: repaired.data as Record }; - } - if (result.success) return { state: 'ok', parsed: result.data as Record }; - const issues = result.error.issues.slice(0, 2).map((i: { path: Array; message: string }) => `${i.path.join('.')}: ${i.message}`).join('; '); - return { state: 'bad', problem: issues }; -} - // Rough resting height per component family so the loading skeleton reserves believable space // (Lobe/Open WebUI pattern: a breathing block where the card will land, not a tiny sliver). function skeletonHeightFor(name: string): number { diff --git a/frontend/src/toolui/parseLeniently.test.ts b/frontend/src/toolui/parseLeniently.test.ts new file mode 100644 index 00000000..f6ea8bb4 --- /dev/null +++ b/frontend/src/toolui/parseLeniently.test.ts @@ -0,0 +1,86 @@ +// Run: npx tsx --test frontend/src/toolui/parseLeniently.test.ts +// Every case here is a payload shape a real agent emitted (Eric's 2026-08-09 console capture): +// widgets users should see were quietly falling back to classic rendering. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseLeniently } from './parseLeniently.ts'; +import { SerializableStatsDisplaySchema } from './components/stats-display/schema.ts'; +import { SerializableProgressTrackerSchema } from './components/progress-tracker/schema.ts'; +import { SerializableDataTableSchema } from './components/data-table/schema.ts'; + +test('stats-display: missing key and stringy diff.value repair to a valid payload', () => { + const gate = parseLeniently(SerializableStatsDisplaySchema, { + id: 'revenue-stats', + stats: [ + { label: 'Revenue', value: 1204, diff: { value: '+12%' } }, + { label: 'Churn', value: '2.1%', diff: '-0.4' }, + ], + }); + assert.equal(gate.state, 'ok'); + const stats = (gate as { parsed: { stats: Array> } }).parsed.stats; + assert.equal(stats[0].key, 'revenue'); + assert.deepEqual(stats[0].diff, { value: 12 }); + assert.deepEqual(stats[1].diff, { value: -0.4 }); +}); + +test('progress-tracker: missing step ids, duplicate labels, underscore statuses all repair', () => { + const gate = parseLeniently(SerializableProgressTrackerSchema, { + id: 'deploy-progress', + steps: [ + { label: 'Build', status: 'completed' }, + { label: 'Test', status: 'in_progress' }, + { label: 'Test', status: 'pending' }, + ], + }); + assert.equal(gate.state, 'ok'); + const steps = (gate as { parsed: { steps: Array> } }).parsed.steps; + assert.deepEqual(steps.map((s) => s.status), ['completed', 'in-progress', 'pending']); + assert.equal(new Set(steps.map((s) => s.id)).size, 3); +}); + +test('extra invented top-level keys AND repairable steps in the same payload still pass', () => { + const gate = parseLeniently(SerializableProgressTrackerSchema, { + id: 'combo', + caption: 'invented key the schema rejects', + steps: [{ label: 'Only step', status: 'done' }], + }); + assert.equal(gate.state, 'ok'); +}); + +test('data-table: a non-ISO receipt.at coerces to ISO; an unparseable one costs only the receipt', () => { + const base = { + id: 'orders', + columns: [{ key: 'name', label: 'Name' }], + data: [{ name: 'Widget' }], + }; + const coerced = parseLeniently(SerializableDataTableSchema, { + ...base, + receipt: { outcome: 'confirmed', summary: 'ok', at: '2026-08-08 14:30' }, + }); + assert.equal(coerced.state, 'ok'); + const rec = (coerced as { parsed: { receipt: { at: string; outcome: string } } }).parsed.receipt; + assert.ok(rec.at.endsWith('Z') && !Number.isNaN(Date.parse(rec.at))); + assert.equal(rec.outcome, 'success'); + const dropped = parseLeniently(SerializableDataTableSchema, { + ...base, + receipt: { outcome: 'confirmed', summary: 'ok', at: 'yesterday-ish' }, + }); + assert.equal(dropped.state, 'ok'); + assert.equal((dropped as { parsed: Record }).parsed.receipt, undefined); +}); + +test('a valid payload passes through byte-identical, no repair applied', () => { + const payload = { + id: 'clean', + stats: [{ key: 'a', label: 'A', value: 1, diff: { value: 2 } }], + }; + const gate = parseLeniently(SerializableStatsDisplaySchema, payload); + assert.equal(gate.state, 'ok'); + assert.deepEqual((gate as { parsed: Record }).parsed.stats, payload.stats); +}); + +test('a genuinely wrong shape still fails loudly with a readable problem', () => { + const gate = parseLeniently(SerializableStatsDisplaySchema, { id: 'nope', stats: [] }); + assert.equal(gate.state, 'bad'); + assert.ok((gate as { problem: string }).problem.length > 0); +}); diff --git a/frontend/src/toolui/parseLeniently.ts b/frontend/src/toolui/parseLeniently.ts new file mode 100644 index 00000000..6ba8d1ad --- /dev/null +++ b/frontend/src/toolui/parseLeniently.ts @@ -0,0 +1,175 @@ +// Lenient wire-payload validation for vendored tool-ui components: strict zod parse first, then +// mechanical repairs for the mistakes agents actually make, re-validated so a repair can flip +// fail->pass but never corrupt a valid payload. + +export type Gate = + | { state: 'pending' } + | { state: 'ok'; parsed: Record } + | { state: 'bad'; problem: string }; + +function slugFor(label: unknown, i: number): string { + const t = typeof label === 'string' ? label.trim().toLowerCase().replace(/\s+/g, '-').slice(0, 40) : ''; + return t || `item-${i + 1}`; +} + +// Agents write "+12%", "1,204", "$40" where schemas want a number; strip the dressing and parse. +function numFrom(v: unknown): number | null { + if (typeof v === 'number' && Number.isFinite(v)) return v; + if (typeof v === 'string') { + const n = parseFloat(v.replace(/[+,%$\s]/g, '')); + return Number.isFinite(n) ? n : null; + } + return null; +} + +// Mechanical repairs for the mistakes agents actually make (numeric ids, ranked priorities, nested +// row objects, bare action objects). Only ever applied when the strict parse FAILED, and the result +// is re-validated, so a repair can flip fail->pass but never corrupt a valid payload. +function repairCommonAgentShapes(props: Record): Record { + let out: Record; + try { + out = JSON.parse(JSON.stringify(props ?? {}, (_k, v) => (v === undefined ? null : v))); + } catch { + return props; + } + const fixIdLabel = (arr: unknown): unknown => { + if (!Array.isArray(arr)) return arr; + return arr.map((o, i) => { + if (typeof o === 'string') return { id: slugFor(o, i), label: o }; + if (o && typeof o === 'object' && !Array.isArray(o)) { + const obj = { ...(o as Record) }; + // Agents reach for value/name/key and title/text as synonyms; honor them before inventing a slug. + if (obj.id == null || obj.id === '') obj.id = obj.value ?? obj.key ?? obj.name ?? null; + if (obj.id == null || obj.id === '') obj.id = slugFor(obj.label ?? obj.title ?? obj.text, i); + else if (typeof obj.id !== 'string') obj.id = String(obj.id); + if (typeof obj.label !== 'string' || !obj.label) obj.label = String(obj.label ?? obj.title ?? obj.text ?? obj.name ?? obj.id); + return obj; + } + return o; + }); + }; + const dedupeIds = (arr: unknown): unknown => { + if (!Array.isArray(arr)) return arr; + const seen = new Map(); + return arr.map((o) => { + if (!o || typeof o !== 'object' || Array.isArray(o)) return o; + const obj = o as Record; + if (typeof obj.id !== 'string') return o; + const count = (seen.get(obj.id) ?? 0) + 1; + seen.set(obj.id, count); + return count === 1 ? o : { ...obj, id: `${obj.id}-${count}` }; + }); + }; + const STATUS_SYNONYMS: Record = { in_progress: 'in-progress', active: 'in-progress', running: 'in-progress', done: 'completed', complete: 'completed', error: 'failed', todo: 'pending', waiting: 'pending' }; + const fixStepStatus = (arr: unknown): unknown => { + if (!Array.isArray(arr)) return arr; + return arr.map((o) => { + if (!o || typeof o !== 'object' || Array.isArray(o)) return o; + const obj = o as Record; + const mapped = STATUS_SYNONYMS[String(obj.status ?? '').toLowerCase()]; + return mapped ? { ...obj, status: mapped } : o; + }); + }; + if ('options' in out) out.options = fixIdLabel(out.options); + if ('steps' in out) out.steps = fixStepStatus(dedupeIds(fixIdLabel(out.steps))); + if ('todos' in out) out.todos = dedupeIds(fixIdLabel(out.todos)); + if (Array.isArray(out.stats)) { + out.stats = out.stats.map((s, i) => { + if (!s || typeof s !== 'object' || Array.isArray(s)) return s; + const st = { ...(s as Record) }; + if (typeof st.key !== 'string' || !st.key) st.key = typeof st.id === 'string' && st.id ? st.id : slugFor(st.label ?? st.name ?? st.title, i); + if (typeof st.label !== 'string' || !st.label) st.label = String(st.name ?? st.title ?? st.key); + if (st.diff != null && typeof st.diff !== 'object') st.diff = { value: st.diff }; + if (st.diff && typeof st.diff === 'object' && !Array.isArray(st.diff)) { + const d = { ...(st.diff as Record) }; + const n = numFrom(d.value); + if (n === null) delete st.diff; + else { d.value = n; st.diff = d; } + } + return st; + }); + } + const RECEIPT_OUTCOMES: Record = { success: 'success', confirmed: 'success', ok: 'success', done: 'success', complete: 'success', completed: 'success', sent: 'success', partial: 'partial', failed: 'failed', error: 'failed', failure: 'failed', cancelled: 'cancelled', canceled: 'cancelled' }; + const receipt = out.receipt as Record | null | undefined; + if (receipt && typeof receipt === 'object' && !Array.isArray(receipt)) { + const fixed = { ...receipt }; + const outcome = RECEIPT_OUTCOMES[String(fixed.outcome ?? '').toLowerCase()]; + if (outcome) fixed.outcome = outcome; + const t = typeof fixed.at === 'string' ? Date.parse(fixed.at) : NaN; + if (!Number.isNaN(t)) fixed.at = new Date(t).toISOString(); + // A receipt that still can't validate costs the optional chip, never the whole widget. + if (!outcome || Number.isNaN(t)) delete out.receipt; + else out.receipt = fixed; + } + if ('actions' in out) { + if (out.actions && !Array.isArray(out.actions) && typeof out.actions === 'object' && 'label' in (out.actions as object)) out.actions = [out.actions]; + out.actions = fixIdLabel(out.actions); + } + const PRIORITY_SYNONYMS: Record = { '1': 'primary', '2': 'secondary', '3': 'tertiary', high: 'primary', medium: 'secondary', low: 'tertiary', primary: 'primary', secondary: 'secondary', tertiary: 'tertiary' }; + if (Array.isArray(out.columns)) { + out.columns = out.columns.map((c) => { + if (c && typeof c === 'object' && 'priority' in (c as object)) { + const mapped = PRIORITY_SYNONYMS[String((c as Record).priority).toLowerCase()]; + const copy = { ...(c as Record) }; + if (mapped) copy.priority = mapped; else delete copy.priority; + return copy; + } + return c; + }); + } + if (Array.isArray(out.data)) { + // Row arrays (instead of keyed objects) zip against the column keys, in order. + const colKeys = Array.isArray(out.columns) + ? (out.columns as Array>).map((c, i) => String((c && typeof c === 'object' ? (c.key ?? c.id ?? c.label) : c) ?? `col${i + 1}`)) + : null; + out.data = out.data.map((row) => { + if (Array.isArray(row) && colKeys && colKeys.length > 0) { + return Object.fromEntries(row.map((v, i) => [colKeys[i] ?? `col${i + 1}`, v])); + } + if (!row || typeof row !== 'object' || Array.isArray(row)) return row; + return Object.fromEntries(Object.entries(row as Record).map(([k, v]) => { + if (v !== null && typeof v === 'object' && !Array.isArray(v)) return [k, JSON.stringify(v)]; + if (Array.isArray(v)) return [k, v.map((x) => (x !== null && typeof x === 'object' ? JSON.stringify(x) : x))]; + return [k, v]; + })); + }); + } + return out; +} + +// Drop top-level keys the schema rejects (models pad payloads with invented ones) and reparse. +// Runs even when OTHER issues coexist, so a payload with extra keys AND a repairable shape can +// still be saved by the repair pass instead of failing on the first problem it happens to hit. +function stripUnrecognizedKeys(props: Record, issues: Array<{ code: string; keys?: string[] }>): Record | null { + const unrecognized = issues.filter((i) => i.code === 'unrecognized_keys'); + if (unrecognized.length === 0) return null; + const cleaned: Record = { ...props }; + for (const issue of unrecognized) { + for (const key of issue.keys || []) delete cleaned[key]; + } + return cleaned; +} + +export function parseLeniently(schema: { safeParse: (v: unknown) => any }, props: Record): Gate { + let result = schema.safeParse(props); + let base: Record = props; + if (!result.success) { + const cleaned = stripUnrecognizedKeys(props, result.error.issues); + if (cleaned) { + base = cleaned; + result = schema.safeParse(cleaned); + } + } + if (!result.success) { + const repairedBase = repairCommonAgentShapes(base); + let repaired = schema.safeParse(repairedBase); + if (!repaired.success) { + const cleaned = stripUnrecognizedKeys(repairedBase, repaired.error.issues); + if (cleaned) repaired = schema.safeParse(cleaned); + } + if (repaired.success) return { state: 'ok', parsed: repaired.data as Record }; + } + if (result.success) return { state: 'ok', parsed: result.data as Record }; + const issues = result.error.issues.slice(0, 2).map((i: { path: Array; message: string }) => `${i.path.join('.')}: ${i.message}`).join('; '); + return { state: 'bad', problem: issues }; +}