Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 5 additions & 11 deletions apps/website/content/docs/a2ui/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -181,22 +181,16 @@
"description": "",
"properties": [
{
"name": "args",
"type": "Record<string, unknown>",
"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": []
Expand Down
4 changes: 2 additions & 2 deletions apps/website/content/docs/a2ui/reference/schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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<string, unknown>`) — 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

Expand Down
2 changes: 1 addition & 1 deletion apps/website/content/docs/chat/a2ui/catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ Renders an HTML5 `<audio>` element with native controls.

## Client-Side Functions

The v0.9 basic catalog defines typed client-side functions that can appear in `{ "call": ... }` dynamic values and `checks` rules: validation (`required`, `regex`, `length`, `numeric`, `email`), formatting (`formatString`, `formatNumber`, `formatCurrency`, `formatDate`, `pluralize`), logic (`and`, `or`, `not`), and `openUrl`. The formatting and logic functions (`formatString` with `${...}` interpolation, `formatNumber`, `formatCurrency`, `formatDate`, `pluralize`, `and`, `or`, `not`) **execute client-side** via `createA2uiFunctionRegistry()`, which `surfaceToSpec` applies to every dynamic value. `openUrl` runs as a local action through the surface component's built-in `a2ui:localAction` fallback (new tab, `noopener`). The validation functions (`required`, `regex`, `length`, `numeric`, `email`) are typed on the wire but not yet enforced — validation `checks` ship in an upcoming release.
The v0.9 basic catalog defines typed client-side functions that can appear in `{ "call": ... }` dynamic values and `checks` rules: validation (`required`, `regex`, `length`, `numeric`, `email`), formatting (`formatString`, `formatNumber`, `formatCurrency`, `formatDate`, `pluralize`), logic (`and`, `or`, `not`), and `openUrl`. The formatting and logic functions (`formatString` with `${...}` interpolation, `formatNumber`, `formatCurrency`, `formatDate`, `pluralize`, `and`, `or`, `not`) **execute client-side** via `createA2uiFunctionRegistry()`, which `surfaceToSpec` applies to every dynamic value. `openUrl` runs as a local action through the surface component's built-in `a2ui:localAction` fallback (new tab, `noopener`). The validation functions (`required`, `regex`, `length`, `numeric`, `email`) power `checks` rules (`{ condition, message }`) on input components: the surface evaluates every rule against the **live** data model (user edits included) when an event action fires — failing checks block the action, render `message` under the offending input, and emit a `VALIDATION_FAILED` error message through the surface component's `validationError` output. A `TextField.validationRegexp` with a bound value contributes an implicit regex rule.

## Component Summary

Expand Down
44 changes: 43 additions & 1 deletion apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,12 @@
"description": "",
"optional": false
},
{
"name": "errorText",
"type": "InputSignal<string>",
"description": "Live validation message written by the surface's check gate\n(bound to /_a2uiChecks/<id>); empty when valid.",
"optional": false
},
{
"name": "label",
"type": "InputSignal<string>",
Expand Down Expand Up @@ -259,6 +265,12 @@
"description": "v0.9 prop: render as 'checkbox' rows (default) or 'chips'.",
"optional": false
},
{
"name": "errorText",
"type": "InputSignal<string>",
"description": "Live validation message written by the surface's check gate\n(bound to /_a2uiChecks/<id>); empty when valid.",
"optional": false
},
{
"name": "filterable",
"type": "InputSignal<boolean>",
Expand Down Expand Up @@ -512,6 +524,12 @@
"description": "v0.9 prop: enableTime — include time portion.",
"optional": false
},
{
"name": "errorText",
"type": "InputSignal<string>",
"description": "Live validation message written by the surface's check gate\n(bound to /_a2uiChecks/<id>); empty when valid.",
"optional": false
},
{
"name": "htmlInputType",
"type": "Signal<string>",
Expand Down Expand Up @@ -998,6 +1016,12 @@
"description": "",
"optional": false
},
{
"name": "errorText",
"type": "InputSignal<string>",
"description": "Live validation message written by the surface's check gate\n(bound to /_a2uiChecks/<id>); empty when valid.",
"optional": false
},
{
"name": "label",
"type": "InputSignal<string>",
Expand Down Expand Up @@ -1088,6 +1112,12 @@
"description": "Merge built-in A2UI handlers with consumer-provided handlers.",
"optional": false
},
{
"name": "liveStore",
"type": "StateStore",
"description": "Surface-owned live state store: `$bindState` props read it and input\ncomponents write user edits into it, so event-time logic (checks,\naction context) sees CURRENT values instead of the agent-seeded\nsnapshot. Seeded from spec.state with user edits preserved. Public so\nhosts (and tests) can read the live values of a rendered surface.",
"optional": false
},
{
"name": "primaryColor",
"type": "Signal<string | null>",
Expand Down Expand Up @@ -1129,6 +1159,12 @@
"type": "InputSignal<Type<unknown> | undefined>",
"description": "Optional top-level placeholder when the surface has no components\nyet. Defaults to A2uiDefaultFallbackComponent.",
"optional": false
},
{
"name": "validationError",
"type": "OutputEmitterRef<A2uiErrorMessage>",
"description": "Emitted when a submit is blocked by failing validation checks —\nthe spec client → agent error message (code VALIDATION_FAILED).",
"optional": false
}
],
"methods": [
Expand Down Expand Up @@ -1309,6 +1345,12 @@
"description": "",
"optional": false
},
{
"name": "errorText",
"type": "InputSignal<string>",
"description": "Live validation message written by the surface's check gate\n(bound to /_a2uiChecks/<id>); empty when valid.",
"optional": false
},
{
"name": "htmlInputType",
"type": "Signal<string>",
Expand Down Expand Up @@ -1342,7 +1384,7 @@
{
"name": "validationRegexp",
"type": "InputSignal<string>",
"description": "Stored but not yet enforced beyond the native pattern attribute.",
"description": "Enforced by the surface's check gate as an implicit regex rule (plus the native pattern attribute).",
"optional": false
},
{
Expand Down
2 changes: 1 addition & 1 deletion apps/website/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./../../dist/apps/website/.next/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
45 changes: 45 additions & 0 deletions docs/superpowers/plans/2026-08-17-a2ui-v09-phase3-checks.md
Original file line number Diff line number Diff line change
@@ -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; `<a2ui-surface>` 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/<id>`) 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).

