From 254382c268f77ae0c853010213bcb59e8436df7c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 17 Aug 2026 10:18:20 -0700 Subject: [PATCH 1/4] =?UTF-8?q?docs:=20Phase=203=20plan=20=E2=80=94=20A2UI?= =?UTF-8?q?=20validation=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../2026-08-17-a2ui-v09-phase3-checks.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-17-a2ui-v09-phase3-checks.md diff --git a/docs/superpowers/plans/2026-08-17-a2ui-v09-phase3-checks.md b/docs/superpowers/plans/2026-08-17-a2ui-v09-phase3-checks.md new file mode 100644 index 000000000..85ae4d8ce --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-a2ui-v09-phase3-checks.md @@ -0,0 +1,45 @@ +# A2UI v0.9 Phase 3 — Validation Checks Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement A2UI v0.9 client-side validation: `checks` rules on input components evaluate against **live user input**, failing checks display inline errors and block event actions, and the client emits the spec `error` message (`VALIDATION_FAILED`). + +**Architecture:** Correct `A2uiCheck` to the official `CheckRule` shape (`{condition: DynamicBoolean, message}`); add the validator functions (`required`/`regex`/`length`/`numeric`/`email`) to the Phase-2 registry; `` takes ownership of the render `StateStore` (seed-preserve pattern from `chat-generative-ui`) so event-time logic sees live values; at `a2ui:event` time the surface evaluates all checks against `dataModel ∪ store.getSnapshot()`, blocks + emits `A2uiErrorMessage` on failure, writes per-component messages to reserved store paths (`/_a2uiChecks/`) that catalog inputs bind for reactive display, and resolves `{path}` action-context values live (fixing the pre-existing stale-context gap). Base: main after #818. + +**Key discovered facts:** official `CheckRule` = `{condition, message}` (NOT `{call,args,message}` as typed in Phase 1 — unused, safe to correct); `render-spec` accepts a `[store]` input and `signalStateStore` exposes `getSnapshot()`; `invokeHandlers` passes action params raw (no dispatch-time binding resolution) — hence the surface must resolve live values itself. + +--- + +### Task 1: types + validators (`libs/a2ui`) + +**Files:** Modify `libs/a2ui/src/lib/types.ts` (+types.spec), `functions.ts` (+functions.spec), `index.ts`. + +- [ ] `A2uiCheck` → `{ condition: DynamicValue; message: string }` (spec CheckRule); update the `A2uiCheckable` JSDoc. +- [ ] Failing specs for validators (exact arg schemas from `scratchpad/basic-catalog.json`): `required {value}` (false for null/undefined/''/[] — true otherwise), `regex {value, pattern}` (RegExp.test, string value required, invalid pattern → false), `length {value, min?, max?}`, `numeric {value, min?, max?}` (accepts numeric strings), `email {value}` (linear-safe pattern). +- [ ] Implement in the standard registry. Green + commit. + +### Task 2: surface store ownership + live models + +**Files:** Modify `libs/chat/src/lib/a2ui/surface.component.ts` (+spec). + +- [ ] `` creates one internal `signalStateStore({})`, seeds it from `spec().state` with the seeded-map preserve-user-edits semantics (copy of `chat-generative-ui`), passes `[store]` to ``. +- [ ] Spec: user write survives a spec re-emission; agent update to an untouched path lands. + +### Task 3: live checks + event gating + error output + +**Files:** Modify `libs/chat/src/lib/a2ui/surface.component.ts` (+spec), `surface-to-spec.ts` (+spec), checkable catalog components (`text-field`, `check-box`, `choice-picker`, `slider`, `date-time-input`) + specs. + +- [ ] surface-to-spec: `{path}` **action-context** values stay as `{ $bindState: path }` markers (no build-time resolution); checkable components with `checks` (or TextField `validationRegexp` + path-bound value → synthesized regex rule) get `errorText: { $bindState: '/_a2uiChecks/' }` prop. +- [ ] surface.component `a2ui:event` handler: build live model = `{...surface.dataModel, ...store.getSnapshot()}` (deep merge by pointer for written paths); resolve `$bindState` markers in `params.context` from the live model; evaluate every component's check rules (`resolveDynamic(rule.condition, liveModel, undefined, registry) === true` passes); on failure: write each failing component's first message to `/_a2uiChecks/`, emit `error` output (`{version:'v0.9', error:{code:'VALIDATION_FAILED', surfaceId, path?, message}}`), do NOT emit the action; on success: clear `/_a2uiChecks/*` and emit as today. +- [ ] Catalog checkable components render `errorText` (small `--ds-*` error line + invalid styling) when non-empty. +- [ ] Specs cover: failing required check blocks + displays + emits error; fixing the value then re-clicking emits the action with the live context value. + +### Task 4: prompts + docs + api-docs + +**Files:** `examples/*/python/src/schemas/a2ui_v09.py` (byte-identical twins; add checks section with the CheckRule shape + validators), a2ui/chat docs pages that say checks are "typed but not enforced", `libs/a2ui/README.md`, `npm run generate-api-docs`. + +### Task 5: verification + PR + +- [ ] `nx run-many -t lint test build -p a2ui chat`; pytest twins; `nx affected -t lint test build`. +- [ ] Live Chrome smoke: prompt for a form with a required + email check; submit empty → inline error, no agent turn; fill valid → action round-trips with the typed values in context (verify via thread state). +- [ ] PR `feat(a2ui): validation checks + client error message (Phase 3)`; merge on green. From 87603a2c139dd7adf1242267b0ddc476aeee87c9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 17 Aug 2026 10:18:54 -0700 Subject: [PATCH 2/4] feat(a2ui): validator functions + spec-shaped CheckRule type (Phase 3) Co-Authored-By: Claude Fable 5 --- libs/a2ui/src/lib/functions.spec.ts | 53 +++++++++++++++++++++++++++++ libs/a2ui/src/lib/functions.ts | 47 +++++++++++++++++++++++++ libs/a2ui/src/lib/types.ts | 11 +++--- 3 files changed, 107 insertions(+), 4 deletions(-) diff --git a/libs/a2ui/src/lib/functions.spec.ts b/libs/a2ui/src/lib/functions.spec.ts index e4915c74e..0e10c2471 100644 --- a/libs/a2ui/src/lib/functions.spec.ts +++ b/libs/a2ui/src/lib/functions.spec.ts @@ -178,3 +178,56 @@ describe('registry behavior', () => { expect(run('not', { value: { call: 'and', args: { values: [true, true] } } })).toBe(false); }); }); + +describe('validators', () => { + test('required', () => { + expect(run('required', { value: 'x' })).toBe(true); + expect(run('required', { value: { path: '/name' } })).toBe(true); + expect(run('required', { value: '' })).toBe(false); + expect(run('required', { value: null })).toBe(false); + expect(run('required', { value: { path: '/missing' } })).toBe(false); + expect(run('required', { value: [] })).toBe(false); + expect(run('required', { value: ['a'] })).toBe(true); + expect(run('required', { value: 0 })).toBe(true); + expect(run('required', { value: false })).toBe(true); + }); + + test('regex', () => { + expect(run('regex', { value: 'abc-12', pattern: '^[a-z]+-\\d+$' })).toBe(true); + expect(run('regex', { value: 'nope', pattern: '^[a-z]+-\\d+$' })).toBe(false); + expect(run('regex', { value: 42, pattern: '\\d+' })).toBe(false); + expect(run('regex', { value: 'x', pattern: '(' })).toBe(false); // invalid pattern + }); + + test('length', () => { + expect(run('length', { value: 'hello', min: 2 })).toBe(true); + expect(run('length', { value: 'h', min: 2 })).toBe(false); + expect(run('length', { value: 'hello', max: 4 })).toBe(false); + expect(run('length', { value: 'hi', min: 1, max: 4 })).toBe(true); + expect(run('length', { value: 7, min: 1 })).toBe(false); + }); + + test('numeric', () => { + expect(run('numeric', { value: 5, min: 1, max: 10 })).toBe(true); + expect(run('numeric', { value: '5', min: 1 })).toBe(true); + expect(run('numeric', { value: 0, min: 1 })).toBe(false); + expect(run('numeric', { value: 11, max: 10 })).toBe(false); + expect(run('numeric', { value: 'abc', min: 0 })).toBe(false); + }); + + test('email', () => { + expect(run('email', { value: 'ada@example.com' })).toBe(true); + expect(run('email', { value: 'ada@sub.example.co' })).toBe(true); + expect(run('email', { value: 'not-an-email' })).toBe(false); + expect(run('email', { value: 'a@b' })).toBe(false); + expect(run('email', { value: '' })).toBe(false); + expect(run('email', { value: 7 })).toBe(false); + }); + + test('validators compose with logic functions in check conditions', () => { + expect(run('and', { values: [ + { call: 'required', args: { value: { path: '/name' } } }, + { call: 'length', args: { value: { path: '/name' }, min: 2 } }, + ] })).toBe(true); + }); +}); diff --git a/libs/a2ui/src/lib/functions.ts b/libs/a2ui/src/lib/functions.ts index 9fb7278b6..b27442a06 100644 --- a/libs/a2ui/src/lib/functions.ts +++ b/libs/a2ui/src/lib/functions.ts @@ -321,6 +321,53 @@ const STANDARD_FUNCTIONS: Record = { not(args, ctx) { return ctx.resolveArg(args['value']) !== true; }, + // --- Validators (check-rule conditions) --- + required(args, ctx) { + const value = ctx.resolveArg(args['value']); + if (value == null) return false; + if (typeof value === 'string') return value.length > 0; + if (Array.isArray(value)) return value.length > 0; + return true; + }, + regex(args, ctx) { + const value = ctx.resolveArg(args['value']); + const pattern = ctx.resolveArg(args['pattern']); + if (typeof value !== 'string' || typeof pattern !== 'string') return false; + try { + return new RegExp(pattern).test(value); + } catch { + return false; + } + }, + length(args, ctx) { + const value = ctx.resolveArg(args['value']); + if (typeof value !== 'string') return false; + const min = toNumber(ctx.resolveArg(args['min'])); + const max = toNumber(ctx.resolveArg(args['max'])); + if (min !== undefined && value.length < min) return false; + if (max !== undefined && value.length > max) return false; + return true; + }, + numeric(args, ctx) { + const value = toNumber(ctx.resolveArg(args['value'])); + if (value === undefined) return false; + const min = toNumber(ctx.resolveArg(args['min'])); + const max = toNumber(ctx.resolveArg(args['max'])); + if (min !== undefined && value < min) return false; + if (max !== undefined && value > max) return false; + return true; + }, + email(args, ctx) { + const value = ctx.resolveArg(args['value']); + if (typeof value !== 'string') return false; + // Linear-time shape check: local@domain.tld with a dotted domain. + const at = value.indexOf('@'); + if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return false; + const domain = value.slice(at + 1); + const dot = domain.lastIndexOf('.'); + if (dot <= 0 || dot === domain.length - 1) return false; + return !/\s/.test(value); + }, }; // formatString needs the registry that owns it to evaluate nested calls; diff --git a/libs/a2ui/src/lib/types.ts b/libs/a2ui/src/lib/types.ts index 5b734abcf..72d51664f 100644 --- a/libs/a2ui/src/lib/types.ts +++ b/libs/a2ui/src/lib/types.ts @@ -55,12 +55,15 @@ export interface A2uiFunctionAction { export type A2uiAction = A2uiEventAction | A2uiFunctionAction; -// --- Validation checks (typed in Phase 1, enforced in Phase 3) --- +// --- Validation checks (spec `CheckRule`) --- export interface A2uiCheck { - call: string; - args?: Record; - message?: string; + /** A DynamicBoolean — typically a validator function call (`required`, + * `regex`, `length`, `numeric`, `email`) or a logic combinator. The rule + * passes when the condition resolves to `true`. */ + condition: DynamicValue; + /** Error message displayed when the check fails. */ + message: string; } // --- Components (flat, discriminated by the `component` string) --- From 0081530ee9e1a74ad496e395f34af2d1b673ff72 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 17 Aug 2026 10:23:19 -0700 Subject: [PATCH 3/4] feat(chat): live validation gate, inline check errors, live action context (Phase 3) owns the render state store (seed-preserve semantics), so checks and {path} action-context values resolve against current user input; failing checks block the event, render inline messages via /_a2uiChecks/ bindings, and emit a VALIDATION_FAILED A2uiErrorMessage. Co-Authored-By: Claude Fable 5 --- .../lib/a2ui/catalog/check-box.component.ts | 12 +- .../a2ui/catalog/choice-picker.component.ts | 12 +- .../a2ui/catalog/date-time-input.component.ts | 12 +- .../src/lib/a2ui/catalog/slider.component.ts | 12 +- .../lib/a2ui/catalog/text-field.component.ts | 14 +- .../chat/src/lib/a2ui/surface-to-spec.spec.ts | 22 ++- libs/chat/src/lib/a2ui/surface-to-spec.ts | 36 +++- .../src/lib/a2ui/surface.component.spec.ts | 111 ++++++++++++ libs/chat/src/lib/a2ui/surface.component.ts | 161 +++++++++++++++++- 9 files changed, 376 insertions(+), 16 deletions(-) diff --git a/libs/chat/src/lib/a2ui/catalog/check-box.component.ts b/libs/chat/src/lib/a2ui/catalog/check-box.component.ts index b47189bb5..3df1b489c 100644 --- a/libs/chat/src/lib/a2ui/catalog/check-box.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/check-box.component.ts @@ -13,6 +13,9 @@ import { emitBinding } from './emit-binding'; {{ label() }} + @if (errorText()) { + + } `, styles: [` .a2ui-cb { @@ -29,7 +32,11 @@ import { emitBinding } from './emit-binding'; cursor: pointer; accent-color: var(--a2ui-primary); } - `], + .a2ui-check-error { + font-size: var(--a2ui-typography-label-size); + color: var(--a2ui-error, #d33d55); + } +`], }) export class A2uiCheckBoxComponent { private readonly host = injectRenderHost(); @@ -37,6 +44,9 @@ export class A2uiCheckBoxComponent { readonly label = input(''); /** v0.9 prop: boolean checked state. */ readonly value = input(false); + /** Live validation message written by the surface's check gate + * (bound to /_a2uiChecks/); empty when valid. */ + readonly errorText = input(''); readonly _bindings = input>({}); // Framework inputs required by the render harness. readonly bindings = input>({}); diff --git a/libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts b/libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts index 648468f5c..c0c906899 100644 --- a/libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts @@ -59,6 +59,9 @@ interface ResolvedOption { } } + @if (errorText()) { + + } `, styles: [` @@ -119,7 +122,11 @@ interface ResolvedOption { color: var(--a2ui-on-primary); border-color: var(--a2ui-primary); } - `], + .a2ui-check-error { + font-size: var(--a2ui-typography-label-size); + color: var(--a2ui-error, #d33d55); + } +`], }) export class A2uiChoicePickerComponent { private static _idCounter = 0; @@ -141,6 +148,9 @@ export class A2uiChoicePickerComponent { readonly displayStyle = input<'checkbox' | 'chips'>('checkbox'); /** v0.9 prop: when true, show a client-side option filter input. */ readonly filterable = input(false); + /** Live validation message written by the surface's check gate + * (bound to /_a2uiChecks/); empty when valid. */ + readonly errorText = input(''); readonly _bindings = input>({}); // Framework inputs required by the render harness. readonly bindings = input>({}); diff --git a/libs/chat/src/lib/a2ui/catalog/date-time-input.component.ts b/libs/chat/src/lib/a2ui/catalog/date-time-input.component.ts index cd17e613b..0c47ec4f4 100644 --- a/libs/chat/src/lib/a2ui/catalog/date-time-input.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/date-time-input.component.ts @@ -22,6 +22,9 @@ import { emitBinding } from './emit-binding'; class="a2ui-dti__input" (change)="onChange($event)" /> + @if (errorText()) { + + } `, styles: [` @@ -46,7 +49,11 @@ import { emitBinding } from './emit-binding'; outline-offset: 2px; border-color: var(--a2ui-primary); } - `], + .a2ui-check-error { + font-size: var(--a2ui-typography-label-size); + color: var(--a2ui-error, #d33d55); + } +`], }) export class A2uiDateTimeInputComponent { private static _idCounter = 0; @@ -65,6 +72,9 @@ export class A2uiDateTimeInputComponent { readonly min = input(undefined); /** v0.9 prop: ISO upper bound mapped to the native input's max. */ readonly max = input(undefined); + /** Live validation message written by the surface's check gate + * (bound to /_a2uiChecks/); empty when valid. */ + readonly errorText = input(''); readonly _bindings = input>({}); // Framework inputs required by the render harness. readonly bindings = input>({}); diff --git a/libs/chat/src/lib/a2ui/catalog/slider.component.ts b/libs/chat/src/lib/a2ui/catalog/slider.component.ts index 60f550dc3..050591801 100644 --- a/libs/chat/src/lib/a2ui/catalog/slider.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/slider.component.ts @@ -22,6 +22,9 @@ import { emitBinding } from './emit-binding'; [value]="value()" (input)="onInput($event)" /> + @if (errorText()) { + + } `, styles: [` @@ -36,7 +39,11 @@ import { emitBinding } from './emit-binding'; cursor: pointer; accent-color: var(--a2ui-primary); } - `], + .a2ui-check-error { + font-size: var(--a2ui-typography-label-size); + color: var(--a2ui-error, #d33d55); + } +`], }) export class A2uiSliderComponent { private static _idCounter = 0; @@ -51,6 +58,9 @@ export class A2uiSliderComponent { readonly min = input(0); /** v0.9 prop: upper bound. */ readonly max = input(100); + /** Live validation message written by the surface's check gate + * (bound to /_a2uiChecks/); empty when valid. */ + readonly errorText = input(''); readonly _bindings = input>({}); // Framework inputs required by the render harness. readonly bindings = input>({}); diff --git a/libs/chat/src/lib/a2ui/catalog/text-field.component.ts b/libs/chat/src/lib/a2ui/catalog/text-field.component.ts index 58f93f249..6518c512b 100644 --- a/libs/chat/src/lib/a2ui/catalog/text-field.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/text-field.component.ts @@ -44,6 +44,9 @@ const TYPE_MAP: Record = { (input)="onInput($event)" /> } + @if (errorText()) { + + } `, styles: [` @@ -69,7 +72,11 @@ const TYPE_MAP: Record = { outline-offset: 2px; border-color: var(--a2ui-primary); } - `], + .a2ui-check-error { + font-size: var(--a2ui-typography-label-size); + color: var(--a2ui-error, #d33d55); + } +`], }) export class A2uiTextFieldComponent { private static _idCounter = 0; @@ -83,8 +90,11 @@ export class A2uiTextFieldComponent { readonly placeholder = input(''); /** v0.9 prop: input variant (default 'shortText'). */ readonly variant = input('shortText'); - /** Stored but not yet enforced beyond the native pattern attribute. */ + /** Enforced by the surface's check gate as an implicit regex rule (plus the native pattern attribute). */ readonly validationRegexp = input(''); + /** Live validation message written by the surface's check gate + * (bound to /_a2uiChecks/); empty when valid. */ + readonly errorText = input(''); readonly _bindings = input>({}); // Framework inputs required by the render harness. readonly bindings = input>({}); diff --git a/libs/chat/src/lib/a2ui/surface-to-spec.spec.ts b/libs/chat/src/lib/a2ui/surface-to-spec.spec.ts index bcf07fd53..ab976d767 100644 --- a/libs/chat/src/lib/a2ui/surface-to-spec.spec.ts +++ b/libs/chat/src/lib/a2ui/surface-to-spec.spec.ts @@ -166,13 +166,16 @@ describe('surfaceToSpec (v0.9)', () => { }); }); - it('resolves action context path bindings against the data model', () => { + it('keeps action context path bindings as live $bindState markers', () => { + // The surface component substitutes the CURRENT store value at dispatch + // time so user edits reach the agent (build-time resolution would freeze + // the agent-seeded snapshot). const surface = makeSurface( [ c({ id: 'root', component: 'Column', children: ['btn'] }), c({ id: 'btn', component: 'Button', child: 'lbl', - action: { event: { name: 'submit', context: { email: { path: '/email' } } } }, + action: { event: { name: 'submit', context: { email: { path: '/email' }, formId: 'signup' } } }, }), c({ id: 'lbl', component: 'Text', text: 'Go' }), ], @@ -180,7 +183,20 @@ describe('surfaceToSpec (v0.9)', () => { ); const spec = surfaceToSpec(surface)!; const params = spec.elements['btn'].on!['click'].params; - expect(params['context']).toEqual({ email: 'alice@example.com' }); + expect(params['context']).toEqual({ email: { $bindState: '/email' }, formId: 'signup' }); + }); + + it('adds errorText bindings + state seeds for checkable components', () => { + const surface = makeSurface( + [c({ + id: 'root', component: 'TextField', label: 'Email', value: { path: '/email' }, + checks: [{ condition: { call: 'email', args: { value: { path: '/email' } } }, message: 'Invalid email' }], + })], + { email: '' }, + ); + const spec = surfaceToSpec(surface)!; + expect(spec.elements['root'].props['errorText']).toEqual({ $bindState: '/_a2uiChecks/root' }); + expect((spec.state as Record>)['_a2uiChecks']).toEqual({ root: '' }); }); it('functionCall actions wire to the local-action handler', () => { diff --git a/libs/chat/src/lib/a2ui/surface-to-spec.ts b/libs/chat/src/lib/a2ui/surface-to-spec.ts index 737b048c7..5f59a3342 100644 --- a/libs/chat/src/lib/a2ui/surface-to-spec.ts +++ b/libs/chat/src/lib/a2ui/surface-to-spec.ts @@ -48,7 +48,14 @@ function resolveAction( const resolvedContext: Record = {}; if (event.context && typeof event.context === 'object') { for (const [key, value] of Object.entries(event.context)) { - resolvedContext[key] = resolveDynamic(value, surface.dataModel, undefined, A2UI_FUNCTIONS); + if (isPathRef(value)) { + // Live marker: the surface component substitutes the CURRENT value + // (user edits included) from its state store at dispatch time — + // build-time resolution would freeze the agent-seeded snapshot. + resolvedContext[key] = { $bindState: value.path }; + } else { + resolvedContext[key] = resolveDynamic(value, surface.dataModel, undefined, A2UI_FUNCTIONS); + } } } return { @@ -118,6 +125,12 @@ export function surfaceToSpec(surface: A2uiSurface): Spec | null { resolvedProps['_bindings'] = bindings; } + // Checkable components surface their live validation message through a + // reserved store path the surface component writes on failed submits. + if (componentHasChecks(rawProps)) { + resolvedProps['errorText'] = { $bindState: `/_a2uiChecks/${id}` }; + } + const action = (rawProps as { action?: A2uiAction }).action; const on = resolveAction(action, surface, id); @@ -185,5 +198,24 @@ export function surfaceToSpec(surface: A2uiSurface): Spec | null { ? 'root' : (surface.components.keys().next().value as string); - return { root, elements, state: surface.dataModel } as Spec; + // Seed empty check messages so errorText $bindState bindings resolve + // (render-element defers mounting while any bound prop is undefined). + const checkSeeds: Record = {}; + for (const [id, comp] of surface.components) { + if (componentHasChecks(comp as unknown as Record)) checkSeeds[id] = ''; + } + const state = Object.keys(checkSeeds).length > 0 + ? { ...surface.dataModel, _a2uiChecks: { ...checkSeeds, ...(surface.dataModel['_a2uiChecks'] as Record ?? {}) } } + : surface.dataModel; + + return { root, elements, state } as Spec; +} + +/** True when the component carries validation rules the renderer enforces: + * explicit `checks`, or a TextField `validationRegexp` with a bound value. */ +export function componentHasChecks(raw: Record): boolean { + if (Array.isArray(raw['checks']) && raw['checks'].length > 0) return true; + return typeof raw['validationRegexp'] === 'string' + && raw['validationRegexp'].length > 0 + && isPathRef(raw['value']); } diff --git a/libs/chat/src/lib/a2ui/surface.component.spec.ts b/libs/chat/src/lib/a2ui/surface.component.spec.ts index 5744971bb..bcf15000d 100644 --- a/libs/chat/src/lib/a2ui/surface.component.spec.ts +++ b/libs/chat/src/lib/a2ui/surface.component.spec.ts @@ -84,3 +84,114 @@ describe('A2uiSurfaceComponent — nested children with real catalog (regression expect(fx.nativeElement.textContent).toContain('Hello'); }); }); + +describe('A2uiSurfaceComponent — validation gate + live context (Phase 3)', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [A2uiSurfaceComponent] })); + + function makeCheckedSurfaceState() { + const store = createA2uiSurfaceStore(); + store.apply({ version: 'v0.9', createSurface: { surfaceId: 's1', catalogId: 'basic' } } as never); + store.apply({ version: 'v0.9', updateComponents: { + surfaceId: 's1', + components: [ + { id: 'root', component: 'Column', children: ['email', 'go'] }, + { id: 'email', component: 'TextField', label: 'Email', value: { path: '/email' }, + checks: [ + { condition: { call: 'required', args: { value: { path: '/email' } } }, message: 'Email is required' }, + { condition: { call: 'email', args: { value: { path: '/email' } } }, message: 'Invalid email' }, + ] }, + { id: 'go', component: 'Button', child: 'go-lbl', + action: { event: { name: 'submit', context: { email: { path: '/email' } } } } }, + { id: 'go-lbl', component: 'Text', text: 'Send' }, + ], + } } as never); + store.apply({ version: 'v0.9', updateDataModel: { surfaceId: 's1', path: '/email', value: '' } } as never); + return store.surfaceState('s1')()!; + } + + it('blocks the action, writes the check message, and emits VALIDATION_FAILED when checks fail', () => { + const fx = TestBed.createComponent(A2uiSurfaceComponent); + fx.componentRef.setInput('state', makeCheckedSurfaceState()); + fx.componentRef.setInput('catalog', a2uiBasicCatalog()); + const actions: unknown[] = []; + const errors: { error: { code: string; message?: string } }[] = []; + fx.componentInstance.action.subscribe((a) => actions.push(a)); + fx.componentInstance.validationError.subscribe((e) => errors.push(e)); + fx.detectChanges(); + + const handler = fx.componentInstance.internalHandlers()['a2ui:event']; + const result = handler({ + surfaceId: 's1', sourceComponentId: 'go', name: 'submit', + context: { email: { $bindState: '/email' } }, + }); + + expect(result).toBeUndefined(); + expect(actions).toHaveLength(0); + expect(errors).toHaveLength(1); + expect(errors[0].error.code).toBe('VALIDATION_FAILED'); + expect(errors[0].error.message).toBe('Email is required'); + expect(fx.componentInstance.liveStore.get('/_a2uiChecks/email')).toBe('Email is required'); + }); + + it('emits the action with live-typed context values once checks pass', () => { + const fx = TestBed.createComponent(A2uiSurfaceComponent); + fx.componentRef.setInput('state', makeCheckedSurfaceState()); + fx.componentRef.setInput('catalog', a2uiBasicCatalog()); + const actions: { action: { context?: Record } }[] = []; + fx.componentInstance.action.subscribe((a) => actions.push(a)); + fx.detectChanges(); + + // Simulate the user typing into the bound TextField (writes the store). + fx.componentInstance.liveStore.set('/email', 'ada@example.com'); + + const handler = fx.componentInstance.internalHandlers()['a2ui:event']; + handler({ + surfaceId: 's1', sourceComponentId: 'go', name: 'submit', + context: { email: { $bindState: '/email' } }, + }); + + expect(actions).toHaveLength(1); + expect(actions[0].action.context).toEqual({ email: 'ada@example.com' }); + expect(fx.componentInstance.liveStore.get('/_a2uiChecks/email')).toBe(''); + }); + + it('enforces TextField validationRegexp as an implicit check', () => { + const store = createA2uiSurfaceStore(); + store.apply({ version: 'v0.9', createSurface: { surfaceId: 's2', catalogId: 'basic' } } as never); + store.apply({ version: 'v0.9', updateComponents: { + surfaceId: 's2', + components: [ + { id: 'root', component: 'Column', children: ['code'] }, + { id: 'code', component: 'TextField', label: 'Code', value: { path: '/code' }, + validationRegexp: '^[A-Z]{3}$' }, + ], + } } as never); + store.apply({ version: 'v0.9', updateDataModel: { surfaceId: 's2', path: '/code', value: 'nope' } } as never); + + const fx = TestBed.createComponent(A2uiSurfaceComponent); + fx.componentRef.setInput('state', store.surfaceState('s2')()!); + fx.componentRef.setInput('catalog', a2uiBasicCatalog()); + const errors: { error: { code: string } }[] = []; + fx.componentInstance.validationError.subscribe((e) => errors.push(e)); + fx.detectChanges(); + + const handler = fx.componentInstance.internalHandlers()['a2ui:event']; + const result = handler({ surfaceId: 's2', sourceComponentId: 'root', name: 'submit', context: {} }); + expect(result).toBeUndefined(); + expect(errors).toHaveLength(1); + expect(fx.componentInstance.liveStore.get('/_a2uiChecks/code')).toBe('Invalid format'); + }); + + it('preserves user edits in the live store across spec re-emissions', () => { + const state = makeCheckedSurfaceState(); + const fx = TestBed.createComponent(A2uiSurfaceComponent); + fx.componentRef.setInput('state', state); + fx.componentRef.setInput('catalog', a2uiBasicCatalog()); + fx.detectChanges(); + fx.componentInstance.liveStore.set('/email', 'user@typed.io'); + // Re-emit the same state (streaming re-materializes surfaces). + fx.componentRef.setInput('state', { ...state }); + fx.detectChanges(); + expect(fx.componentInstance.liveStore.get('/email')).toBe('user@typed.io'); + }); +}); diff --git a/libs/chat/src/lib/a2ui/surface.component.ts b/libs/chat/src/lib/a2ui/surface.component.ts index 6ed485f6f..17d38712f 100644 --- a/libs/chat/src/lib/a2ui/surface.component.ts +++ b/libs/chat/src/lib/a2ui/surface.component.ts @@ -1,12 +1,15 @@ // SPDX-License-Identifier: MIT import { - Component, computed, input, output, ChangeDetectionStrategy, Type, + Component, computed, effect, input, output, untracked, ChangeDetectionStrategy, Type, } from '@angular/core'; import { NgComponentOutlet } from '@angular/common'; -import type { A2uiSurface, A2uiActionMessage } from '@threadplane/a2ui'; -import { RenderSpecComponent, toRenderRegistry } from '@threadplane/render'; +import type { A2uiSurface, A2uiActionMessage, A2uiErrorMessage, A2uiCheck } from '@threadplane/a2ui'; +import { + A2UI_WIRE_VERSION, createA2uiFunctionRegistry, getByPointer, isPathRef, resolveDynamic, +} from '@threadplane/a2ui'; +import { RenderSpecComponent, toRenderRegistry, signalStateStore } from '@threadplane/render'; import type { ViewRegistry, RenderEvent } from '@threadplane/render'; -import { surfaceToSpec } from './surface-to-spec'; +import { surfaceToSpec, componentHasChecks } from './surface-to-spec'; import { buildA2uiActionMessage } from './build-action-message'; import { A2uiDefaultFallbackComponent } from './a2ui-default-fallback.component'; import type { A2uiSurfaceState } from './surface-store'; @@ -32,6 +35,7 @@ import type { A2uiViews } from './views'; @@ -68,6 +72,41 @@ export class A2uiSurfaceComponent { readonly surfaceFallback = input | undefined>(undefined); readonly events = output(); readonly action = output(); + /** Emitted when a submit is blocked by failing validation checks — + * the spec client → agent error message (code VALIDATION_FAILED). */ + readonly validationError = output(); + + /** Surface-owned live state store: `$bindState` props read it and input + * components write user edits into it, so event-time logic (checks, + * action context) sees CURRENT values instead of the agent-seeded + * snapshot. Seeded from spec.state with user edits preserved. Public so + * hosts (and tests) can read the live values of a rendered surface. */ + readonly liveStore = signalStateStore({}); + + /** Last value this component seeded per state path (see chat-generative-ui: + * distinguishes "still our seed — safe to overwrite" from "user edited"). */ + private readonly seeded = new Map(); + + constructor() { + effect(() => { + const s = this.spec(); + const state = s?.state as Record | undefined; + if (!state) return; + untracked(() => { + for (const [key, value] of Object.entries(state)) { + const path = key.startsWith('/') ? key : `/${key}`; + const current = this.liveStore.get(path); + const untouched = + current === undefined || + (this.seeded.has(path) && current === this.seeded.get(path)); + if (untouched) { + if (current !== value) this.liveStore.set(path, value); + this.seeded.set(path, value); + } + } + }); + }); + } /** Agent-set primary color from `createSurface.theme.primaryColor`. * Returns null when unset so the host binding doesn't override the @@ -119,7 +158,49 @@ export class A2uiSurfaceComponent { // a mismatched id is also bound. const surf = this.state()?.surface ?? this.surface(); if (!surf) return undefined; - const message = buildA2uiActionMessage(params, surf); + + // Live model: user edits in the store overlay the agent-seeded model. + const liveModel = this.mergedLiveModel(surf); + + // Validation gate: every check rule on the surface must pass before + // an event action dispatches (spec CheckRule semantics). + const failures = evaluateSurfaceChecks(surf, liveModel); + if (failures.length > 0) { + for (const f of failures) { + this.liveStore.set(`/_a2uiChecks/${f.componentId}`, f.message); + } + const first = failures[0]; + this.validationError.emit({ + version: A2UI_WIRE_VERSION, + error: { + code: 'VALIDATION_FAILED', + surfaceId: surf.surfaceId, + ...(first.path ? { path: first.path } : {}), + message: first.message, + }, + }); + return undefined; + } + // Clear any stale messages from a previous failed submit. + for (const [id, comp] of surf.components) { + if (componentHasChecks(comp as unknown as Record)) { + this.liveStore.set(`/_a2uiChecks/${id}`, ''); + } + } + + // Substitute live-context markers with current values. + const rawContext = (params['context'] as Record) ?? {}; + const context: Record = {}; + for (const [k, v] of Object.entries(rawContext)) { + if (v != null && typeof v === 'object' && '$bindState' in (v as Record)) { + const path = String((v as Record)['$bindState']); + context[k] = getByPointer(liveModel, path); + } else { + context[k] = v; + } + } + + const message = buildA2uiActionMessage({ ...params, context }, surf); this.action.emit(message); return message; }, @@ -144,4 +225,74 @@ export class A2uiSurfaceComponent { onRenderEvent(event: RenderEvent): void { this.events.emit(event); } + + /** Agent-seeded data model overlaid with the store's current state + * (user edits + check messages). Shallow per-key merge is sufficient: + * store snapshots hold whole top-level values written via pointers. */ + private mergedLiveModel(surf: A2uiSurface): Record { + const snapshot = this.liveStore.getSnapshot() as Record; + return deepOverlay(surf.dataModel, snapshot); + } +} + +/** Recursively overlay `top` onto `base` (plain objects merge; anything + * else in `top` wins). */ +function deepOverlay( + base: Record, + top: Record, +): Record { + const out: Record = { ...base }; + for (const [k, v] of Object.entries(top)) { + const prev = out[k]; + if ( + v != null && typeof v === 'object' && !Array.isArray(v) + && prev != null && typeof prev === 'object' && !Array.isArray(prev) + ) { + out[k] = deepOverlay(prev as Record, v as Record); + } else { + out[k] = v; + } + } + return out; +} + +const CHECK_FUNCTIONS = createA2uiFunctionRegistry(); + +interface CheckFailure { + componentId: string; + message: string; + /** Data-model pointer of the checked value, when determinable. */ + path?: string; +} + +/** Evaluate every check rule on the surface against the live model. + * A rule passes when its condition resolves to exactly `true`. TextField + * `validationRegexp` (with a bound value) contributes an implicit rule. */ +function evaluateSurfaceChecks( + surf: A2uiSurface, + liveModel: Record, +): CheckFailure[] { + const failures: CheckFailure[] = []; + for (const [id, comp] of surf.components) { + const raw = comp as unknown as Record; + const boundPath = isPathRef(raw['value']) ? (raw['value'] as { path: string }).path : undefined; + const rules: A2uiCheck[] = Array.isArray(raw['checks']) ? [...(raw['checks'] as A2uiCheck[])] : []; + if ( + typeof raw['validationRegexp'] === 'string' && raw['validationRegexp'].length > 0 && boundPath + ) { + rules.push({ + condition: { call: 'regex', args: { value: { path: boundPath }, pattern: raw['validationRegexp'] } }, + message: 'Invalid format', + }); + } + for (const rule of rules) { + if (!rule || typeof rule.message !== 'string') continue; + const passed = resolveDynamic(rule.condition, liveModel, undefined, CHECK_FUNCTIONS) === true; + if (!passed) { + failures.push({ componentId: id, message: rule.message, ...(boundPath ? { path: boundPath } : {}) }); + break; // first failing rule per component + } + } + } + return failures; } From 79570a310f2891abd7a431c5c217b79e7beda75a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 17 Aug 2026 10:24:01 -0700 Subject: [PATCH 4/4] =?UTF-8?q?docs(a2ui):=20validation=20checks=20are=20e?= =?UTF-8?q?nforced=20=E2=80=94=20prompts,=20guides,=20api-docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../content/docs/a2ui/api/api-docs.json | 16 +++---- .../content/docs/a2ui/reference/schema.mdx | 4 +- .../content/docs/chat/a2ui/catalog.mdx | 2 +- .../content/docs/chat/api/api-docs.json | 44 ++++++++++++++++++- apps/website/next-env.d.ts | 2 +- examples/ag-ui/python/src/schemas/a2ui_v09.py | 21 +++++++++ examples/chat/python/src/schemas/a2ui_v09.py | 21 +++++++++ 7 files changed, 94 insertions(+), 16 deletions(-) diff --git a/apps/website/content/docs/a2ui/api/api-docs.json b/apps/website/content/docs/a2ui/api/api-docs.json index ed322a41f..15fb63358 100644 --- a/apps/website/content/docs/a2ui/api/api-docs.json +++ b/apps/website/content/docs/a2ui/api/api-docs.json @@ -181,22 +181,16 @@ "description": "", "properties": [ { - "name": "args", - "type": "Record", - "description": "", - "optional": true - }, - { - "name": "call", - "type": "string", - "description": "", + "name": "condition", + "type": "unknown", + "description": "A DynamicBoolean — typically a validator function call (`required`,\n`regex`, `length`, `numeric`, `email`) or a logic combinator. The rule\npasses when the condition resolves to `true`.", "optional": false }, { "name": "message", "type": "string", - "description": "", - "optional": true + "description": "Error message displayed when the check fails.", + "optional": false } ], "examples": [] diff --git a/apps/website/content/docs/a2ui/reference/schema.mdx b/apps/website/content/docs/a2ui/reference/schema.mdx index a463bd7f8..aa666d5c2 100644 --- a/apps/website/content/docs/a2ui/reference/schema.mdx +++ b/apps/website/content/docs/a2ui/reference/schema.mdx @@ -83,7 +83,7 @@ interface A2uiComponentBase { } ``` -Input components additionally mix in `A2uiCheckable` (`checks?: A2uiCheck[]`) for client-side validation rules (typed now, enforced in a later phase). +Input components additionally mix in `A2uiCheckable` (`checks?: A2uiCheck[]`). Each rule is the spec `CheckRule` shape — `{ condition: DynamicBoolean, message: string }` — where `condition` is typically a validator call (`required`, `regex`, `length`, `numeric`, `email`) or a logic combination. Renderers evaluate rules against the live data model and block event actions while any rule fails. The basic-catalog component shapes are: @@ -130,7 +130,7 @@ Several fields are constrained to a fixed enum. Emit one of the listed values The union of the basic-catalog shapes is exported as `A2uiCatalogComponent`. The broader `A2uiComponent` also admits non-basic-catalog components (`A2uiComponentBase & Record`) — renderers treat unknown `component` strings as unrenderable and fall back gracefully. -The schema exposes `validationRegexp` on `TextField` and `checks` on input components, but validation execution is not implemented in this package. Treat schema fields as protocol data until a renderer wires behavior. +The schema exposes `validationRegexp` on `TextField` and `checks` on input components; this package supplies the validator functions in `createA2uiFunctionRegistry()`, and `@threadplane/chat`'s surface renderer enforces the rules (inline messages, blocked event actions, `VALIDATION_FAILED` error messages). ## Message envelopes diff --git a/apps/website/content/docs/chat/a2ui/catalog.mdx b/apps/website/content/docs/chat/a2ui/catalog.mdx index 36e6b75d1..808eebb43 100644 --- a/apps/website/content/docs/chat/a2ui/catalog.mdx +++ b/apps/website/content/docs/chat/a2ui/catalog.mdx @@ -431,7 +431,7 @@ Renders an HTML5 `