- [ ] `<a2ui-surface>` 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 `<render-spec>`.
- [ ] 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/<id>' }` 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/<id>`, 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.
21 changes: 21 additions & 0 deletions examples/ag-ui/python/src/schemas/a2ui_v09.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,27 @@
Action function (only inside {"functionCall": ...} actions): openUrl with
args {url} — opens the URL in a new tab.

### Validation checks

Input components (TextField, CheckBox, ChoicePicker, Slider, DateTimeInput)
accept a "checks" array of rules {"condition": DynamicBoolean, "message": str}.
The condition is typically a validator call — required {value}, regex {value,
pattern}, length {value, min?, max?} (min and/or max required), numeric
{value, min?, max?}, email {value} — or an and/or/not combination. The client
evaluates checks against the LIVE data model (including user edits) when an
event action fires: failing checks block the action, display "message" under
the offending input, and send the agent an error message
{"version":"v0.9","error":{"code":"VALIDATION_FAILED",...}} instead. Bind the
checked value to the same {"path": ...} as the input's value. Example:

{"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": "Enter a valid email address"}]}

### Children

Container components ("children" property) accept either:
Expand Down
21 changes: 21 additions & 0 deletions examples/chat/python/src/schemas/a2ui_v09.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,27 @@
Action function (only inside {"functionCall": ...} actions): openUrl with
args {url} — opens the URL in a new tab.

### Validation checks

Input components (TextField, CheckBox, ChoicePicker, Slider, DateTimeInput)
accept a "checks" array of rules {"condition": DynamicBoolean, "message": str}.
The condition is typically a validator call — required {value}, regex {value,
pattern}, length {value, min?, max?} (min and/or max required), numeric
{value, min?, max?}, email {value} — or an and/or/not combination. The client
evaluates checks against the LIVE data model (including user edits) when an
event action fires: failing checks block the action, display "message" under
the offending input, and send the agent an error message
{"version":"v0.9","error":{"code":"VALIDATION_FAILED",...}} instead. Bind the
checked value to the same {"path": ...} as the input's value. Example:

{"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": "Enter a valid email address"}]}

### Children

Container components ("children" property) accept either:
Expand Down
53 changes: 53 additions & 0 deletions libs/a2ui/src/lib/functions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading
Loading